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