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