bitwarden_vault/cipher/
cipher_client.rs

1use bitwarden_core::{key_management::SymmetricKeyId, Client, OrganizationId};
2use bitwarden_crypto::{CompositeEncryptable, IdentifyKey, SymmetricCryptoKey};
3#[cfg(feature = "wasm")]
4use bitwarden_encoding::B64;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::*;
7
8use super::EncryptionContext;
9use crate::{
10    cipher::cipher::DecryptCipherListResult, Cipher, CipherError, CipherListView, CipherView,
11    DecryptError, EncryptError, Fido2CredentialFullView,
12};
13
14#[allow(missing_docs)]
15#[cfg_attr(feature = "wasm", wasm_bindgen)]
16pub struct CiphersClient {
17    pub(crate) client: Client,
18}
19
20#[cfg_attr(feature = "wasm", wasm_bindgen)]
21impl CiphersClient {
22    #[allow(missing_docs)]
23    pub fn encrypt(&self, mut cipher_view: CipherView) -> Result<EncryptionContext, EncryptError> {
24        let user_id = self
25            .client
26            .internal
27            .get_user_id()
28            .ok_or(EncryptError::MissingUserId)?;
29        let key_store = self.client.internal.get_key_store();
30
31        // TODO: Once this flag is removed, the key generation logic should
32        // be moved directly into the KeyEncryptable implementation
33        if cipher_view.key.is_none()
34            && self
35                .client
36                .internal
37                .get_flags()
38                .enable_cipher_key_encryption
39        {
40            let key = cipher_view.key_identifier();
41            cipher_view.generate_cipher_key(&mut key_store.context(), key)?;
42        }
43
44        let cipher = key_store.encrypt(cipher_view)?;
45        Ok(EncryptionContext {
46            cipher,
47            encrypted_for: user_id,
48        })
49    }
50
51    /// Encrypt a cipher with the provided key. This should only be used when rotating encryption
52    /// keys in the Web client.
53    ///
54    /// Until key rotation is fully implemented in the SDK, this method must be provided the new
55    /// symmetric key in base64 format. See PM-23084
56    ///
57    /// If the cipher has a CipherKey, it will be re-encrypted with the new key.
58    /// If the cipher does not have a CipherKey and CipherKeyEncryption is enabled, one will be
59    /// generated using the new key. Otherwise, the cipher's data will be encrypted with the new
60    /// key directly.
61    #[cfg(feature = "wasm")]
62    pub fn encrypt_cipher_for_rotation(
63        &self,
64        mut cipher_view: CipherView,
65        new_key: B64,
66    ) -> Result<EncryptionContext, CipherError> {
67        let new_key = SymmetricCryptoKey::try_from(new_key)?;
68
69        let user_id = self
70            .client
71            .internal
72            .get_user_id()
73            .ok_or(EncryptError::MissingUserId)?;
74        let key_store = self.client.internal.get_key_store();
75        let mut ctx = key_store.context();
76
77        // Set the new key in the key store context
78        const NEW_KEY_ID: SymmetricKeyId = SymmetricKeyId::Local("new_cipher_key");
79        #[allow(deprecated)]
80        ctx.set_symmetric_key(NEW_KEY_ID, new_key)?;
81
82        if cipher_view.key.is_none()
83            && self
84                .client
85                .internal
86                .get_flags()
87                .enable_cipher_key_encryption
88        {
89            cipher_view.generate_cipher_key(&mut ctx, NEW_KEY_ID)?;
90        } else {
91            cipher_view.reencrypt_cipher_keys(&mut ctx, NEW_KEY_ID)?;
92        }
93
94        let cipher = cipher_view.encrypt_composite(&mut ctx, NEW_KEY_ID)?;
95
96        Ok(EncryptionContext {
97            cipher,
98            encrypted_for: user_id,
99        })
100    }
101
102    #[allow(missing_docs)]
103    pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
104        let key_store = self.client.internal.get_key_store();
105        let cipher_view = key_store.decrypt(&cipher)?;
106        Ok(cipher_view)
107    }
108
109    #[allow(missing_docs)]
110    pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
111        let key_store = self.client.internal.get_key_store();
112        let cipher_views = key_store.decrypt_list(&ciphers)?;
113        Ok(cipher_views)
114    }
115
116    /// Decrypt cipher list with failures
117    /// Returns both successfully decrypted ciphers and any that failed to decrypt
118    pub fn decrypt_list_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherListResult {
119        let key_store = self.client.internal.get_key_store();
120        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
121
122        DecryptCipherListResult {
123            successes,
124            failures: failures.into_iter().cloned().collect(),
125        }
126    }
127
128    #[allow(missing_docs)]
129    pub fn decrypt_fido2_credentials(
130        &self,
131        cipher_view: CipherView,
132    ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
133        let key_store = self.client.internal.get_key_store();
134        let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
135        Ok(credentials)
136    }
137
138    /// Temporary method used to re-encrypt FIDO2 credentials for a cipher view.
139    /// Necessary until the TS clients utilize the SDK entirely for FIDO2 credentials management.
140    /// TS clients create decrypted FIDO2 credentials that need to be encrypted manually when
141    /// encrypting the rest of the CipherView.
142    /// TODO: Remove once TS passkey provider implementation uses SDK - PM-8313
143    #[cfg(feature = "wasm")]
144    pub fn set_fido2_credentials(
145        &self,
146        mut cipher_view: CipherView,
147        fido2_credentials: Vec<Fido2CredentialFullView>,
148    ) -> Result<CipherView, CipherError> {
149        let key_store = self.client.internal.get_key_store();
150
151        cipher_view.set_new_fido2_credentials(&mut key_store.context(), fido2_credentials)?;
152
153        Ok(cipher_view)
154    }
155
156    #[allow(missing_docs)]
157    pub fn move_to_organization(
158        &self,
159        mut cipher_view: CipherView,
160        organization_id: OrganizationId,
161    ) -> Result<CipherView, CipherError> {
162        let key_store = self.client.internal.get_key_store();
163        cipher_view.move_to_organization(&mut key_store.context(), organization_id)?;
164        Ok(cipher_view)
165    }
166
167    #[cfg(feature = "wasm")]
168    #[allow(missing_docs)]
169    pub fn decrypt_fido2_private_key(
170        &self,
171        cipher_view: CipherView,
172    ) -> Result<String, CipherError> {
173        let key_store = self.client.internal.get_key_store();
174        let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
175        Ok(decrypted_key)
176    }
177}
178
179#[cfg(test)]
180mod tests {
181
182    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
183    use bitwarden_crypto::CryptoError;
184
185    use super::*;
186    use crate::{Attachment, CipherRepromptType, CipherType, Login, LoginView, VaultClientExt};
187
188    fn test_cipher() -> Cipher {
189        Cipher {
190            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
191            organization_id: None,
192            folder_id: None,
193            collection_ids: vec![],
194            key: None,
195            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
196            notes: None,
197            r#type: CipherType::Login,
198            login: Some(Login{
199                username: None,
200                password: None,
201                password_revision_date: None,
202                uris:None,
203                totp: None,
204                autofill_on_page_load: None,
205                fido2_credentials: None,
206            }),
207            identity: None,
208            card: None,
209            secure_note: None,
210            ssh_key: None,
211            favorite: false,
212            reprompt: CipherRepromptType::None,
213            organization_use_totp: true,
214            edit: true,
215            permissions: None,
216            view_password: true,
217            local_data: None,
218            attachments: None,
219            fields:  None,
220            password_history: None,
221            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
222            deleted_date: None,
223            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
224            archived_date: None,
225        }
226    }
227
228    fn test_cipher_view() -> CipherView {
229        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
230        CipherView {
231            r#type: CipherType::Login,
232            login: Some(LoginView {
233                username: Some("test_username".to_string()),
234                password: Some("test_password".to_string()),
235                password_revision_date: None,
236                uris: None,
237                totp: None,
238                autofill_on_page_load: None,
239                fido2_credentials: None,
240            }),
241            id: Some(test_id),
242            organization_id: None,
243            folder_id: None,
244            collection_ids: vec![],
245            key: None,
246            name: "My test login".to_string(),
247            notes: None,
248            identity: None,
249            card: None,
250            secure_note: None,
251            ssh_key: None,
252            favorite: false,
253            reprompt: CipherRepromptType::None,
254            organization_use_totp: true,
255            edit: true,
256            permissions: None,
257            view_password: true,
258            local_data: None,
259            attachments: None,
260            fields: None,
261            password_history: None,
262            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
263            deleted_date: None,
264            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
265            archived_date: None,
266        }
267    }
268
269    fn test_attachment_legacy() -> Attachment {
270        Attachment {
271            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
272            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
273            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
274            key: None,
275            size: Some("65".to_string()),
276            size_name: Some("65 Bytes".to_string()),
277        }
278    }
279
280    fn test_attachment_v2() -> Attachment {
281        Attachment {
282            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
283            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
284            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
285            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
286            size: Some("65".to_string()),
287            size_name: Some("65 Bytes".to_string()),
288        }
289    }
290
291    #[tokio::test]
292    async fn test_decrypt_list() {
293        let client = Client::init_test_account(test_bitwarden_com_account()).await;
294
295        let dec = client
296            .vault()
297            .ciphers()
298            .decrypt_list(vec![Cipher {
299                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
300                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
301                folder_id: None,
302                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
303                key: None,
304                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
305                notes: None,
306                r#type: CipherType::Login,
307                login: Some(Login{
308                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
309                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
310                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
311                identity: None,
312                card: None,
313                secure_note: None,
314                ssh_key: None,
315                favorite: false,
316                reprompt: CipherRepromptType::None,
317                organization_use_totp: true,
318                edit: true,
319                permissions: None,
320                view_password: true,
321                local_data: None,
322                attachments: None,
323                fields:  None,
324                password_history: None,
325                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
326                deleted_date: None,
327                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
328                archived_date: None,
329            }])
330
331            .unwrap();
332
333        assert_eq!(dec[0].name, "Test item");
334    }
335
336    #[tokio::test]
337    async fn test_decrypt_list_with_failures_all_success() {
338        let client = Client::init_test_account(test_bitwarden_com_account()).await;
339
340        let valid_cipher = test_cipher();
341
342        let result = client
343            .vault()
344            .ciphers()
345            .decrypt_list_with_failures(vec![valid_cipher]);
346
347        assert_eq!(result.successes.len(), 1);
348        assert!(result.failures.is_empty());
349        assert_eq!(result.successes[0].name, "234234");
350    }
351
352    #[tokio::test]
353    async fn test_decrypt_list_with_failures_mixed_results() {
354        let client = Client::init_test_account(test_bitwarden_com_account()).await;
355        let valid_cipher = test_cipher();
356        let mut invalid_cipher = test_cipher();
357        // Set an invalid encryptedkey to cause decryption failure
358        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
359
360        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
361
362        let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
363
364        assert_eq!(result.successes.len(), 1);
365        assert_eq!(result.failures.len(), 1);
366
367        assert_eq!(result.successes[0].name, "234234");
368    }
369
370    #[tokio::test]
371    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
372        let client = Client::init_test_account(test_bitwarden_com_account()).await;
373
374        let mut cipher = test_cipher();
375        cipher.attachments = Some(vec![test_attachment_legacy()]);
376
377        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
378
379        //  Move cipher to organization
380        let res = client.vault().ciphers().move_to_organization(
381            view,
382            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
383        );
384
385        assert!(res.is_err());
386    }
387
388    #[tokio::test]
389    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
390        let client = Client::init_test_account(test_bitwarden_com_account()).await;
391
392        let mut cipher = test_cipher();
393        let attachment = test_attachment_legacy();
394        cipher.attachments = Some(vec![attachment.clone()]);
395
396        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
397
398        assert!(cipher.key.is_none());
399
400        // Assert the cipher has a key, and the attachment is still readable
401        let EncryptionContext {
402            cipher: new_cipher,
403            encrypted_for: _,
404        } = client.vault().ciphers().encrypt(view).unwrap();
405        assert!(new_cipher.key.is_some());
406
407        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
408        let attachments = view.clone().attachments.unwrap();
409        let attachment_view = attachments.first().unwrap().clone();
410        assert!(attachment_view.key.is_none());
411
412        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
413
414        let buf = vec![
415            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
416            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
417            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
418            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
419        ];
420
421        let content = client
422            .vault()
423            .attachments()
424            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
425            .unwrap();
426
427        assert_eq!(content, b"Hello");
428    }
429
430    #[tokio::test]
431    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
432        let client = Client::init_test_account(test_bitwarden_com_account()).await;
433
434        let mut cipher = test_cipher();
435        let attachment = test_attachment_v2();
436        cipher.attachments = Some(vec![attachment.clone()]);
437
438        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
439
440        assert!(cipher.key.is_none());
441
442        // Assert the cipher has a key, and the attachment is still readable
443        let EncryptionContext {
444            cipher: new_cipher,
445            encrypted_for: _,
446        } = client.vault().ciphers().encrypt(view).unwrap();
447        assert!(new_cipher.key.is_some());
448
449        let view = client
450            .vault()
451            .ciphers()
452            .decrypt(new_cipher.clone())
453            .unwrap();
454        let attachments = view.clone().attachments.unwrap();
455        let attachment_view = attachments.first().unwrap().clone();
456        assert!(attachment_view.key.is_some());
457
458        // Ensure attachment key is updated since it's now protected by the cipher key
459        assert_ne!(
460            attachment.clone().key.unwrap().to_string(),
461            attachment_view.clone().key.unwrap().to_string()
462        );
463
464        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
465
466        let buf = vec![
467            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
468            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
469            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
470            188, 196, 170, 127, 67, 44, 129, 90,
471        ];
472
473        let content = client
474            .vault()
475            .attachments()
476            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
477            .unwrap();
478
479        assert_eq!(content, b"Hello");
480
481        // Move cipher to organization
482        let new_view = client
483            .vault()
484            .ciphers()
485            .move_to_organization(
486                view,
487                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
488            )
489            .unwrap();
490        let EncryptionContext {
491            cipher: new_cipher,
492            encrypted_for: _,
493        } = client.vault().ciphers().encrypt(new_view).unwrap();
494
495        let attachment = new_cipher
496            .clone()
497            .attachments
498            .unwrap()
499            .first()
500            .unwrap()
501            .clone();
502
503        // Ensure attachment key is still the same since it's protected by the cipher key
504        assert_eq!(
505            attachment.clone().key.as_ref().unwrap().to_string(),
506            attachment_view.key.as_ref().unwrap().to_string()
507        );
508
509        let content = client
510            .vault()
511            .attachments()
512            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
513            .unwrap();
514
515        assert_eq!(content, b"Hello");
516    }
517
518    #[tokio::test]
519    async fn test_encrypt_cipher_for_rotation() {
520        let client = Client::init_test_account(test_bitwarden_com_account()).await;
521
522        let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
523
524        let cipher_view = test_cipher_view();
525        let new_key_b64 = new_key.to_base64();
526
527        let ctx = client
528            .vault()
529            .ciphers()
530            .encrypt_cipher_for_rotation(cipher_view, new_key_b64)
531            .unwrap();
532
533        assert!(ctx.cipher.key.is_some());
534
535        // Decrypting the cipher "normally" will fail because it was encrypted with a new key
536        assert!(matches!(
537            client.vault().ciphers().decrypt(ctx.cipher).err(),
538            Some(DecryptError::Crypto(CryptoError::InvalidMac))
539        ));
540    }
541}