bitwarden_vault/cipher/cipher_client/
mod.rs

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