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