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