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;
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 delete_attachment;
26mod edit;
27mod get;
28mod restore;
29mod share_cipher;
30
31#[allow(missing_docs)]
32#[cfg_attr(feature = "wasm", wasm_bindgen)]
33pub struct CiphersClient {
34    pub(crate) client: Client,
35}
36
37#[cfg_attr(feature = "wasm", wasm_bindgen)]
38impl CiphersClient {
39    #[allow(missing_docs)]
40    pub fn encrypt(&self, mut cipher_view: CipherView) -> Result<EncryptionContext, EncryptError> {
41        let user_id = self
42            .client
43            .internal
44            .get_user_id()
45            .ok_or(EncryptError::MissingUserId)?;
46        let key_store = self.client.internal.get_key_store();
47
48        // TODO: Once this flag is removed, the key generation logic should
49        // be moved directly into the KeyEncryptable implementation
50        if cipher_view.key.is_none()
51            && self
52                .client
53                .internal
54                .get_flags()
55                .enable_cipher_key_encryption
56        {
57            let key = cipher_view.key_identifier();
58            cipher_view.generate_cipher_key(&mut key_store.context(), key)?;
59        }
60
61        let cipher = key_store.encrypt(cipher_view)?;
62        Ok(EncryptionContext {
63            cipher,
64            encrypted_for: user_id,
65        })
66    }
67
68    /// Encrypt a cipher with the provided key. This should only be used when rotating encryption
69    /// keys in the Web client.
70    ///
71    /// Until key rotation is fully implemented in the SDK, this method must be provided the new
72    /// symmetric key in base64 format. See PM-23084
73    ///
74    /// If the cipher has a CipherKey, it will be re-encrypted with the new key.
75    /// If the cipher does not have a CipherKey and CipherKeyEncryption is enabled, one will be
76    /// generated using the new key. Otherwise, the cipher's data will be encrypted with the new
77    /// key directly.
78    #[cfg(feature = "wasm")]
79    pub fn encrypt_cipher_for_rotation(
80        &self,
81        mut cipher_view: CipherView,
82        new_key: B64,
83    ) -> Result<EncryptionContext, CipherError> {
84        let new_key = SymmetricCryptoKey::try_from(new_key)?;
85
86        let user_id = self
87            .client
88            .internal
89            .get_user_id()
90            .ok_or(EncryptError::MissingUserId)?;
91        let key_store = self.client.internal.get_key_store();
92        let mut ctx = key_store.context();
93
94        // Set the new key in the key store context
95        let new_key_id = ctx.add_local_symmetric_key(new_key);
96
97        if cipher_view.key.is_none()
98            && self
99                .client
100                .internal
101                .get_flags()
102                .enable_cipher_key_encryption
103        {
104            cipher_view.generate_cipher_key(&mut ctx, new_key_id)?;
105        } else {
106            cipher_view.reencrypt_cipher_keys(&mut ctx, new_key_id)?;
107        }
108
109        let cipher = cipher_view.encrypt_composite(&mut ctx, new_key_id)?;
110
111        Ok(EncryptionContext {
112            cipher,
113            encrypted_for: user_id,
114        })
115    }
116
117    /// Encrypt a list of cipher views.
118    ///
119    /// This method attempts to encrypt all ciphers in the list. If any cipher
120    /// fails to encrypt, the entire operation fails and an error is returned.
121    #[cfg(feature = "wasm")]
122    pub fn encrypt_list(
123        &self,
124        cipher_views: Vec<CipherView>,
125    ) -> Result<Vec<EncryptionContext>, EncryptError> {
126        let user_id = self
127            .client
128            .internal
129            .get_user_id()
130            .ok_or(EncryptError::MissingUserId)?;
131        let key_store = self.client.internal.get_key_store();
132        let enable_cipher_key = self
133            .client
134            .internal
135            .get_flags()
136            .enable_cipher_key_encryption;
137
138        let mut ctx = key_store.context();
139
140        let prepared_views: Vec<CipherView> = cipher_views
141            .into_iter()
142            .map(|mut cv| {
143                if cv.key.is_none() && enable_cipher_key {
144                    let key = cv.key_identifier();
145                    cv.generate_cipher_key(&mut ctx, key)?;
146                }
147                Ok(cv)
148            })
149            .collect::<Result<Vec<_>, bitwarden_crypto::CryptoError>>()?;
150
151        let ciphers: Vec<Cipher> = key_store.encrypt_list(&prepared_views)?;
152
153        Ok(ciphers
154            .into_iter()
155            .map(|cipher| EncryptionContext {
156                cipher,
157                encrypted_for: user_id,
158            })
159            .collect())
160    }
161
162    #[allow(missing_docs)]
163    pub fn decrypt(&self, cipher: Cipher) -> Result<CipherView, DecryptError> {
164        let key_store = self.client.internal.get_key_store();
165        let cipher_view = key_store.decrypt(&cipher)?;
166        Ok(cipher_view)
167    }
168
169    #[allow(missing_docs)]
170    pub fn decrypt_list(&self, ciphers: Vec<Cipher>) -> Result<Vec<CipherListView>, DecryptError> {
171        let key_store = self.client.internal.get_key_store();
172        let cipher_views = key_store.decrypt_list(&ciphers)?;
173        Ok(cipher_views)
174    }
175
176    /// Decrypt cipher list with failures
177    /// Returns both successfully decrypted ciphers and any that failed to decrypt
178    pub fn decrypt_list_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherListResult {
179        let key_store = self.client.internal.get_key_store();
180        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
181
182        DecryptCipherListResult {
183            successes,
184            failures: failures.into_iter().cloned().collect(),
185        }
186    }
187
188    /// Decrypt full cipher list
189    /// Returns both successfully fully decrypted ciphers and any that failed to decrypt
190    #[cfg(feature = "wasm")]
191    pub fn decrypt_list_full_with_failures(&self, ciphers: Vec<Cipher>) -> DecryptCipherResult {
192        let key_store = self.client.internal.get_key_store();
193        let (successes, failures) = key_store.decrypt_list_with_failures(&ciphers);
194
195        DecryptCipherResult {
196            successes,
197            failures: failures.into_iter().cloned().collect(),
198        }
199    }
200
201    #[allow(missing_docs)]
202    pub fn decrypt_fido2_credentials(
203        &self,
204        cipher_view: CipherView,
205    ) -> Result<Vec<crate::Fido2CredentialView>, DecryptError> {
206        let key_store = self.client.internal.get_key_store();
207        let credentials = cipher_view.decrypt_fido2_credentials(&mut key_store.context())?;
208        Ok(credentials)
209    }
210
211    /// Temporary method used to re-encrypt FIDO2 credentials for a cipher view.
212    /// Necessary until the TS clients utilize the SDK entirely for FIDO2 credentials management.
213    /// TS clients create decrypted FIDO2 credentials that need to be encrypted manually when
214    /// encrypting the rest of the CipherView.
215    /// TODO: Remove once TS passkey provider implementation uses SDK - PM-8313
216    #[cfg(feature = "wasm")]
217    pub fn set_fido2_credentials(
218        &self,
219        mut cipher_view: CipherView,
220        fido2_credentials: Vec<Fido2CredentialFullView>,
221    ) -> Result<CipherView, CipherError> {
222        let key_store = self.client.internal.get_key_store();
223
224        cipher_view.set_new_fido2_credentials(&mut key_store.context(), fido2_credentials)?;
225
226        Ok(cipher_view)
227    }
228
229    #[allow(missing_docs)]
230    pub fn move_to_organization(
231        &self,
232        mut cipher_view: CipherView,
233        organization_id: OrganizationId,
234    ) -> Result<CipherView, CipherError> {
235        let key_store = self.client.internal.get_key_store();
236        cipher_view.move_to_organization(&mut key_store.context(), organization_id)?;
237        Ok(cipher_view)
238    }
239
240    #[cfg(feature = "wasm")]
241    #[allow(missing_docs)]
242    pub fn decrypt_fido2_private_key(
243        &self,
244        cipher_view: CipherView,
245    ) -> Result<String, CipherError> {
246        let key_store = self.client.internal.get_key_store();
247        let decrypted_key = cipher_view.decrypt_fido2_private_key(&mut key_store.context())?;
248        Ok(decrypted_key)
249    }
250
251    /// Returns a new client for performing admin operations.
252    /// Uses the admin server API endpoints and does not modify local state.
253    pub fn admin(&self) -> CipherAdminClient {
254        CipherAdminClient {
255            client: self.client.clone(),
256        }
257    }
258}
259
260impl CiphersClient {
261    fn get_repository(&self) -> Result<Arc<dyn Repository<Cipher>>, RepositoryError> {
262        Ok(self.client.platform().state().get::<Cipher>()?)
263    }
264}
265
266#[cfg(test)]
267mod tests {
268
269    use bitwarden_core::client::test_accounts::test_bitwarden_com_account;
270    #[cfg(feature = "wasm")]
271    use bitwarden_crypto::CryptoError;
272
273    use super::*;
274    use crate::{Attachment, CipherRepromptType, CipherType, Login, LoginView, VaultClientExt};
275
276    fn test_cipher() -> Cipher {
277        Cipher {
278            id: Some("358f2b2b-9326-4e5e-94a8-b18100bb0908".parse().unwrap()),
279            organization_id: None,
280            folder_id: None,
281            collection_ids: vec![],
282            key: None,
283            name: "2.+oPT8B4xJhyhQRe1VkIx0A==|PBtC/bZkggXR+fSnL/pG7g==|UkjRD0VpnUYkjRC/05ZLdEBAmRbr3qWRyJey2bUvR9w=".parse().unwrap(),
284            notes: None,
285            r#type: CipherType::Login,
286            login: Some(Login{
287                username: None,
288                password: None,
289                password_revision_date: None,
290                uris:None,
291                totp: None,
292                autofill_on_page_load: None,
293                fido2_credentials: None,
294            }),
295            identity: None,
296            card: None,
297            secure_note: None,
298            ssh_key: None,
299            favorite: false,
300            reprompt: CipherRepromptType::None,
301            organization_use_totp: true,
302            edit: true,
303            permissions: None,
304            view_password: true,
305            local_data: None,
306            attachments: None,
307            fields:  None,
308            password_history: None,
309            creation_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
310            deleted_date: None,
311            revision_date: "2024-05-31T11:20:58.4566667Z".parse().unwrap(),
312            archived_date: None,
313            data: None,
314        }
315    }
316
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(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    #[tokio::test]
709    async fn test_encrypt_list() {
710        let client = Client::init_test_account(test_bitwarden_com_account()).await;
711
712        let cipher_views = vec![test_cipher_view(), test_cipher_view()];
713
714        let result = client.vault().ciphers().encrypt_list(cipher_views);
715
716        assert!(result.is_ok());
717        let contexts = result.unwrap();
718        assert_eq!(contexts.len(), 2);
719
720        // Verify each encrypted cipher has a key (cipher key encryption is enabled)
721        for ctx in &contexts {
722            assert!(ctx.cipher.key.is_some());
723        }
724    }
725
726    #[tokio::test]
727    async fn test_encrypt_list_empty() {
728        let client = Client::init_test_account(test_bitwarden_com_account()).await;
729
730        let result = client.vault().ciphers().encrypt_list(vec![]);
731
732        assert!(result.is_ok());
733        assert!(result.unwrap().is_empty());
734    }
735
736    #[tokio::test]
737    async fn test_encrypt_list_roundtrip() {
738        let client = Client::init_test_account(test_bitwarden_com_account()).await;
739
740        let original_views = vec![test_cipher_view(), test_cipher_view()];
741        let original_names: Vec<_> = original_views.iter().map(|v| v.name.clone()).collect();
742
743        let contexts = client
744            .vault()
745            .ciphers()
746            .encrypt_list(original_views)
747            .unwrap();
748
749        // Decrypt each cipher and verify the name matches
750        for (ctx, original_name) in contexts.iter().zip(original_names.iter()) {
751            let decrypted = client
752                .vault()
753                .ciphers()
754                .decrypt(ctx.cipher.clone())
755                .unwrap();
756            assert_eq!(&decrypted.name, original_name);
757        }
758    }
759
760    #[tokio::test]
761    async fn test_encrypt_list_preserves_user_id() {
762        let client = Client::init_test_account(test_bitwarden_com_account()).await;
763
764        let expected_user_id = client.internal.get_user_id().unwrap();
765
766        let cipher_views = vec![test_cipher_view(), test_cipher_view(), test_cipher_view()];
767        let contexts = client.vault().ciphers().encrypt_list(cipher_views).unwrap();
768
769        for ctx in contexts {
770            assert_eq!(ctx.encrypted_for, expected_user_id);
771        }
772    }
773}