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.client.platform().state().get::<Cipher>()?)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208
209    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
210    #[cfg(feature = "wasm")]
211    use bitwarden_crypto::CryptoError;
212
213    use super::*;
214    use crate::{Attachment, CipherRepromptType, CipherType, Login, LoginView, VaultClientExt};
215
216    fn test_cipher() -> Cipher {
217        Cipher {
218            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
219            organization_id: None,
220            folder_id: None,
221            collection_ids: vec![],
222            key: None,
223            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
224            notes: None,
225            r#type: CipherType::Login,
226            login: Some(Login{
227                username: None,
228                password: None,
229                password_revision_date: None,
230                uris:None,
231                totp: None,
232                autofill_on_page_load: None,
233                fido2_credentials: None,
234            }),
235            identity: None,
236            card: None,
237            secure_note: None,
238            ssh_key: None,
239            favorite: false,
240            reprompt: CipherRepromptType::None,
241            organization_use_totp: true,
242            edit: true,
243            permissions: None,
244            view_password: true,
245            local_data: None,
246            attachments: None,
247            fields:  None,
248            password_history: None,
249            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
250            deleted_date: None,
251            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
252            archived_date: None,
253            data: None,
254        }
255    }
256
257    fn test_cipher_view() -> CipherView {
258        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
259        CipherView {
260            r#type: CipherType::Login,
261            login: Some(LoginView {
262                username: Some("test_username".to_string()),
263                password: Some("test_password".to_string()),
264                password_revision_date: None,
265                uris: None,
266                totp: None,
267                autofill_on_page_load: None,
268                fido2_credentials: None,
269            }),
270            id: Some(test_id),
271            organization_id: None,
272            folder_id: None,
273            collection_ids: vec![],
274            key: None,
275            name: "My test login".to_string(),
276            notes: None,
277            identity: None,
278            card: None,
279            secure_note: None,
280            ssh_key: None,
281            favorite: false,
282            reprompt: CipherRepromptType::None,
283            organization_use_totp: true,
284            edit: true,
285            permissions: None,
286            view_password: true,
287            local_data: None,
288            attachments: None,
289            attachment_decryption_failures: None,
290            fields: None,
291            password_history: None,
292            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
293            deleted_date: None,
294            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
295            archived_date: None,
296        }
297    }
298
299    fn test_attachment_legacy() -> Attachment {
300        Attachment {
301            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
302            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
303            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
304            key: None,
305            size: Some("65".to_string()),
306            size_name: Some("65 Bytes".to_string()),
307        }
308    }
309
310    fn test_attachment_v2() -> Attachment {
311        Attachment {
312            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
313            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
314            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
315            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
316            size: Some("65".to_string()),
317            size_name: Some("65 Bytes".to_string()),
318        }
319    }
320
321    #[tokio::test]
322    async fn test_decrypt_list() {
323        let client = Client::init_test_account(test_bitwarden_com_account()).await;
324
325        let dec = client
326            .vault()
327            .ciphers()
328            .decrypt_list(vec![Cipher {
329                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
330                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
331                folder_id: None,
332                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
333                key: None,
334                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
335                notes: None,
336                r#type: CipherType::Login,
337                login: Some(Login{
338                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
339                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
340                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
341                identity: None,
342                card: None,
343                secure_note: None,
344                ssh_key: None,
345                favorite: false,
346                reprompt: CipherRepromptType::None,
347                organization_use_totp: true,
348                edit: true,
349                permissions: None,
350                view_password: true,
351                local_data: None,
352                attachments: None,
353                fields:  None,
354                password_history: None,
355                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
356                deleted_date: None,
357                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
358                archived_date: None,
359                data: None,
360            }])
361
362            .unwrap();
363
364        assert_eq!(dec[0].name, "Test item");
365    }
366
367    #[tokio::test]
368    async fn test_decrypt_list_with_failures_all_success() {
369        let client = Client::init_test_account(test_bitwarden_com_account()).await;
370
371        let valid_cipher = test_cipher();
372
373        let result = client
374            .vault()
375            .ciphers()
376            .decrypt_list_with_failures(vec![valid_cipher]);
377
378        assert_eq!(result.successes.len(), 1);
379        assert!(result.failures.is_empty());
380        assert_eq!(result.successes[0].name, "234234");
381    }
382
383    #[tokio::test]
384    async fn test_decrypt_list_with_failures_mixed_results() {
385        let client = Client::init_test_account(test_bitwarden_com_account()).await;
386        let valid_cipher = test_cipher();
387        let mut invalid_cipher = test_cipher();
388        // Set an invalid encryptedkey to cause decryption failure
389        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
390
391        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
392
393        let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
394
395        assert_eq!(result.successes.len(), 1);
396        assert_eq!(result.failures.len(), 1);
397
398        assert_eq!(result.successes[0].name, "234234");
399    }
400
401    #[tokio::test]
402    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
403        let client = Client::init_test_account(test_bitwarden_com_account()).await;
404
405        let mut cipher = test_cipher();
406        cipher.attachments = Some(vec![test_attachment_legacy()]);
407
408        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
409
410        //  Move cipher to organization
411        let res = client.vault().ciphers().move_to_organization(
412            view,
413            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
414        );
415
416        assert!(res.is_err());
417    }
418
419    #[tokio::test]
420    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
421        let client = Client::init_test_account(test_bitwarden_com_account()).await;
422
423        let mut cipher = test_cipher();
424        let attachment = test_attachment_legacy();
425        cipher.attachments = Some(vec![attachment.clone()]);
426
427        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
428
429        assert!(cipher.key.is_none());
430
431        // Assert the cipher has a key, and the attachment is still readable
432        let EncryptionContext {
433            cipher: new_cipher,
434            encrypted_for: _,
435        } = client.vault().ciphers().encrypt(view).unwrap();
436        assert!(new_cipher.key.is_some());
437
438        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
439        let attachments = view.clone().attachments.unwrap();
440        let attachment_view = attachments.first().unwrap().clone();
441        assert!(attachment_view.key.is_none());
442
443        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
444
445        let buf = vec![
446            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
447            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
448            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
449            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
450        ];
451
452        let content = client
453            .vault()
454            .attachments()
455            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
456            .unwrap();
457
458        assert_eq!(content, b"Hello");
459    }
460
461    #[tokio::test]
462    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
463        let client = Client::init_test_account(test_bitwarden_com_account()).await;
464
465        let mut cipher = test_cipher();
466        let attachment = test_attachment_v2();
467        cipher.attachments = Some(vec![attachment.clone()]);
468
469        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
470
471        assert!(cipher.key.is_none());
472
473        // Assert the cipher has a key, and the attachment is still readable
474        let EncryptionContext {
475            cipher: new_cipher,
476            encrypted_for: _,
477        } = client.vault().ciphers().encrypt(view).unwrap();
478        assert!(new_cipher.key.is_some());
479
480        let view = client
481            .vault()
482            .ciphers()
483            .decrypt(new_cipher.clone())
484            .unwrap();
485        let attachments = view.clone().attachments.unwrap();
486        let attachment_view = attachments.first().unwrap().clone();
487        assert!(attachment_view.key.is_some());
488
489        // Ensure attachment key is updated since it's now protected by the cipher key
490        assert_ne!(
491            attachment.clone().key.unwrap().to_string(),
492            attachment_view.clone().key.unwrap().to_string()
493        );
494
495        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
496
497        let buf = vec![
498            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
499            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
500            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
501            188, 196, 170, 127, 67, 44, 129, 90,
502        ];
503
504        let content = client
505            .vault()
506            .attachments()
507            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
508            .unwrap();
509
510        assert_eq!(content, b"Hello");
511
512        // Move cipher to organization
513        let new_view = client
514            .vault()
515            .ciphers()
516            .move_to_organization(
517                view,
518                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
519            )
520            .unwrap();
521        let EncryptionContext {
522            cipher: new_cipher,
523            encrypted_for: _,
524        } = client.vault().ciphers().encrypt(new_view).unwrap();
525
526        let attachment = new_cipher
527            .clone()
528            .attachments
529            .unwrap()
530            .first()
531            .unwrap()
532            .clone();
533
534        // Ensure attachment key is still the same since it's protected by the cipher key
535        assert_eq!(
536            attachment.clone().key.as_ref().unwrap().to_string(),
537            attachment_view.key.as_ref().unwrap().to_string()
538        );
539
540        let content = client
541            .vault()
542            .attachments()
543            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
544            .unwrap();
545
546        assert_eq!(content, b"Hello");
547    }
548
549    #[tokio::test]
550    #[cfg(feature = "wasm")]
551    async fn test_encrypt_cipher_for_rotation() {
552        let client = Client::init_test_account(test_bitwarden_com_account()).await;
553
554        let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
555
556        let cipher_view = test_cipher_view();
557        let new_key_b64 = new_key.to_base64();
558
559        let ctx = client
560            .vault()
561            .ciphers()
562            .encrypt_cipher_for_rotation(cipher_view, new_key_b64)
563            .unwrap();
564
565        assert!(ctx.cipher.key.is_some());
566
567        // Decrypting the cipher "normally" will fail because it was encrypted with a new key
568        assert!(matches!(
569            client.vault().ciphers().decrypt(ctx.cipher).err(),
570            Some(DecryptError::Crypto(CryptoError::Decrypt))
571        ));
572    }
573}