Skip to main content

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;
14use crate::{
15    Cipher, CipherError, CipherListView, CipherView, DecryptError, EncryptError,
16    cipher::cipher::DecryptCipherListResult, cipher_client::admin::CipherAdminClient,
17};
18#[cfg(feature = "wasm")]
19use crate::{Fido2CredentialFullView, cipher::cipher::DecryptCipherResult};
20
21mod admin;
22mod create;
23mod delete;
24mod delete_attachment;
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, 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    #[cfg(feature = "wasm")]
317    fn test_cipher_view() -> CipherView {
318        let test_id = "fd411a1a-fec8-4070-985d-0e6560860e69".parse().unwrap();
319        CipherView {
320            r#type: CipherType::Login,
321            login: Some(crate::LoginView {
322                username: Some("test_username".to_string()),
323                password: Some("test_password".to_string()),
324                password_revision_date: None,
325                uris: None,
326                totp: None,
327                autofill_on_page_load: None,
328                fido2_credentials: None,
329            }),
330            id: Some(test_id),
331            organization_id: None,
332            folder_id: None,
333            collection_ids: vec![],
334            key: None,
335            name: "My test login".to_string(),
336            notes: None,
337            identity: None,
338            card: None,
339            secure_note: None,
340            ssh_key: None,
341            favorite: false,
342            reprompt: CipherRepromptType::None,
343            organization_use_totp: true,
344            edit: true,
345            permissions: None,
346            view_password: true,
347            local_data: None,
348            attachments: None,
349            attachment_decryption_failures: None,
350            fields: None,
351            password_history: None,
352            creation_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
353            deleted_date: None,
354            revision_date: "2024-01-30T17:55:36.150Z".parse().unwrap(),
355            archived_date: None,
356        }
357    }
358
359    fn test_attachment_legacy() -> Attachment {
360        Attachment {
361            id: Some("uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
362            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
363            file_name: Some("2.mV50WiLq6duhwGbhM1TO0A==|dTufWNH8YTPP0EMlNLIpFA==|QHp+7OM8xHtEmCfc9QPXJ0Ro2BeakzvLgxJZ7NdLuDc=".parse().unwrap()),
364            key: None,
365            size: Some("65".to_string()),
366            size_name: Some("65 Bytes".to_string()),
367        }
368    }
369
370    fn test_attachment_v2() -> Attachment {
371        Attachment {
372            id: Some("a77m56oerrz5b92jm05lq5qoyj1xh2t9".to_string()),
373            url: Some("http://localhost:4000/attachments//358f2b2b-9326-4e5e-94a8-b18100bb0908/uf7bkexzag04d3cw04jsbqqkbpbwhxs0".to_string()),
374            file_name: Some("2.GhazFdCYQcM5v+AtVwceQA==|98bMUToqC61VdVsSuXWRwA==|bsLByMht9Hy5QO9pPMRz0K4d0aqBiYnnROGM5YGbNu4=".parse().unwrap()),
375            key: Some("2.6TPEiYULFg/4+3CpDRwCqw==|6swweBHCJcd5CHdwBBWuRN33XRV22VoroDFDUmiM4OzjPEAhgZK57IZS1KkBlCcFvT+t+YbsmDcdv+Lqr+iJ3MmzfJ40MCB5TfYy+22HVRA=|rkgFDh2IWTfPC1Y66h68Diiab/deyi1p/X0Fwkva0NQ=".parse().unwrap()),
376            size: Some("65".to_string()),
377            size_name: Some("65 Bytes".to_string()),
378        }
379    }
380
381    #[tokio::test]
382    async fn test_decrypt_list() {
383        let client = Client::init_test_account(test_bitwarden_com_account()).await;
384
385        let dec = client
386            .vault()
387            .ciphers()
388            .decrypt_list(vec![Cipher {
389                id: Some("a1569f46-0797-4d3f-b859-b181009e2e49".parse().unwrap()),
390                organization_id: Some("1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap()),
391                folder_id: None,
392                collection_ids: vec!["66c5ca57-0868-4c7e-902f-b181009709c0".parse().unwrap()],
393                key: None,
394                name: "2.RTdUGVWYl/OZHUMoy68CMg==|sCaT5qHx8i0rIvzVrtJKww==|jB8DsRws6bXBtXNfNXUmFJ0JLDlB6GON6Y87q0jgJ+0=".parse().unwrap(),
395                notes: None,
396                r#type: CipherType::Login,
397                login: Some(Login{
398                    username: Some("2.ouEYEk+SViUtqncesfe9Ag==|iXzEJq1zBeNdDbumFO1dUA==|RqMoo9soSwz/yB99g6YPqk8+ASWRcSdXsKjbwWzyy9U=".parse().unwrap()),
399                    password: Some("2.6yXnOz31o20Z2kiYDnXueA==|rBxTb6NK9lkbfdhrArmacw==|ogZir8Z8nLgiqlaLjHH+8qweAtItS4P2iPv1TELo5a0=".parse().unwrap()),
400                    password_revision_date: None, uris:None, totp: None, autofill_on_page_load: None, fido2_credentials: None }),
401                identity: None,
402                card: None,
403                secure_note: None,
404                ssh_key: None,
405                favorite: false,
406                reprompt: CipherRepromptType::None,
407                organization_use_totp: true,
408                edit: true,
409                permissions: None,
410                view_password: true,
411                local_data: None,
412                attachments: None,
413                fields:  None,
414                password_history: None,
415                creation_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
416                deleted_date: None,
417                revision_date: "2024-05-31T09:35:55.12Z".parse().unwrap(),
418                archived_date: None,
419                data: None,
420            }])
421
422            .unwrap();
423
424        assert_eq!(dec[0].name, "Test item");
425    }
426
427    #[tokio::test]
428    async fn test_decrypt_list_with_failures_all_success() {
429        let client = Client::init_test_account(test_bitwarden_com_account()).await;
430
431        let valid_cipher = test_cipher();
432
433        let result = client
434            .vault()
435            .ciphers()
436            .decrypt_list_with_failures(vec![valid_cipher]);
437
438        assert_eq!(result.successes.len(), 1);
439        assert!(result.failures.is_empty());
440        assert_eq!(result.successes[0].name, "234234");
441    }
442
443    #[tokio::test]
444    async fn test_decrypt_list_with_failures_mixed_results() {
445        let client = Client::init_test_account(test_bitwarden_com_account()).await;
446        let valid_cipher = test_cipher();
447        let mut invalid_cipher = test_cipher();
448        // Set an invalid encryptedkey to cause decryption failure
449        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
450
451        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
452
453        let result = client.vault().ciphers().decrypt_list_with_failures(ciphers);
454
455        assert_eq!(result.successes.len(), 1);
456        assert_eq!(result.failures.len(), 1);
457
458        assert_eq!(result.successes[0].name, "234234");
459    }
460
461    #[tokio::test]
462    async fn test_move_user_cipher_with_attachment_without_key_to_org_fails() {
463        let client = Client::init_test_account(test_bitwarden_com_account()).await;
464
465        let mut cipher = test_cipher();
466        cipher.attachments = Some(vec![test_attachment_legacy()]);
467
468        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
469
470        //  Move cipher to organization
471        let res = client.vault().ciphers().move_to_organization(
472            view,
473            "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
474        );
475
476        assert!(res.is_err());
477    }
478
479    #[tokio::test]
480    async fn test_encrypt_cipher_with_legacy_attachment_without_key() {
481        let client = Client::init_test_account(test_bitwarden_com_account()).await;
482
483        let mut cipher = test_cipher();
484        let attachment = test_attachment_legacy();
485        cipher.attachments = Some(vec![attachment.clone()]);
486
487        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
488
489        assert!(cipher.key.is_none());
490
491        // Assert the cipher has a key, and the attachment is still readable
492        let EncryptionContext {
493            cipher: new_cipher,
494            encrypted_for: _,
495        } = client.vault().ciphers().encrypt(view).unwrap();
496        assert!(new_cipher.key.is_some());
497
498        let view = client.vault().ciphers().decrypt(new_cipher).unwrap();
499        let attachments = view.clone().attachments.unwrap();
500        let attachment_view = attachments.first().unwrap().clone();
501        assert!(attachment_view.key.is_none());
502
503        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
504
505        let buf = vec![
506            2, 100, 205, 148, 152, 77, 184, 77, 53, 80, 38, 240, 83, 217, 251, 118, 254, 27, 117,
507            41, 148, 244, 216, 110, 216, 255, 104, 215, 23, 15, 176, 239, 208, 114, 95, 159, 23,
508            211, 98, 24, 145, 166, 60, 197, 42, 204, 131, 144, 253, 204, 195, 154, 27, 201, 215,
509            43, 10, 244, 107, 226, 152, 85, 167, 66, 185,
510        ];
511
512        let content = client
513            .vault()
514            .attachments()
515            .decrypt_buffer(cipher, attachment_view.clone(), buf.as_slice())
516            .unwrap();
517
518        assert_eq!(content, b"Hello");
519    }
520
521    #[tokio::test]
522    async fn test_encrypt_cipher_with_v1_attachment_without_key() {
523        let client = Client::init_test_account(test_bitwarden_com_account()).await;
524
525        let mut cipher = test_cipher();
526        let attachment = test_attachment_v2();
527        cipher.attachments = Some(vec![attachment.clone()]);
528
529        let view = client.vault().ciphers().decrypt(cipher.clone()).unwrap();
530
531        assert!(cipher.key.is_none());
532
533        // Assert the cipher has a key, and the attachment is still readable
534        let EncryptionContext {
535            cipher: new_cipher,
536            encrypted_for: _,
537        } = client.vault().ciphers().encrypt(view).unwrap();
538        assert!(new_cipher.key.is_some());
539
540        let view = client
541            .vault()
542            .ciphers()
543            .decrypt(new_cipher.clone())
544            .unwrap();
545        let attachments = view.clone().attachments.unwrap();
546        let attachment_view = attachments.first().unwrap().clone();
547        assert!(attachment_view.key.is_some());
548
549        // Ensure attachment key is updated since it's now protected by the cipher key
550        assert_ne!(
551            attachment.clone().key.unwrap().to_string(),
552            attachment_view.clone().key.unwrap().to_string()
553        );
554
555        assert_eq!(attachment_view.file_name.as_deref(), Some("h.txt"));
556
557        let buf = vec![
558            2, 114, 53, 72, 20, 82, 18, 46, 48, 137, 97, 1, 100, 142, 120, 187, 28, 36, 180, 46,
559            189, 254, 133, 23, 169, 58, 73, 212, 172, 116, 185, 127, 111, 92, 112, 145, 99, 28,
560            158, 198, 48, 241, 121, 218, 66, 37, 152, 197, 122, 241, 110, 82, 245, 72, 47, 230, 95,
561            188, 196, 170, 127, 67, 44, 129, 90,
562        ];
563
564        let content = client
565            .vault()
566            .attachments()
567            .decrypt_buffer(new_cipher.clone(), attachment_view.clone(), buf.as_slice())
568            .unwrap();
569
570        assert_eq!(content, b"Hello");
571
572        // Move cipher to organization
573        let new_view = client
574            .vault()
575            .ciphers()
576            .move_to_organization(
577                view,
578                "1bc9ac1e-f5aa-45f2-94bf-b181009709b8".parse().unwrap(),
579            )
580            .unwrap();
581        let EncryptionContext {
582            cipher: new_cipher,
583            encrypted_for: _,
584        } = client.vault().ciphers().encrypt(new_view).unwrap();
585
586        let attachment = new_cipher
587            .clone()
588            .attachments
589            .unwrap()
590            .first()
591            .unwrap()
592            .clone();
593
594        // Ensure attachment key is still the same since it's protected by the cipher key
595        assert_eq!(
596            attachment.clone().key.as_ref().unwrap().to_string(),
597            attachment_view.key.as_ref().unwrap().to_string()
598        );
599
600        let content = client
601            .vault()
602            .attachments()
603            .decrypt_buffer(new_cipher, attachment_view, buf.as_slice())
604            .unwrap();
605
606        assert_eq!(content, b"Hello");
607    }
608
609    #[tokio::test]
610    #[cfg(feature = "wasm")]
611    async fn test_decrypt_list_full_with_failures_all_success() {
612        let client = Client::init_test_account(test_bitwarden_com_account()).await;
613
614        let valid_cipher = test_cipher();
615
616        let result = client
617            .vault()
618            .ciphers()
619            .decrypt_list_full_with_failures(vec![valid_cipher]);
620
621        assert_eq!(result.successes.len(), 1);
622        assert!(result.failures.is_empty());
623        assert_eq!(result.successes[0].name, "234234");
624    }
625
626    #[tokio::test]
627    #[cfg(feature = "wasm")]
628    async fn test_decrypt_list_full_with_failures_mixed_results() {
629        let client = Client::init_test_account(test_bitwarden_com_account()).await;
630        let valid_cipher = test_cipher();
631        let mut invalid_cipher = test_cipher();
632        // Set an invalid encrypted key to cause decryption failure
633        invalid_cipher.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
634
635        let ciphers = vec![valid_cipher, invalid_cipher.clone()];
636
637        let result = client
638            .vault()
639            .ciphers()
640            .decrypt_list_full_with_failures(ciphers);
641
642        assert_eq!(result.successes.len(), 1);
643        assert_eq!(result.failures.len(), 1);
644
645        assert_eq!(result.successes[0].name, "234234");
646    }
647
648    #[tokio::test]
649    #[cfg(feature = "wasm")]
650    async fn test_decrypt_list_full_with_failures_all_failures() {
651        let client = Client::init_test_account(test_bitwarden_com_account()).await;
652        let mut invalid_cipher1 = test_cipher();
653        let mut invalid_cipher2 = test_cipher();
654        // Set invalid encrypted keys to cause decryption failures
655        invalid_cipher1.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
656        invalid_cipher2.key = Some("2.Gg8yCM4IIgykCZyq0O4+cA==|GJLBtfvSJTDJh/F7X4cJPkzI6ccnzJm5DYl3yxOW2iUn7DgkkmzoOe61sUhC5dgVdV0kFqsZPcQ0yehlN1DDsFIFtrb4x7LwzJNIkMgxNyg=|1rGkGJ8zcM5o5D0aIIwAyLsjMLrPsP3EWm3CctBO3Fw=".parse().unwrap());
657
658        let ciphers = vec![invalid_cipher1, invalid_cipher2];
659
660        let result = client
661            .vault()
662            .ciphers()
663            .decrypt_list_full_with_failures(ciphers);
664
665        assert!(result.successes.is_empty());
666        assert_eq!(result.failures.len(), 2);
667    }
668
669    #[tokio::test]
670    #[cfg(feature = "wasm")]
671    async fn test_decrypt_list_full_with_failures_empty_list() {
672        let client = Client::init_test_account(test_bitwarden_com_account()).await;
673
674        let result = client
675            .vault()
676            .ciphers()
677            .decrypt_list_full_with_failures(vec![]);
678
679        assert!(result.successes.is_empty());
680        assert!(result.failures.is_empty());
681    }
682
683    #[tokio::test]
684    #[cfg(feature = "wasm")]
685    async fn test_encrypt_cipher_for_rotation() {
686        let client = Client::init_test_account(test_bitwarden_com_account()).await;
687
688        let new_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key();
689
690        let cipher_view = test_cipher_view();
691        let new_key_b64 = new_key.to_base64();
692
693        let ctx = client
694            .vault()
695            .ciphers()
696            .encrypt_cipher_for_rotation(cipher_view, new_key_b64)
697            .unwrap();
698
699        assert!(ctx.cipher.key.is_some());
700
701        // Decrypting the cipher "normally" will fail because it was encrypted with a new key
702        assert!(matches!(
703            client.vault().ciphers().decrypt(ctx.cipher).err(),
704            Some(DecryptError::Crypto(CryptoError::Decrypt))
705        ));
706    }
707
708    #[cfg(feature = "wasm")]
709    #[tokio::test]
710    async fn test_encrypt_list() {
711        let client = Client::init_test_account(test_bitwarden_com_account()).await;
712
713        let cipher_views = vec![test_cipher_view(), test_cipher_view()];
714
715        let result = client.vault().ciphers().encrypt_list(cipher_views);
716
717        assert!(result.is_ok());
718        let contexts = result.unwrap();
719        assert_eq!(contexts.len(), 2);
720
721        // Verify each encrypted cipher has a key (cipher key encryption is enabled)
722        for ctx in &contexts {
723            assert!(ctx.cipher.key.is_some());
724        }
725    }
726
727    #[cfg(feature = "wasm")]
728    #[tokio::test]
729    async fn test_encrypt_list_empty() {
730        let client = Client::init_test_account(test_bitwarden_com_account()).await;
731
732        let result = client.vault().ciphers().encrypt_list(vec![]);
733
734        assert!(result.is_ok());
735        assert!(result.unwrap().is_empty());
736    }
737
738    #[cfg(feature = "wasm")]
739    #[tokio::test]
740    async fn test_encrypt_list_roundtrip() {
741        let client = Client::init_test_account(test_bitwarden_com_account()).await;
742
743        let original_views = vec![test_cipher_view(), test_cipher_view()];
744        let original_names: Vec<_> = original_views.iter().map(|v| v.name.clone()).collect();
745
746        let contexts = client
747            .vault()
748            .ciphers()
749            .encrypt_list(original_views)
750            .unwrap();
751
752        // Decrypt each cipher and verify the name matches
753        for (ctx, original_name) in contexts.iter().zip(original_names.iter()) {
754            let decrypted = client
755                .vault()
756                .ciphers()
757                .decrypt(ctx.cipher.clone())
758                .unwrap();
759            assert_eq!(&decrypted.name, original_name);
760        }
761    }
762
763    #[cfg(feature = "wasm")]
764    #[tokio::test]
765    async fn test_encrypt_list_preserves_user_id() {
766        let client = Client::init_test_account(test_bitwarden_com_account()).await;
767
768        let expected_user_id = client.internal.get_user_id().unwrap();
769
770        let cipher_views = vec![test_cipher_view(), test_cipher_view(), test_cipher_view()];
771        let contexts = client.vault().ciphers().encrypt_list(cipher_views).unwrap();
772
773        for ctx in contexts {
774            assert_eq!(ctx.encrypted_for, expected_user_id);
775        }
776    }
777}