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::{Cipher, CipherError, CipherListView, CipherView, DecryptError, EncryptError};
8
9#[allow(missing_docs)]
10#[cfg_attr(feature = "wasm", wasm_bindgen)]
11pub struct CiphersClient {
12    pub(crate) client: Client,
13}
14
15#[cfg_attr(feature = "wasm", wasm_bindgen)]
16impl CiphersClient {
17    #[allow(missing_docs)]
18    pub fn encrypt(&self, mut cipher_view: CipherView) -> Result<EncryptionContext, EncryptError> {
19        let user_id = self
20            .client
21            .internal
22            .get_user_id()
23            .ok_or(EncryptError::MissingUserId)?;
24        let key_store = self.client.internal.get_key_store();
25
26        // TODO: Once this flag is removed, the key generation logic should
27        // be moved directly into the KeyEncryptable implementation
28        if cipher_view.key.is_none()
29            && self
30                .client
31                .internal
32                .get_flags()
33                .enable_cipher_key_encryption
34        {
35            let key = cipher_view.key_identifier();
36            cipher_view.generate_cipher_key(&mut key_store.context(), key)?;
37        }
38
39        let cipher = key_store.encrypt(cipher_view)?;
40        Ok(EncryptionContext {
41            cipher,
42            encrypted_for: user_id,
43        })
44    }
45
46    #[allow(missing_docs)]
47    pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
48        let key_store = self.client.internal.get_key_store();
49        let cipher_view = key_store.decrypt(&cipher)?;
50        Ok(cipher_view)
51    }
52
53    #[allow(missing_docs)]
54    pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
55        let key_store = self.client.internal.get_key_store();
56        let cipher_views = key_store.decrypt_list(&ciphers)?;
57        Ok(cipher_views)
58    }
59
60    #[allow(missing_docs)]
61    pub fn decrypt_fido2_credentials(
62        &self,
63        cipher_view: CipherView,
64    ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
65        let key_store = self.client.internal.get_key_store();
66        let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
67        Ok(credentials)
68    }
69
70    #[allow(missing_docs)]
71    pub fn move_to_organization(
72        &self,
73        mut cipher_view: CipherView,
74        organization_id: OrganizationId,
75    ) -> Result<CipherView, CipherError> {
76        let key_store = self.client.internal.get_key_store();
77        cipher_view.move_to_organization(&mut key_store.context(), organization_id.into())?;
78        Ok(cipher_view)
79    }
80
81    #[cfg(feature = "wasm")]
82    #[allow(missing_docs)]
83    pub fn decrypt_fido2_private_key(
84        &self,
85        cipher_view: CipherView,
86    ) -> Result<String, CipherError> {
87        let key_store = self.client.internal.get_key_store();
88        let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
89        Ok(decrypted_key)
90    }
91}
92
93#[cfg(test)]
94mod tests {
95
96    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
97
98    use super::*;
99    use crate::{Attachment, CipherRepromptType, CipherType, Login, VaultClientExt};
100
101    #[tokio::test]
102    async fn test_decrypt_list() {
103        let client = Client::init_test_account(test_bitwarden_com_account()).await;
104
105        let dec = client
106            .vault()
107            .ciphers()
108            .decrypt_list(vec![Cipher {
109                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
110                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
111                folder_id: None,
112                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
113                key: None,
114                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
115                notes: None,
116                r#type: CipherType::Login,
117                login: Some(Login{
118                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
119                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
120                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
121                identity: None,
122                card: None,
123                secure_note: None,
124                ssh_key: None,
125                favorite: false,
126                reprompt: CipherRepromptType::None,
127                organization_use_totp: true,
128                edit: true,
129                permissions: None,
130                view_password: true,
131                local_data: None,
132                attachments: None,
133                fields:  None,
134                password_history: None,
135                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
136                deleted_date: None,
137                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
138            }])
139
140            .unwrap();
141
142        assert_eq!(dec[0].name, "Test item");
143    }
144
145    fn test_cipher() -> Cipher {
146        Cipher {
147            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
148            organization_id: None,
149            folder_id: None,
150            collection_ids: vec![],
151            key: None,
152            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
153            notes: None,
154            r#type: CipherType::Login,
155            login: Some(Login{
156                username: None,
157                password: None,
158                password_revision_date: None,
159                uris:None,
160                totp: None,
161                autofill_on_page_load: None,
162                fido2_credentials: None,
163            }),
164            identity: None,
165            card: None,
166            secure_note: None,
167            ssh_key: None,
168            favorite: false,
169            reprompt: CipherRepromptType::None,
170            organization_use_totp: true,
171            edit: true,
172            permissions: None,
173            view_password: true,
174            local_data: None,
175            attachments: None,
176            fields:  None,
177            password_history: None,
178            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
179            deleted_date: None,
180            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
181        }
182    }
183
184    fn test_attachment_legacy() -> Attachment {
185        Attachment {
186            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
187            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
188            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
189            key: None,
190            size: Some("65".to_string()),
191            size_name: Some("65 Bytes".to_string()),
192        }
193    }
194
195    fn test_attachment_v2() -> Attachment {
196        Attachment {
197            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
198            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
199            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
200            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
201            size: Some("65".to_string()),
202            size_name: Some("65 Bytes".to_string()),
203        }
204    }
205
206    #[tokio::test]
207    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
208        let client = Client::init_test_account(test_bitwarden_com_account()).await;
209
210        let mut cipher = test_cipher();
211        cipher.attachments = Some(vec![test_attachment_legacy()]);
212
213        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
214
215        //  Move cipher to organization
216        let res = client.vault().ciphers().move_to_organization(
217            view,
218            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
219        );
220
221        assert!(res.is_err());
222    }
223
224    #[tokio::test]
225    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
226        let client = Client::init_test_account(test_bitwarden_com_account()).await;
227
228        let mut cipher = test_cipher();
229        let attachment = test_attachment_legacy();
230        cipher.attachments = Some(vec![attachment.clone()]);
231
232        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
233
234        assert!(cipher.key.is_none());
235
236        // Assert the cipher has a key, and the attachment is still readable
237        let EncryptionContext {
238            cipher: new_cipher,
239            encrypted_for: _,
240        } = client.vault().ciphers().encrypt(view).unwrap();
241        assert!(new_cipher.key.is_some());
242
243        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
244        let attachments = view.clone().attachments.unwrap();
245        let attachment_view = attachments.first().unwrap().clone();
246        assert!(attachment_view.key.is_none());
247
248        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
249
250        let buf = vec![
251            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
252            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
253            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
254            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
255        ];
256
257        let content = client
258            .vault()
259            .attachments()
260            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
261            .unwrap();
262
263        assert_eq!(content, b"Hello");
264    }
265
266    #[tokio::test]
267    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
268        let client = Client::init_test_account(test_bitwarden_com_account()).await;
269
270        let mut cipher = test_cipher();
271        let attachment = test_attachment_v2();
272        cipher.attachments = Some(vec![attachment.clone()]);
273
274        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
275
276        assert!(cipher.key.is_none());
277
278        // Assert the cipher has a key, and the attachment is still readable
279        let EncryptionContext {
280            cipher: new_cipher,
281            encrypted_for: _,
282        } = client.vault().ciphers().encrypt(view).unwrap();
283        assert!(new_cipher.key.is_some());
284
285        let view = client
286            .vault()
287            .ciphers()
288            .decrypt(new_cipher.clone())
289            .unwrap();
290        let attachments = view.clone().attachments.unwrap();
291        let attachment_view = attachments.first().unwrap().clone();
292        assert!(attachment_view.key.is_some());
293
294        // Ensure attachment key is updated since it's now protected by the cipher key
295        assert_ne!(
296            attachment.clone().key.unwrap().to_string(),
297            attachment_view.clone().key.unwrap().to_string()
298        );
299
300        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
301
302        let buf = vec![
303            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
304            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
305            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
306            188, 196, 170, 127, 67, 44, 129, 90,
307        ];
308
309        let content = client
310            .vault()
311            .attachments()
312            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
313            .unwrap();
314
315        assert_eq!(content, b"Hello");
316
317        // Move cipher to organization
318        let new_view = client
319            .vault()
320            .ciphers()
321            .move_to_organization(
322                view,
323                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
324            )
325            .unwrap();
326        let EncryptionContext {
327            cipher: new_cipher,
328            encrypted_for: _,
329        } = client.vault().ciphers().encrypt(new_view).unwrap();
330
331        let attachment = new_cipher
332            .clone()
333            .attachments
334            .unwrap()
335            .first()
336            .unwrap()
337            .clone();
338
339        // Ensure attachment key is still the same since it's protected by the cipher key
340        assert_eq!(
341            attachment.clone().key.as_ref().unwrap().to_string(),
342            attachment_view.key.as_ref().unwrap().to_string()
343        );
344
345        let content = client
346            .vault()
347            .attachments()
348            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
349            .unwrap();
350
351        assert_eq!(content, b"Hello");
352    }
353}