bitwarden_vault/cipher/cipher_client/
mod.rs

1use std::sync::Arc;
2
3use bitwarden_core::{Client, OrganizationId};
4use bitwarden_crypto::IdentifyKey;
5#[cfg(feature = "wasm")]
6use bitwarden_crypto::{CompositeEncryptable, SymmetricCryptoKey};
7#[cfg(feature = "wasm")]
8use bitwarden_encoding::B64;
9use bitwarden_state::repository::{Repository, RepositoryError};
10#[cfg(feature = "wasm")]
11use wasm_bindgen::prelude::*;
12
13use super::EncryptionContext;
14#[cfg(feature = "wasm")]
15use crate::Fido2CredentialFullView;
16use crate::{
17    Cipher, CipherError, CipherListView, CipherView, DecryptError, EncryptError,
18    cipher::cipher::{DecryptCipherListResult, DecryptCipherResult},
19    cipher_client::admin::CipherAdminClient,
20};
21
22mod admin;
23mod create;
24mod delete;
25mod edit;
26mod get;
27mod restore;
28mod share_cipher;
29
30#[allow(missing_docs)]
31#[cfg_attr(feature = "wasm", wasm_bindgen)]
32pub struct CiphersClient {
33    pub(crate) client: Client,
34}
35
36#[cfg_attr(feature = "wasm", wasm_bindgen)]
37impl CiphersClient {
38    #[allow(missing_docs)]
39    pub fn encrypt(&self, mut cipher_view: CipherView) -> Result<EncryptionContext, EncryptError> {
40        let user_id = self
41            .client
42            .internal
43            .get_user_id()
44            .ok_or(EncryptError::MissingUserId)?;
45        let key_store = self.client.internal.get_key_store();
46
47        // TODO: Once this flag is removed, the key generation logic should
48        // be moved directly into the KeyEncryptable implementation
49        if cipher_view.key.is_none()
50            && self
51                .client
52                .internal
53                .get_flags()
54                .enable_cipher_key_encryption
55        {
56            let key = cipher_view.key_identifier();
57            cipher_view.generate_cipher_key(&mut key_store.context(), key)?;
58        }
59
60        let cipher = key_store.encrypt(cipher_view)?;
61        Ok(EncryptionContext {
62            cipher,
63            encrypted_for: user_id,
64        })
65    }
66
67    /// Encrypt a cipher with the provided key. This should only be used when rotating encryption
68    /// keys in the Web client.
69    ///
70    /// Until key rotation is fully implemented in the SDK, this method must be provided the new
71    /// symmetric key in base64 format. See PM-23084
72    ///
73    /// If the cipher has a CipherKey, it will be re-encrypted with the new key.
74    /// If the cipher does not have a CipherKey and CipherKeyEncryption is enabled, one will be
75    /// generated using the new key. Otherwise, the cipher's data will be encrypted with the new
76    /// key directly.
77    #[cfg(feature = "wasm")]
78    pub fn encrypt_cipher_for_rotation(
79        &self,
80        mut cipher_view: CipherView,
81        new_key: B64,
82    ) -> Result<EncryptionContext, CipherError> {
83        let new_key = SymmetricCryptoKey::try_from(new_key)?;
84
85        let user_id = self
86            .client
87            .internal
88            .get_user_id()
89            .ok_or(EncryptError::MissingUserId)?;
90        let key_store = self.client.internal.get_key_store();
91        let mut ctx = key_store.context();
92
93        // Set the new key in the key store context
94        let new_key_id = ctx.add_local_symmetric_key(new_key);
95
96        if cipher_view.key.is_none()
97            && self
98                .client
99                .internal
100                .get_flags()
101                .enable_cipher_key_encryption
102        {
103            cipher_view.generate_cipher_key(&mut ctx, new_key_id)?;
104        } else {
105            cipher_view.reencrypt_cipher_keys(&mut ctx, new_key_id)?;
106        }
107
108        let cipher = cipher_view.encrypt_composite(&mut ctx, new_key_id)?;
109
110        Ok(EncryptionContext {
111            cipher,
112            encrypted_for: user_id,
113        })
114    }
115
116    /// Encrypt a list of cipher views.
117    ///
118    /// This method attempts to encrypt all ciphers in the list. If any cipher
119    /// fails to encrypt, the entire operation fails and an error is returned.
120    #[cfg(feature = "wasm")]
121    pub fn encrypt_list(
122        &self,
123        cipher_views: Vec<CipherView>,
124    ) -> Result<Vec<EncryptionContext>, EncryptError> {
125        let user_id = self
126            .client
127            .internal
128            .get_user_id()
129            .ok_or(EncryptError::MissingUserId)?;
130        let key_store = self.client.internal.get_key_store();
131        let enable_cipher_key = self
132            .client
133            .internal
134            .get_flags()
135            .enable_cipher_key_encryption;
136
137        let mut ctx = key_store.context();
138
139        let prepared_views: Vec<CipherView> = cipher_views
140            .into_iter()
141            .map(|mut cv| {
142                if cv.key.is_none() && enable_cipher_key {
143                    let key = cv.key_identifier();
144                    cv.generate_cipher_key(&mut ctx, key)?;
145                }
146                Ok(cv)
147            })
148            .collect::<Result<Vec<_>, bitwarden_crypto::CryptoError>>()?;
149
150        let ciphers: Vec<Cipher> = key_store.encrypt_list(&prepared_views)?;
151
152        Ok(ciphers
153            .into_iter()
154            .map(|cipher| EncryptionContext {
155                cipher,
156                encrypted_for: user_id,
157            })
158            .collect())
159    }
160
161    #[allow(missing_docs)]
162    pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
163        let key_store = self.client.internal.get_key_store();
164        let cipher_view = key_store.decrypt(&cipher)?;
165        Ok(cipher_view)
166    }
167
168    #[allow(missing_docs)]
169    pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
170        let key_store = self.client.internal.get_key_store();
171        let cipher_views = key_store.decrypt_list(&ciphers)?;
172        Ok(cipher_views)
173    }
174
175    /// Decrypt cipher list with failures
176    /// Returns both successfully decrypted ciphers and any that failed to decrypt
177    pub fn decrypt_list_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherListResult {
178        let key_store = self.client.internal.get_key_store();
179        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
180
181        DecryptCipherListResult {
182            successes,
183            failures: failures.into_iter().cloned().collect(),
184        }
185    }
186
187    /// Decrypt full cipher list
188    /// Returns both successfully fully decrypted ciphers and any that failed to decrypt
189    #[cfg(feature = "wasm")]
190    pub fn decrypt_list_full_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherResult {
191        let key_store = self.client.internal.get_key_store();
192        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
193
194        DecryptCipherResult {
195            successes,
196            failures: failures.into_iter().cloned().collect(),
197        }
198    }
199
200    #[allow(missing_docs)]
201    pub fn decrypt_fido2_credentials(
202        &self,
203        cipher_view: CipherView,
204    ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
205        let key_store = self.client.internal.get_key_store();
206        let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
207        Ok(credentials)
208    }
209
210    /// Temporary method used to re-encrypt FIDO2 credentials for a cipher view.
211    /// Necessary until the TS clients utilize the SDK entirely for FIDO2 credentials management.
212    /// TS clients create decrypted FIDO2 credentials that need to be encrypted manually when
213    /// encrypting the rest of the CipherView.
214    /// TODO: Remove once TS passkey provider implementation uses SDK - PM-8313
215    #[cfg(feature = "wasm")]
216    pub fn set_fido2_credentials(
217        &self,
218        mut cipher_view: CipherView,
219        fido2_credentials: Vec<Fido2CredentialFullView>,
220    ) -> Result<CipherView, CipherError> {
221        let key_store = self.client.internal.get_key_store();
222
223        cipher_view.set_new_fido2_credentials(&mut key_store.context(), fido2_credentials)?;
224
225        Ok(cipher_view)
226    }
227
228    #[allow(missing_docs)]
229    pub fn move_to_organization(
230        &self,
231        mut cipher_view: CipherView,
232        organization_id: OrganizationId,
233    ) -> Result<CipherView, CipherError> {
234        let key_store = self.client.internal.get_key_store();
235        cipher_view.move_to_organization(&mut key_store.context(), organization_id)?;
236        Ok(cipher_view)
237    }
238
239    #[cfg(feature = "wasm")]
240    #[allow(missing_docs)]
241    pub fn decrypt_fido2_private_key(
242        &self,
243        cipher_view: CipherView,
244    ) -> Result<String, CipherError> {
245        let key_store = self.client.internal.get_key_store();
246        let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
247        Ok(decrypted_key)
248    }
249
250    /// Returns a new client for performing admin operations.
251    /// Uses the admin server API endpoints and does not modify local state.
252    pub fn admin(&self) -> CipherAdminClient {
253        CipherAdminClient {
254            client: self.client.clone(),
255        }
256    }
257}
258
259impl CiphersClient {
260    fn get_repository(&self) -> Result<Arc<dyn Repository<Cipher>>, RepositoryError> {
261        Ok(self.client.platform().state().get::<Cipher>()?)
262    }
263}
264
265#[cfg(test)]
266mod tests {
267
268    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
269    #[cfg(feature = "wasm")]
270    use bitwarden_crypto::CryptoError;
271
272    use super::*;
273    use crate::{Attachment, CipherRepromptType, CipherType, Login, LoginView, VaultClientExt};
274
275    fn test_cipher() -> Cipher {
276        Cipher {
277            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
278            organization_id: None,
279            folder_id: None,
280            collection_ids: vec![],
281            key: None,
282            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
283            notes: None,
284            r#type: CipherType::Login,
285            login: Some(Login{
286                username: None,
287                password: None,
288                password_revision_date: None,
289                uris:None,
290                totp: None,
291                autofill_on_page_load: None,
292                fido2_credentials: None,
293            }),
294            identity: None,
295            card: None,
296            secure_note: None,
297            ssh_key: None,
298            favorite: false,
299            reprompt: CipherRepromptType::None,
300            organization_use_totp: true,
301            edit: true,
302            permissions: None,
303            view_password: true,
304            local_data: None,
305            attachments: None,
306            fields:  None,
307            password_history: None,
308            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
309            deleted_date: None,
310            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
311            archived_date: None,
312            data: None,
313        }
314    }
315
316    fn test_cipher_view() -> CipherView {
317        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
318        CipherView {
319            r#type: CipherType::Login,
320            login: Some(LoginView {
321                username: Some("test_username".to_string()),
322                password: Some("test_password".to_string()),
323                password_revision_date: None,
324                uris: None,
325                totp: None,
326                autofill_on_page_load: None,
327                fido2_credentials: None,
328            }),
329            id: Some(test_id),
330            organization_id: None,
331            folder_id: None,
332            collection_ids: vec![],
333            key: None,
334            name: "My test login".to_string(),
335            notes: None,
336            identity: None,
337            card: None,
338            secure_note: None,
339            ssh_key: None,
340            favorite: false,
341            reprompt: CipherRepromptType::None,
342            organization_use_totp: true,
343            edit: true,
344            permissions: None,
345            view_password: true,
346            local_data: None,
347            attachments: None,
348            attachment_decryption_failures: None,
349            fields: None,
350            password_history: None,
351            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
352            deleted_date: None,
353            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
354            archived_date: None,
355        }
356    }
357
358    fn test_attachment_legacy() -> Attachment {
359        Attachment {
360            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
361            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
362            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
363            key: None,
364            size: Some("65".to_string()),
365            size_name: Some("65 Bytes".to_string()),
366        }
367    }
368
369    fn test_attachment_v2() -> Attachment {
370        Attachment {
371            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
372            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
373            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
374            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
375            size: Some("65".to_string()),
376            size_name: Some("65 Bytes".to_string()),
377        }
378    }
379
380    #[tokio::test]
381    async fn test_decrypt_list() {
382        let client = Client::init_test_account(test_bitwarden_com_account()).await;
383
384        let dec = client
385            .vault()
386            .ciphers()
387            .decrypt_list(vec![Cipher {
388                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
389                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
390                folder_id: None,
391                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
392                key: None,
393                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
394                notes: None,
395                r#type: CipherType::Login,
396                login: Some(Login{
397                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
398                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
399                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
400                identity: None,
401                card: None,
402                secure_note: None,
403                ssh_key: None,
404                favorite: false,
405                reprompt: CipherRepromptType::None,
406                organization_use_totp: true,
407                edit: true,
408                permissions: None,
409                view_password: true,
410                local_data: None,
411                attachments: None,
412                fields:  None,
413                password_history: None,
414                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
415                deleted_date: None,
416                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
417                archived_date: None,
418                data: None,
419            }])
420
421            .unwrap();
422
423        assert_eq!(dec[0].name, "Test item");
424    }
425
426    #[tokio::test]
427    async fn test_decrypt_list_with_failures_all_success() {
428        let client = Client::init_test_account(test_bitwarden_com_account()).await;
429
430        let valid_cipher = test_cipher();
431
432        let result = client
433            .vault()
434            .ciphers()
435            .decrypt_list_with_failures(vec![valid_cipher]);
436
437        assert_eq!(result.successes.len(), 1);
438        assert!(result.failures.is_empty());
439        assert_eq!(result.successes[0].name, "234234");
440    }
441
442    #[tokio::test]
443    async fn test_decrypt_list_with_failures_mixed_results() {
444        let client = Client::init_test_account(test_bitwarden_com_account()).await;
445        let valid_cipher = test_cipher();
446        let mut invalid_cipher = test_cipher();
447        // Set an invalid encryptedkey to cause decryption failure
448        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
449
450        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
451
452        let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
453
454        assert_eq!(result.successes.len(), 1);
455        assert_eq!(result.failures.len(), 1);
456
457        assert_eq!(result.successes[0].name, "234234");
458    }
459
460    #[tokio::test]
461    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
462        let client = Client::init_test_account(test_bitwarden_com_account()).await;
463
464        let mut cipher = test_cipher();
465        cipher.attachments = Some(vec![test_attachment_legacy()]);
466
467        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
468
469        //  Move cipher to organization
470        let res = client.vault().ciphers().move_to_organization(
471            view,
472            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
473        );
474
475        assert!(res.is_err());
476    }
477
478    #[tokio::test]
479    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
480        let client = Client::init_test_account(test_bitwarden_com_account()).await;
481
482        let mut cipher = test_cipher();
483        let attachment = test_attachment_legacy();
484        cipher.attachments = Some(vec![attachment.clone()]);
485
486        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
487
488        assert!(cipher.key.is_none());
489
490        // Assert the cipher has a key, and the attachment is still readable
491        let EncryptionContext {
492            cipher: new_cipher,
493            encrypted_for: _,
494        } = client.vault().ciphers().encrypt(view).unwrap();
495        assert!(new_cipher.key.is_some());
496
497        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
498        let attachments = view.clone().attachments.unwrap();
499        let attachment_view = attachments.first().unwrap().clone();
500        assert!(attachment_view.key.is_none());
501
502        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
503
504        let buf = vec![
505            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
506            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
507            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
508            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
509        ];
510
511        let content = client
512            .vault()
513            .attachments()
514            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
515            .unwrap();
516
517        assert_eq!(content, b"Hello");
518    }
519
520    #[tokio::test]
521    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
522        let client = Client::init_test_account(test_bitwarden_com_account()).await;
523
524        let mut cipher = test_cipher();
525        let attachment = test_attachment_v2();
526        cipher.attachments = Some(vec![attachment.clone()]);
527
528        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
529
530        assert!(cipher.key.is_none());
531
532        // Assert the cipher has a key, and the attachment is still readable
533        let EncryptionContext {
534            cipher: new_cipher,
535            encrypted_for: _,
536        } = client.vault().ciphers().encrypt(view).unwrap();
537        assert!(new_cipher.key.is_some());
538
539        let view = client
540            .vault()
541            .ciphers()
542            .decrypt(new_cipher.clone())
543            .unwrap();
544        let attachments = view.clone().attachments.unwrap();
545        let attachment_view = attachments.first().unwrap().clone();
546        assert!(attachment_view.key.is_some());
547
548        // Ensure attachment key is updated since it's now protected by the cipher key
549        assert_ne!(
550            attachment.clone().key.unwrap().to_string(),
551            attachment_view.clone().key.unwrap().to_string()
552        );
553
554        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
555
556        let buf = vec![
557            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
558            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
559            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
560            188, 196, 170, 127, 67, 44, 129, 90,
561        ];
562
563        let content = client
564            .vault()
565            .attachments()
566            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
567            .unwrap();
568
569        assert_eq!(content, b"Hello");
570
571        // Move cipher to organization
572        let new_view = client
573            .vault()
574            .ciphers()
575            .move_to_organization(
576                view,
577                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
578            )
579            .unwrap();
580        let EncryptionContext {
581            cipher: new_cipher,
582            encrypted_for: _,
583        } = client.vault().ciphers().encrypt(new_view).unwrap();
584
585        let attachment = new_cipher
586            .clone()
587            .attachments
588            .unwrap()
589            .first()
590            .unwrap()
591            .clone();
592
593        // Ensure attachment key is still the same since it's protected by the cipher key
594        assert_eq!(
595            attachment.clone().key.as_ref().unwrap().to_string(),
596            attachment_view.key.as_ref().unwrap().to_string()
597        );
598
599        let content = client
600            .vault()
601            .attachments()
602            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
603            .unwrap();
604
605        assert_eq!(content, b"Hello");
606    }
607
608    #[tokio::test]
609    #[cfg(feature = "wasm")]
610    async fn test_decrypt_list_full_with_failures_all_success() {
611        let client = Client::init_test_account(test_bitwarden_com_account()).await;
612
613        let valid_cipher = test_cipher();
614
615        let result = client
616            .vault()
617            .ciphers()
618            .decrypt_list_full_with_failures(vec![valid_cipher]);
619
620        assert_eq!(result.successes.len(), 1);
621        assert!(result.failures.is_empty());
622        assert_eq!(result.successes[0].name, "234234");
623    }
624
625    #[tokio::test]
626    #[cfg(feature = "wasm")]
627    async fn test_decrypt_list_full_with_failures_mixed_results() {
628        let client = Client::init_test_account(test_bitwarden_com_account()).await;
629        let valid_cipher = test_cipher();
630        let mut invalid_cipher = test_cipher();
631        // Set an invalid encrypted key to cause decryption failure
632        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
633
634        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
635
636        let result = client
637            .vault()
638            .ciphers()
639            .decrypt_list_full_with_failures(ciphers);
640
641        assert_eq!(result.successes.len(), 1);
642        assert_eq!(result.failures.len(), 1);
643
644        assert_eq!(result.successes[0].name, "234234");
645    }
646
647    #[tokio::test]
648    #[cfg(feature = "wasm")]
649    async fn test_decrypt_list_full_with_failures_all_failures() {
650        let client = Client::init_test_account(test_bitwarden_com_account()).await;
651        let mut invalid_cipher1 = test_cipher();
652        let mut invalid_cipher2 = test_cipher();
653        // Set invalid encrypted keys to cause decryption failures
654        invalid_cipher1.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
655        invalid_cipher2.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
656
657        let ciphers = vec![invalid_cipher1, invalid_cipher2];
658
659        let result = client
660            .vault()
661            .ciphers()
662            .decrypt_list_full_with_failures(ciphers);
663
664        assert!(result.successes.is_empty());
665        assert_eq!(result.failures.len(), 2);
666    }
667
668    #[tokio::test]
669    #[cfg(feature = "wasm")]
670    async fn test_decrypt_list_full_with_failures_empty_list() {
671        let client = Client::init_test_account(test_bitwarden_com_account()).await;
672
673        let result = client
674            .vault()
675            .ciphers()
676            .decrypt_list_full_with_failures(vec![]);
677
678        assert!(result.successes.is_empty());
679        assert!(result.failures.is_empty());
680    }
681
682    #[tokio::test]
683    #[cfg(feature = "wasm")]
684    async fn test_encrypt_cipher_for_rotation() {
685        let client = Client::init_test_account(test_bitwarden_com_account()).await;
686
687        let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
688
689        let cipher_view = test_cipher_view();
690        let new_key_b64 = new_key.to_base64();
691
692        let ctx = client
693            .vault()
694            .ciphers()
695            .encrypt_cipher_for_rotation(cipher_view, new_key_b64)
696            .unwrap();
697
698        assert!(ctx.cipher.key.is_some());
699
700        // Decrypting the cipher "normally" will fail because it was encrypted with a new key
701        assert!(matches!(
702            client.vault().ciphers().decrypt(ctx.cipher).err(),
703            Some(DecryptError::Crypto(CryptoError::Decrypt))
704        ));
705    }
706
707    #[tokio::test]
708    async fn test_encrypt_list() {
709        let client = Client::init_test_account(test_bitwarden_com_account()).await;
710
711        let cipher_views = vec![test_cipher_view(), test_cipher_view()];
712
713        let result = client.vault().ciphers().encrypt_list(cipher_views);
714
715        assert!(result.is_ok());
716        let contexts = result.unwrap();
717        assert_eq!(contexts.len(), 2);
718
719        // Verify each encrypted cipher has a key (cipher key encryption is enabled)
720        for ctx in &contexts {
721            assert!(ctx.cipher.key.is_some());
722        }
723    }
724
725    #[tokio::test]
726    async fn test_encrypt_list_empty() {
727        let client = Client::init_test_account(test_bitwarden_com_account()).await;
728
729        let result = client.vault().ciphers().encrypt_list(vec![]);
730
731        assert!(result.is_ok());
732        assert!(result.unwrap().is_empty());
733    }
734
735    #[tokio::test]
736    async fn test_encrypt_list_roundtrip() {
737        let client = Client::init_test_account(test_bitwarden_com_account()).await;
738
739        let original_views = vec![test_cipher_view(), test_cipher_view()];
740        let original_names: Vec<_> = original_views.iter().map(|v| v.name.clone()).collect();
741
742        let contexts = client
743            .vault()
744            .ciphers()
745            .encrypt_list(original_views)
746            .unwrap();
747
748        // Decrypt each cipher and verify the name matches
749        for (ctx, original_name) in contexts.iter().zip(original_names.iter()) {
750            let decrypted = client
751                .vault()
752                .ciphers()
753                .decrypt(ctx.cipher.clone())
754                .unwrap();
755            assert_eq!(&decrypted.name, original_name);
756        }
757    }
758
759    #[tokio::test]
760    async fn test_encrypt_list_preserves_user_id() {
761        let client = Client::init_test_account(test_bitwarden_com_account()).await;
762
763        let expected_user_id = client.internal.get_user_id().unwrap();
764
765        let cipher_views = vec![test_cipher_view(), test_cipher_view(), test_cipher_view()];
766        let contexts = client.vault().ciphers().encrypt_list(cipher_views).unwrap();
767
768        for ctx in contexts {
769            assert_eq!(ctx.encrypted_for, expected_user_id);
770        }
771    }
772}