bitwarden_vault/cipher/cipher_client/
mod.rs

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