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