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