1use bitwarden_core::{key_management::SymmetricKeyId, Client, OrganizationId};
2use bitwarden_crypto::{CompositeEncryptable, IdentifyKey, SymmetricCryptoKey};
3#[cfg(feature = "wasm")]
4use bitwarden_encoding::B64;
5#[cfg(feature = "wasm")]
6use wasm_bindgen::prelude::*;
7
8use super::EncryptionContext;
9use crate::{
10 cipher::cipher::DecryptCipherListResult, Cipher, CipherError, CipherListView, CipherView,
11 DecryptError, EncryptError, Fido2CredentialFullView,
12};
13
14#[allow(missing_docs)]
15#[cfg_attr(feature = "wasm", wasm_bindgen)]
16pub struct CiphersClient {
17 pub(crate) client: Client,
18}
19
20#[cfg_attr(feature = "wasm", wasm_bindgen)]
21impl CiphersClient {
22 #[allow(missing_docs)]
23 pub fn encrypt(&self, mut cipher_view: CipherView) -> Result<EncryptionContext, EncryptError> {
24 let user_id = self
25 .client
26 .internal
27 .get_user_id()
28 .ok_or(EncryptError::MissingUserId)?;
29 let key_store = self.client.internal.get_key_store();
30
31 if cipher_view.key.is_none()
34 && self
35 .client
36 .internal
37 .get_flags()
38 .enable_cipher_key_encryption
39 {
40 let key = cipher_view.key_identifier();
41 cipher_view.generate_cipher_key(&mut key_store.context(), key)?;
42 }
43
44 let cipher = key_store.encrypt(cipher_view)?;
45 Ok(EncryptionContext {
46 cipher,
47 encrypted_for: user_id,
48 })
49 }
50
51 #[cfg(feature = "wasm")]
62 pub fn encrypt_cipher_for_rotation(
63 &self,
64 mut cipher_view: CipherView,
65 new_key: B64,
66 ) -> Result<EncryptionContext, CipherError> {
67 let new_key = SymmetricCryptoKey::try_from(new_key)?;
68
69 let user_id = self
70 .client
71 .internal
72 .get_user_id()
73 .ok_or(EncryptError::MissingUserId)?;
74 let key_store = self.client.internal.get_key_store();
75 let mut ctx = key_store.context();
76
77 const NEW_KEY_ID: SymmetricKeyId = SymmetricKeyId::Local("new_cipher_key");
79 #[allow(deprecated)]
80 ctx.set_symmetric_key(NEW_KEY_ID, new_key)?;
81
82 if cipher_view.key.is_none()
83 && self
84 .client
85 .internal
86 .get_flags()
87 .enable_cipher_key_encryption
88 {
89 cipher_view.generate_cipher_key(&mut ctx, NEW_KEY_ID)?;
90 } else {
91 cipher_view.reencrypt_cipher_keys(&mut ctx, NEW_KEY_ID)?;
92 }
93
94 let cipher = cipher_view.encrypt_composite(&mut ctx, NEW_KEY_ID)?;
95
96 Ok(EncryptionContext {
97 cipher,
98 encrypted_for: user_id,
99 })
100 }
101
102 #[allow(missing_docs)]
103 pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
104 let key_store = self.client.internal.get_key_store();
105 let cipher_view = key_store.decrypt(&cipher)?;
106 Ok(cipher_view)
107 }
108
109 #[allow(missing_docs)]
110 pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
111 let key_store = self.client.internal.get_key_store();
112 let cipher_views = key_store.decrypt_list(&ciphers)?;
113 Ok(cipher_views)
114 }
115
116 pub fn decrypt_list_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherListResult {
119 let key_store = self.client.internal.get_key_store();
120 let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
121
122 DecryptCipherListResult {
123 successes,
124 failures: failures.into_iter().cloned().collect(),
125 }
126 }
127
128 #[allow(missing_docs)]
129 pub fn decrypt_fido2_credentials(
130 &self,
131 cipher_view: CipherView,
132 ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
133 let key_store = self.client.internal.get_key_store();
134 let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
135 Ok(credentials)
136 }
137
138 #[cfg(feature = "wasm")]
144 pub fn set_fido2_credentials(
145 &self,
146 mut cipher_view: CipherView,
147 fido2_credentials: Vec<Fido2CredentialFullView>,
148 ) -> Result<CipherView, CipherError> {
149 let key_store = self.client.internal.get_key_store();
150
151 cipher_view.set_new_fido2_credentials(&mut key_store.context(), fido2_credentials)?;
152
153 Ok(cipher_view)
154 }
155
156 #[allow(missing_docs)]
157 pub fn move_to_organization(
158 &self,
159 mut cipher_view: CipherView,
160 organization_id: OrganizationId,
161 ) -> Result<CipherView, CipherError> {
162 let key_store = self.client.internal.get_key_store();
163 cipher_view.move_to_organization(&mut key_store.context(), organization_id)?;
164 Ok(cipher_view)
165 }
166
167 #[cfg(feature = "wasm")]
168 #[allow(missing_docs)]
169 pub fn decrypt_fido2_private_key(
170 &self,
171 cipher_view: CipherView,
172 ) -> Result<String, CipherError> {
173 let key_store = self.client.internal.get_key_store();
174 let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
175 Ok(decrypted_key)
176 }
177}
178
179#[cfg(test)]
180mod tests {
181
182 use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
183 use bitwarden_crypto::CryptoError;
184
185 use super::*;
186 use crate::{Attachment, CipherRepromptType, CipherType, Login, LoginView, VaultClientExt};
187
188 fn test_cipher() -> Cipher {
189 Cipher {
190 id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
191 organization_id: None,
192 folder_id: None,
193 collection_ids: vec![],
194 key: None,
195 name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
196 notes: None,
197 r#type: CipherType::Login,
198 login: Some(Login{
199 username: None,
200 password: None,
201 password_revision_date: None,
202 uris:None,
203 totp: None,
204 autofill_on_page_load: None,
205 fido2_credentials: None,
206 }),
207 identity: None,
208 card: None,
209 secure_note: None,
210 ssh_key: None,
211 favorite: false,
212 reprompt: CipherRepromptType::None,
213 organization_use_totp: true,
214 edit: true,
215 permissions: None,
216 view_password: true,
217 local_data: None,
218 attachments: None,
219 fields: None,
220 password_history: None,
221 creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
222 deleted_date: None,
223 revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
224 archived_date: None,
225 }
226 }
227
228 fn test_cipher_view() -> CipherView {
229 let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
230 CipherView {
231 r#type: CipherType::Login,
232 login: Some(LoginView {
233 username: Some("test_username".to_string()),
234 password: Some("test_password".to_string()),
235 password_revision_date: None,
236 uris: None,
237 totp: None,
238 autofill_on_page_load: None,
239 fido2_credentials: None,
240 }),
241 id: Some(test_id),
242 organization_id: None,
243 folder_id: None,
244 collection_ids: vec![],
245 key: None,
246 name: "My test login".to_string(),
247 notes: None,
248 identity: None,
249 card: None,
250 secure_note: None,
251 ssh_key: None,
252 favorite: false,
253 reprompt: CipherRepromptType::None,
254 organization_use_totp: true,
255 edit: true,
256 permissions: None,
257 view_password: true,
258 local_data: None,
259 attachments: None,
260 fields: None,
261 password_history: None,
262 creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
263 deleted_date: None,
264 revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
265 archived_date: None,
266 }
267 }
268
269 fn test_attachment_legacy() -> Attachment {
270 Attachment {
271 id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
272 url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
273 file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
274 key: None,
275 size: Some("65".to_string()),
276 size_name: Some("65 Bytes".to_string()),
277 }
278 }
279
280 fn test_attachment_v2() -> Attachment {
281 Attachment {
282 id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
283 url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
284 file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
285 key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
286 size: Some("65".to_string()),
287 size_name: Some("65 Bytes".to_string()),
288 }
289 }
290
291 #[tokio::test]
292 async fn test_decrypt_list() {
293 let client = Client::init_test_account(test_bitwarden_com_account()).await;
294
295 let dec = client
296 .vault()
297 .ciphers()
298 .decrypt_list(vec![Cipher {
299 id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
300 organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
301 folder_id: None,
302 collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
303 key: None,
304 name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
305 notes: None,
306 r#type: CipherType::Login,
307 login: Some(Login{
308 username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
309 password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
310 password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
311 identity: None,
312 card: None,
313 secure_note: None,
314 ssh_key: None,
315 favorite: false,
316 reprompt: CipherRepromptType::None,
317 organization_use_totp: true,
318 edit: true,
319 permissions: None,
320 view_password: true,
321 local_data: None,
322 attachments: None,
323 fields: None,
324 password_history: None,
325 creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
326 deleted_date: None,
327 revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
328 archived_date: None,
329 }])
330
331 .unwrap();
332
333 assert_eq!(dec[0].name, "Test item");
334 }
335
336 #[tokio::test]
337 async fn test_decrypt_list_with_failures_all_success() {
338 let client = Client::init_test_account(test_bitwarden_com_account()).await;
339
340 let valid_cipher = test_cipher();
341
342 let result = client
343 .vault()
344 .ciphers()
345 .decrypt_list_with_failures(vec![valid_cipher]);
346
347 assert_eq!(result.successes.len(), 1);
348 assert!(result.failures.is_empty());
349 assert_eq!(result.successes[0].name, "234234");
350 }
351
352 #[tokio::test]
353 async fn test_decrypt_list_with_failures_mixed_results() {
354 let client = Client::init_test_account(test_bitwarden_com_account()).await;
355 let valid_cipher = test_cipher();
356 let mut invalid_cipher = test_cipher();
357 invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
359
360 let ciphers = vec![valid_cipher, invalid_cipher.clone()];
361
362 let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
363
364 assert_eq!(result.successes.len(), 1);
365 assert_eq!(result.failures.len(), 1);
366
367 assert_eq!(result.successes[0].name, "234234");
368 }
369
370 #[tokio::test]
371 async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
372 let client = Client::init_test_account(test_bitwarden_com_account()).await;
373
374 let mut cipher = test_cipher();
375 cipher.attachments = Some(vec![test_attachment_legacy()]);
376
377 let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
378
379 let res = client.vault().ciphers().move_to_organization(
381 view,
382 "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
383 );
384
385 assert!(res.is_err());
386 }
387
388 #[tokio::test]
389 async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
390 let client = Client::init_test_account(test_bitwarden_com_account()).await;
391
392 let mut cipher = test_cipher();
393 let attachment = test_attachment_legacy();
394 cipher.attachments = Some(vec![attachment.clone()]);
395
396 let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
397
398 assert!(cipher.key.is_none());
399
400 let EncryptionContext {
402 cipher: new_cipher,
403 encrypted_for: _,
404 } = client.vault().ciphers().encrypt(view).unwrap();
405 assert!(new_cipher.key.is_some());
406
407 let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
408 let attachments = view.clone().attachments.unwrap();
409 let attachment_view = attachments.first().unwrap().clone();
410 assert!(attachment_view.key.is_none());
411
412 assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
413
414 let buf = vec![
415 2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
416 41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
417 211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
418 43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
419 ];
420
421 let content = client
422 .vault()
423 .attachments()
424 .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
425 .unwrap();
426
427 assert_eq!(content, b"Hello");
428 }
429
430 #[tokio::test]
431 async fn test_encrypt_cipher_with_v1_attachment_without_key() {
432 let client = Client::init_test_account(test_bitwarden_com_account()).await;
433
434 let mut cipher = test_cipher();
435 let attachment = test_attachment_v2();
436 cipher.attachments = Some(vec![attachment.clone()]);
437
438 let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
439
440 assert!(cipher.key.is_none());
441
442 let EncryptionContext {
444 cipher: new_cipher,
445 encrypted_for: _,
446 } = client.vault().ciphers().encrypt(view).unwrap();
447 assert!(new_cipher.key.is_some());
448
449 let view = client
450 .vault()
451 .ciphers()
452 .decrypt(new_cipher.clone())
453 .unwrap();
454 let attachments = view.clone().attachments.unwrap();
455 let attachment_view = attachments.first().unwrap().clone();
456 assert!(attachment_view.key.is_some());
457
458 assert_ne!(
460 attachment.clone().key.unwrap().to_string(),
461 attachment_view.clone().key.unwrap().to_string()
462 );
463
464 assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
465
466 let buf = vec![
467 2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
468 189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
469 158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
470 188, 196, 170, 127, 67, 44, 129, 90,
471 ];
472
473 let content = client
474 .vault()
475 .attachments()
476 .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
477 .unwrap();
478
479 assert_eq!(content, b"Hello");
480
481 let new_view = client
483 .vault()
484 .ciphers()
485 .move_to_organization(
486 view,
487 "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
488 )
489 .unwrap();
490 let EncryptionContext {
491 cipher: new_cipher,
492 encrypted_for: _,
493 } = client.vault().ciphers().encrypt(new_view).unwrap();
494
495 let attachment = new_cipher
496 .clone()
497 .attachments
498 .unwrap()
499 .first()
500 .unwrap()
501 .clone();
502
503 assert_eq!(
505 attachment.clone().key.as_ref().unwrap().to_string(),
506 attachment_view.key.as_ref().unwrap().to_string()
507 );
508
509 let content = client
510 .vault()
511 .attachments()
512 .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
513 .unwrap();
514
515 assert_eq!(content, b"Hello");
516 }
517
518 #[tokio::test]
519 async fn test_encrypt_cipher_for_rotation() {
520 let client = Client::init_test_account(test_bitwarden_com_account()).await;
521
522 let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
523
524 let cipher_view = test_cipher_view();
525 let new_key_b64 = new_key.to_base64();
526
527 let ctx = client
528 .vault()
529 .ciphers()
530 .encrypt_cipher_for_rotation(cipher_view, new_key_b64)
531 .unwrap();
532
533 assert!(ctx.cipher.key.is_some());
534
535 assert!(matches!(
537 client.vault().ciphers().decrypt(ctx.cipher).err(),
538 Some(DecryptError::Crypto(CryptoError::InvalidMac))
539 ));
540 }
541}