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