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