bitwarden_vault/cipher/
cipher_client.rs

1use bitwarden_core::{Client, OrganizationId};
2use bitwarden_crypto::IdentifyKey;
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    #[allow(missing_docs)]
50    pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
51        let key_store = self.client.internal.get_key_store();
52        let cipher_view = key_store.decrypt(&cipher)?;
53        Ok(cipher_view)
54    }
55
56    #[allow(missing_docs)]
57    pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
58        let key_store = self.client.internal.get_key_store();
59        let cipher_views = key_store.decrypt_list(&ciphers)?;
60        Ok(cipher_views)
61    }
62
63    /// Decrypt cipher list with failures
64    /// Returns both successfully decrypted ciphers and any that failed to decrypt
65    pub fn decrypt_list_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherListResult {
66        let key_store = self.client.internal.get_key_store();
67        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
68
69        DecryptCipherListResult {
70            successes,
71            failures: failures.into_iter().cloned().collect(),
72        }
73    }
74
75    #[allow(missing_docs)]
76    pub fn decrypt_fido2_credentials(
77        &self,
78        cipher_view: CipherView,
79    ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
80        let key_store = self.client.internal.get_key_store();
81        let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
82        Ok(credentials)
83    }
84
85    /// Temporary method used to re-encrypt FIDO2 credentials for a cipher view.
86    /// Necessary until the TS clients utilize the SDK entirely for FIDO2 credentials management.
87    /// TS clients create decrypted FIDO2 credentials that need to be encrypted manually when
88    /// encrypting the rest of the CipherView.
89    /// TODO: Remove once TS passkey provider implementation uses SDK - PM-8313
90    #[cfg(feature = "wasm")]
91    pub fn set_fido2_credentials(
92        &self,
93        mut cipher_view: CipherView,
94        fido2_credentials: Vec<Fido2CredentialFullView>,
95    ) -> Result<CipherView, CipherError> {
96        let key_store = self.client.internal.get_key_store();
97
98        cipher_view.set_new_fido2_credentials(&mut key_store.context(), fido2_credentials)?;
99
100        Ok(cipher_view)
101    }
102
103    #[allow(missing_docs)]
104    pub fn move_to_organization(
105        &self,
106        mut cipher_view: CipherView,
107        organization_id: OrganizationId,
108    ) -> Result<CipherView, CipherError> {
109        let key_store = self.client.internal.get_key_store();
110        cipher_view.move_to_organization(&mut key_store.context(), organization_id.into())?;
111        Ok(cipher_view)
112    }
113
114    #[cfg(feature = "wasm")]
115    #[allow(missing_docs)]
116    pub fn decrypt_fido2_private_key(
117        &self,
118        cipher_view: CipherView,
119    ) -> Result<String, CipherError> {
120        let key_store = self.client.internal.get_key_store();
121        let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
122        Ok(decrypted_key)
123    }
124}
125
126#[cfg(test)]
127mod tests {
128
129    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
130
131    use super::*;
132    use crate::{Attachment, CipherRepromptType, CipherType, Login, VaultClientExt};
133
134    fn test_cipher() -> Cipher {
135        Cipher {
136            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
137            organization_id: None,
138            folder_id: None,
139            collection_ids: vec![],
140            key: None,
141            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
142            notes: None,
143            r#type: CipherType::Login,
144            login: Some(Login{
145                username: None,
146                password: None,
147                password_revision_date: None,
148                uris:None,
149                totp: None,
150                autofill_on_page_load: None,
151                fido2_credentials: None,
152            }),
153            identity: None,
154            card: None,
155            secure_note: None,
156            ssh_key: None,
157            favorite: false,
158            reprompt: CipherRepromptType::None,
159            organization_use_totp: true,
160            edit: true,
161            permissions: None,
162            view_password: true,
163            local_data: None,
164            attachments: None,
165            fields:  None,
166            password_history: None,
167            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
168            deleted_date: None,
169            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
170        }
171    }
172
173    fn test_attachment_legacy() -> Attachment {
174        Attachment {
175            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
176            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
177            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
178            key: None,
179            size: Some("65".to_string()),
180            size_name: Some("65 Bytes".to_string()),
181        }
182    }
183
184    fn test_attachment_v2() -> Attachment {
185        Attachment {
186            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
187            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
188            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
189            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
190            size: Some("65".to_string()),
191            size_name: Some("65 Bytes".to_string()),
192        }
193    }
194
195    #[tokio::test]
196    async fn test_decrypt_list() {
197        let client = Client::init_test_account(test_bitwarden_com_account()).await;
198
199        let dec = client
200            .vault()
201            .ciphers()
202            .decrypt_list(vec![Cipher {
203                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
204                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
205                folder_id: None,
206                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
207                key: None,
208                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
209                notes: None,
210                r#type: CipherType::Login,
211                login: Some(Login{
212                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
213                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
214                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
215                identity: None,
216                card: None,
217                secure_note: None,
218                ssh_key: None,
219                favorite: false,
220                reprompt: CipherRepromptType::None,
221                organization_use_totp: true,
222                edit: true,
223                permissions: None,
224                view_password: true,
225                local_data: None,
226                attachments: None,
227                fields:  None,
228                password_history: None,
229                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
230                deleted_date: None,
231                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
232            }])
233
234            .unwrap();
235
236        assert_eq!(dec[0].name, "Test item");
237    }
238
239    #[tokio::test]
240    async fn test_decrypt_list_with_failures_all_success() {
241        let client = Client::init_test_account(test_bitwarden_com_account()).await;
242
243        let valid_cipher = test_cipher();
244
245        let result = client
246            .vault()
247            .ciphers()
248            .decrypt_list_with_failures(vec![valid_cipher]);
249
250        assert_eq!(result.successes.len(), 1);
251        assert!(result.failures.is_empty());
252        assert_eq!(result.successes[0].name, "234234");
253    }
254
255    #[tokio::test]
256    async fn test_decrypt_list_with_failures_mixed_results() {
257        let client = Client::init_test_account(test_bitwarden_com_account()).await;
258        let valid_cipher = test_cipher();
259        let mut invalid_cipher = test_cipher();
260        // Set an invalid encryptedkey to cause decryption failure
261        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
262
263        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
264
265        let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
266
267        assert_eq!(result.successes.len(), 1);
268        assert_eq!(result.failures.len(), 1);
269
270        assert_eq!(result.successes[0].name, "234234");
271    }
272
273    #[tokio::test]
274    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
275        let client = Client::init_test_account(test_bitwarden_com_account()).await;
276
277        let mut cipher = test_cipher();
278        cipher.attachments = Some(vec![test_attachment_legacy()]);
279
280        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
281
282        //  Move cipher to organization
283        let res = client.vault().ciphers().move_to_organization(
284            view,
285            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
286        );
287
288        assert!(res.is_err());
289    }
290
291    #[tokio::test]
292    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
293        let client = Client::init_test_account(test_bitwarden_com_account()).await;
294
295        let mut cipher = test_cipher();
296        let attachment = test_attachment_legacy();
297        cipher.attachments = Some(vec![attachment.clone()]);
298
299        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
300
301        assert!(cipher.key.is_none());
302
303        // Assert the cipher has a key, and the attachment is still readable
304        let EncryptionContext {
305            cipher: new_cipher,
306            encrypted_for: _,
307        } = client.vault().ciphers().encrypt(view).unwrap();
308        assert!(new_cipher.key.is_some());
309
310        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
311        let attachments = view.clone().attachments.unwrap();
312        let attachment_view = attachments.first().unwrap().clone();
313        assert!(attachment_view.key.is_none());
314
315        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
316
317        let buf = vec![
318            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
319            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
320            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
321            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
322        ];
323
324        let content = client
325            .vault()
326            .attachments()
327            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
328            .unwrap();
329
330        assert_eq!(content, b"Hello");
331    }
332
333    #[tokio::test]
334    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
335        let client = Client::init_test_account(test_bitwarden_com_account()).await;
336
337        let mut cipher = test_cipher();
338        let attachment = test_attachment_v2();
339        cipher.attachments = Some(vec![attachment.clone()]);
340
341        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
342
343        assert!(cipher.key.is_none());
344
345        // Assert the cipher has a key, and the attachment is still readable
346        let EncryptionContext {
347            cipher: new_cipher,
348            encrypted_for: _,
349        } = client.vault().ciphers().encrypt(view).unwrap();
350        assert!(new_cipher.key.is_some());
351
352        let view = client
353            .vault()
354            .ciphers()
355            .decrypt(new_cipher.clone())
356            .unwrap();
357        let attachments = view.clone().attachments.unwrap();
358        let attachment_view = attachments.first().unwrap().clone();
359        assert!(attachment_view.key.is_some());
360
361        // Ensure attachment key is updated since it's now protected by the cipher key
362        assert_ne!(
363            attachment.clone().key.unwrap().to_string(),
364            attachment_view.clone().key.unwrap().to_string()
365        );
366
367        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
368
369        let buf = vec![
370            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
371            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
372            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
373            188, 196, 170, 127, 67, 44, 129, 90,
374        ];
375
376        let content = client
377            .vault()
378            .attachments()
379            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
380            .unwrap();
381
382        assert_eq!(content, b"Hello");
383
384        // Move cipher to organization
385        let new_view = client
386            .vault()
387            .ciphers()
388            .move_to_organization(
389                view,
390                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
391            )
392            .unwrap();
393        let EncryptionContext {
394            cipher: new_cipher,
395            encrypted_for: _,
396        } = client.vault().ciphers().encrypt(new_view).unwrap();
397
398        let attachment = new_cipher
399            .clone()
400            .attachments
401            .unwrap()
402            .first()
403            .unwrap()
404            .clone();
405
406        // Ensure attachment key is still the same since it's protected by the cipher key
407        assert_eq!(
408            attachment.clone().key.as_ref().unwrap().to_string(),
409            attachment_view.key.as_ref().unwrap().to_string()
410        );
411
412        let content = client
413            .vault()
414            .attachments()
415            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
416            .unwrap();
417
418        assert_eq!(content, b"Hello");
419    }
420}