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