Skip to main content

bitwarden_vault/cipher/cipher_client/
mod.rs

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