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