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