Skip to main content

bitwarden_user_crypto_management/key_rotation/
data.rs

1//! Functionality for re-encrypting user data during key rotation.
2
3use bitwarden_api_api::models::{
4    AccountDataRequestModel, CipherWithIdRequestModel, SendWithIdRequestModel,
5};
6use bitwarden_core::{
7    UserId,
8    key_management::{KeySlotIds, SymmetricKeySlotId},
9};
10use bitwarden_crypto::{CompositeEncryptable, Decryptable, KeyStoreContext};
11use bitwarden_send::SendView;
12use bitwarden_vault::{CipherView, EncryptMode, EncryptionContext, FolderView};
13use tracing::{debug, debug_span};
14use uuid::Uuid;
15
16use super::RotateUserKeysError;
17
18/// Errors that can occur during data re-encryption
19#[derive(Debug)]
20pub(crate) enum DataReencryptionError {
21    /// Failed to decrypt data with the current user key
22    Decryption,
23    /// Failed to encrypt data with the new user key
24    Encryption,
25    /// Failed to convert data to API model
26    DataConversion,
27    /// CipherKeyRewrap
28    CipherKeyRewrap,
29}
30
31/// Checks that no cipher contains legacy attachments (attachments where `key` is `None`).
32/// Ciphers with old attachments cannot be safely re-encrypted during key rotation because
33/// the attachment file contents are encrypted directly with the user key and would become
34/// irrecoverable after the user key change.
35pub(super) fn check_for_old_attachments(
36    ciphers: &[bitwarden_vault::Cipher],
37) -> Result<(), RotateUserKeysError> {
38    let has_old = ciphers
39        .iter()
40        .filter(|c| c.organization_id.is_none())
41        .any(|c| {
42            c.attachments
43                .as_ref()
44                .is_some_and(|atts| atts.iter().any(|a| a.key.is_none()))
45        });
46    if has_old {
47        return Err(RotateUserKeysError::OldAttachments);
48    }
49    Ok(())
50}
51
52/// Re-encrypts all user data (folders, ciphers, sends) with the new user key for the purpose of
53/// key-rotation. Note: Ciphers must be filtered to just contain the user's ciphers, not
54/// organization ciphers.
55#[bitwarden_logging::instrument(name = "reencrypt_data", fields(current_user_key_id = ?current_user_key_id, new_user_key_id = ?new_user_key_id))]
56pub(super) fn reencrypt_data(
57    folders: &[bitwarden_vault::Folder],
58    ciphers: &[bitwarden_vault::Cipher],
59    sends: &[bitwarden_send::Send],
60    current_user_key_id: SymmetricKeySlotId,
61    new_user_key_id: SymmetricKeySlotId,
62    ctx: &mut KeyStoreContext<KeySlotIds>,
63) -> Result<AccountDataRequestModel, DataReencryptionError> {
64    // Fully re-encrypt all user data with the new user key
65    let reencrypted_folders =
66        reencrypt_folders(folders, current_user_key_id, new_user_key_id, ctx)?;
67    let reencrypted_ciphers =
68        reencrypt_ciphers(ciphers, current_user_key_id, new_user_key_id, ctx)?;
69    let reencrypted_sends = reencrypt_sends(sends, current_user_key_id, new_user_key_id, ctx)?;
70
71    // Every cipher here was just re-encrypted under the new user key, so that is the key id the
72    // server needs to validate this rotation's cipher writes against. `reencrypt_data` is only
73    // ever passed the user's own ciphers, never organization ciphers.
74    let encrypted_by_key_id = ctx
75        .get_symmetric_key_id(new_user_key_id)
76        .map(|id| id.to_string());
77
78    Ok(AccountDataRequestModel {
79        folders: Some(
80            reencrypted_folders
81                .into_iter()
82                .map(|folder| (&folder).into())
83                .collect(),
84        ),
85        ciphers: Some(
86            reencrypted_ciphers
87                .into_iter()
88                .map(|cipher| {
89                    EncryptionContext {
90                        // Encrypted for is not used in key-rotation, and ciphers are validated to
91                        // be correct server-side
92                        encrypted_for: UserId::new(Uuid::nil()),
93                        encrypted_by_key_id: encrypted_by_key_id.clone(),
94                        cipher,
95                    }
96                    .try_into()
97                    .map_err(|_| DataReencryptionError::DataConversion)
98                })
99                .collect::<Result<Vec<CipherWithIdRequestModel>, DataReencryptionError>>()?,
100        ),
101        sends: Some(
102            reencrypted_sends
103                .into_iter()
104                .map(|send| Ok(send.into()))
105                .collect::<Result<Vec<SendWithIdRequestModel>, DataReencryptionError>>()?,
106        ),
107    })
108}
109
110#[bitwarden_logging::instrument(name = "reencrypt_folders", fields(current_key = ?current_key, new_key = ?new_key))]
111fn reencrypt_folders(
112    folders: &[bitwarden_vault::Folder],
113    current_key: SymmetricKeySlotId,
114    new_key: SymmetricKeySlotId,
115    ctx: &mut KeyStoreContext<KeySlotIds>,
116) -> Result<Vec<bitwarden_vault::Folder>, DataReencryptionError> {
117    folders
118        .iter()
119        .map(|folder| {
120            let _span = debug_span!("reencrypt_folder", folder_id = ?folder.id).entered();
121            let folder_view: FolderView = folder
122                .decrypt(ctx, current_key)
123                .map_err(|_| DataReencryptionError::Decryption)?;
124            folder_view
125                .encrypt_composite(ctx, new_key)
126                .map_err(|_| DataReencryptionError::Encryption)
127        })
128        .collect::<Result<Vec<bitwarden_vault::Folder>, DataReencryptionError>>()
129}
130
131#[bitwarden_logging::instrument(name = "reencrypt_ciphers", fields(current_key = ?current_key, new_key = ?new_key))]
132fn reencrypt_ciphers(
133    ciphers: &[bitwarden_vault::Cipher],
134    current_key: SymmetricKeySlotId,
135    new_key: SymmetricKeySlotId,
136    ctx: &mut KeyStoreContext<KeySlotIds>,
137) -> Result<Vec<bitwarden_vault::Cipher>, DataReencryptionError> {
138    ciphers
139        .iter()
140        .map(|cipher| {
141            let _span = debug_span!("reencrypt_cipher", cipher_id = ?cipher.id).entered();
142
143            // Rotation always lands the account on the V2 security state, so every individual
144            // cipher ends up blob-encrypted. Ciphers that are already sealed blobs only need their
145            // per-item key re-wrapped; the sealed blob is left untouched. Legacy ciphers are
146            // decrypted and re-sealed as blobs.
147            if cipher.is_blob_encrypted() && cipher.key.is_some() {
148                debug!("Cipher already blob-encrypted, re-wrapping cipher key");
149                let mut cipher = cipher.clone();
150                cipher
151                    .rewrap_cipher_key(current_key, new_key, ctx)
152                    .map_err(|_| DataReencryptionError::CipherKeyRewrap)?;
153                Ok(cipher)
154            } else {
155                debug!("Upgrading legacy cipher to blob encryption");
156                let cipher_view = decrypt_for_blob_upgrade(cipher, current_key, new_key, ctx)?;
157                EncryptMode::Blob(cipher_view)
158                    .encrypt_composite(ctx, new_key)
159                    .map_err(|_| DataReencryptionError::Encryption)
160            }
161        })
162        .collect::<Result<Vec<bitwarden_vault::Cipher>, DataReencryptionError>>()
163}
164
165/// Decrypts a legacy cipher into a view ready to be re-sealed as a blob under `new_key`.
166///
167/// The returned view carries a per-item cipher key wrapped under `new_key`, with its sub-keys
168/// (attachment keys, FIDO2 credentials) wrapped under that cipher key.
169fn decrypt_for_blob_upgrade(
170    cipher: &bitwarden_vault::Cipher,
171    current_key: SymmetricKeySlotId,
172    new_key: SymmetricKeySlotId,
173    ctx: &mut KeyStoreContext<KeySlotIds>,
174) -> Result<CipherView, DataReencryptionError> {
175    if cipher.key.is_some() {
176        let mut rewrapped = cipher.clone();
177        rewrapped
178            .rewrap_cipher_key(current_key, new_key, ctx)
179            .map_err(|_| DataReencryptionError::CipherKeyRewrap)?;
180        rewrapped
181            .decrypt(ctx, new_key)
182            .map_err(|_| DataReencryptionError::Decryption)
183    } else {
184        let mut view: CipherView = cipher
185            .decrypt(ctx, current_key)
186            .map_err(|_| DataReencryptionError::Decryption)?;
187        let _ = view
188            .load_cipher_key_slot(ctx)
189            .map_err(|_| DataReencryptionError::Encryption)?;
190        Ok(view)
191    }
192}
193
194#[bitwarden_logging::instrument(name = "reencrypt_sends", fields(current_key = ?current_key, new_key = ?new_key))]
195fn reencrypt_sends(
196    sends: &[bitwarden_send::Send],
197    current_key: SymmetricKeySlotId,
198    new_key: SymmetricKeySlotId,
199    ctx: &mut KeyStoreContext<KeySlotIds>,
200) -> Result<Vec<bitwarden_send::Send>, DataReencryptionError> {
201    sends
202        .iter()
203        .map(|send| {
204            let _span = debug_span!("reencrypt_send", send_id = ?send.id).entered();
205            let send_view: SendView = send
206                .decrypt(ctx, current_key)
207                .map_err(|_| DataReencryptionError::Decryption)?;
208            send_view
209                .encrypt_composite(ctx, new_key)
210                .map_err(|_| DataReencryptionError::Encryption)
211        })
212        .collect::<Result<Vec<bitwarden_send::Send>, DataReencryptionError>>()
213}
214
215#[cfg(test)]
216mod tests {
217    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
218    use bitwarden_crypto::{CompositeEncryptable, Decryptable, KeyStore, PrimitiveEncryptable};
219    use bitwarden_send::SendView;
220    use bitwarden_vault::{
221        Attachment, Cipher, CipherRepromptType, CipherType, CipherView, EncryptMode,
222        Fido2CredentialView,
223    };
224    use chrono::Utc;
225
226    use super::check_for_old_attachments;
227    use crate::key_rotation::RotateUserKeysError;
228
229    const TEST_ENC_STRING: &str = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
230
231    fn make_test_cipher(attachments: Option<Vec<Attachment>>) -> Cipher {
232        Cipher {
233            partial_data: None,
234            id: None,
235            organization_id: None,
236            folder_id: None,
237            collection_ids: vec![],
238            key: None,
239            name: Some(TEST_ENC_STRING.parse().unwrap()),
240            notes: None,
241            r#type: CipherType::Login,
242            login: None,
243            identity: None,
244            card: None,
245            secure_note: None,
246            ssh_key: None,
247            bank_account: None,
248            passport: None,
249            drivers_license: None,
250            favorite: false,
251            reprompt: CipherRepromptType::None,
252            organization_use_totp: false,
253            edit: true,
254            permissions: None,
255            view_password: true,
256            local_data: None,
257            attachments,
258            fields: None,
259            password_history: None,
260            creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
261            deleted_date: None,
262            revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
263            archived_date: None,
264            data: None,
265        }
266    }
267
268    #[test]
269    fn test_check_for_old_attachments_no_attachments() {
270        let ciphers = vec![make_test_cipher(None)];
271        assert!(check_for_old_attachments(&ciphers).is_ok());
272    }
273
274    #[test]
275    fn test_check_for_old_attachments_empty_ciphers() {
276        assert!(check_for_old_attachments(&[]).is_ok());
277    }
278
279    #[test]
280    fn test_check_for_old_attachments_all_have_keys() {
281        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
282            id: Some("att1".to_string()),
283            url: None,
284            size: None,
285            size_name: None,
286            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
287            key: Some(TEST_ENC_STRING.parse().unwrap()),
288        }]))];
289        assert!(check_for_old_attachments(&ciphers).is_ok());
290    }
291
292    #[test]
293    fn test_check_for_old_attachments_one_missing_key() {
294        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
295            id: Some("att1".to_string()),
296            url: None,
297            size: None,
298            size_name: None,
299            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
300            key: None,
301        }]))];
302        assert!(matches!(
303            check_for_old_attachments(&ciphers),
304            Err(RotateUserKeysError::OldAttachments)
305        ));
306    }
307
308    #[test]
309    fn test_check_for_old_attachments_ignores_organization_ciphers() {
310        let mut cipher = make_test_cipher(Some(vec![Attachment {
311            id: Some("att1".to_string()),
312            url: None,
313            size: None,
314            size_name: None,
315            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
316            key: None,
317        }]));
318        cipher.organization_id = Some(bitwarden_core::OrganizationId::new_v4());
319        let ciphers = vec![cipher];
320        assert!(check_for_old_attachments(&ciphers).is_ok());
321    }
322
323    fn make_cipher_view() -> bitwarden_vault::CipherView {
324        use bitwarden_vault::{CipherView, LoginView};
325        CipherView {
326            partial: false,
327            id: None,
328            organization_id: None,
329            folder_id: None,
330            r#type: CipherType::Login,
331            name: "Test Cipher".to_string(),
332            notes: Some("Some cipher notes".to_string()),
333            favorite: false,
334            revision_date: Utc::now(),
335            deleted_date: None,
336            fields: None,
337            login: Some(LoginView {
338                username: Some("user".to_string()),
339                password: Some("pass".to_string()),
340                totp: None,
341                uris: None,
342                autofill_on_page_load: None,
343                http_authentication_enabled: None,
344                fido2_credentials: None,
345                password_revision_date: None,
346            }),
347            card: None,
348            identity: None,
349            secure_note: None,
350            attachments: None,
351            attachment_decryption_failures: None,
352            organization_use_totp: false,
353            collection_ids: vec![],
354            reprompt: CipherRepromptType::None,
355            local_data: None,
356            key: None,
357            ssh_key: None,
358            bank_account: None,
359            passport: None,
360            drivers_license: None,
361            permissions: None,
362            view_password: false,
363            creation_date: Utc::now(),
364            archived_date: None,
365            edit: false,
366            password_history: None,
367        }
368    }
369
370    fn assert_decrypts_to(
371        cipher: &Cipher,
372        expected: &CipherView,
373        key: SymmetricKeySlotId,
374        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
375    ) {
376        use bitwarden_vault::CipherView;
377        let decrypted: CipherView = cipher.decrypt(ctx, key).unwrap();
378        assert_eq!(expected.name, decrypted.name);
379        assert_eq!(expected.notes, decrypted.notes);
380        assert_eq!(expected.r#type, decrypted.r#type);
381        assert_eq!(
382            expected.login.as_ref().unwrap().username,
383            decrypted.login.as_ref().unwrap().username
384        );
385        assert_eq!(
386            expected.login.as_ref().unwrap().password,
387            decrypted.login.as_ref().unwrap().password
388        );
389    }
390
391    /// Builds a legacy cipher whose fields are encrypted directly under `key`, with no per-cipher
392    /// key. Encrypting a `CipherView` always produces one now, so this shape — which still exists
393    /// server-side for vaults predating per-cipher keys — has to be constructed by hand.
394    fn make_keyless_legacy_cipher(
395        view: &CipherView,
396        key: SymmetricKeySlotId,
397        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
398    ) -> Cipher {
399        use bitwarden_vault::Login;
400
401        let login = view.login.as_ref().unwrap();
402
403        Cipher {
404            name: Some(view.name.encrypt(ctx, key).unwrap()),
405            notes: view.notes.encrypt(ctx, key).unwrap(),
406            login: Some(Login {
407                username: login.username.encrypt(ctx, key).unwrap(),
408                password: login.password.encrypt(ctx, key).unwrap(),
409                password_revision_date: None,
410                uris: None,
411                totp: None,
412                autofill_on_page_load: None,
413                http_authentication_enabled: None,
414                fido2_credentials: None,
415            }),
416            ..make_test_cipher(None)
417        }
418    }
419
420    #[test]
421    fn test_blob_gate_rewraps_existing_blob_without_re_encrypting() {
422        let store: KeyStore<KeySlotIds> = KeyStore::default();
423        let mut ctx = store.context_mut();
424
425        let user_key_old =
426            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
427        let user_key_new =
428            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XAes256Gcm);
429
430        // An already blob-encrypted cipher
431        let cipher = make_cipher_view();
432        let encrypted = EncryptMode::Blob(cipher.clone())
433            .encrypt_composite(&mut ctx, user_key_old)
434            .unwrap();
435        assert!(encrypted.is_blob_encrypted());
436
437        let reencrypted = super::reencrypt_ciphers(
438            std::slice::from_ref(&encrypted),
439            user_key_old,
440            user_key_new,
441            &mut ctx,
442        )
443        .unwrap();
444
445        // The sealed blob is left intact; only the wrapped cipher key is rewrapped
446        assert!(reencrypted[0].is_blob_encrypted());
447        assert_eq!(encrypted.data, reencrypted[0].data);
448        assert_ne!(encrypted.key, reencrypted[0].key);
449        assert_decrypts_to(&reencrypted[0], &cipher, user_key_new, &mut ctx);
450    }
451
452    #[test]
453    fn test_folders() {
454        let store: KeyStore<KeySlotIds> = KeyStore::default();
455        let mut ctx = store.context_mut();
456
457        let user_key_old =
458            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
459        let user_key_new =
460            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
461
462        // Create an encrypted folder
463        let folder = bitwarden_vault::FolderView {
464            id: None,
465            name: "Test Folder".to_string(),
466            revision_date: Utc::now(),
467        };
468        let encrypted_folder = folder.encrypt_composite(&mut ctx, user_key_old).unwrap();
469
470        // Rotate it
471        let folders = vec![encrypted_folder];
472        let reencrypted_folders =
473            super::reencrypt_folders(folders.as_slice(), user_key_old, user_key_new, &mut ctx)
474                .unwrap();
475
476        // Decrypt and assert
477        let decrypted_folder = reencrypted_folders[0]
478            .decrypt(&mut ctx, user_key_new)
479            .unwrap();
480        assert_eq!(folder, decrypted_folder);
481    }
482
483    #[test]
484    fn test_sends() {
485        let store: KeyStore<KeySlotIds> = KeyStore::default();
486        let mut ctx = store.context_mut();
487
488        let user_key_old =
489            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
490        let user_key_new =
491            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
492
493        // Create an encrypted send
494        let send = bitwarden_send::SendView {
495            id: None,
496            access_id: None,
497            name: "Test Send".to_string(),
498            notes: Some("Some notes".to_string()),
499            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
500            text: Some(bitwarden_send::SendTextView {
501                text: Some("This is a test send".to_string()),
502                hidden: false,
503            }),
504            data: None,
505            r#type: bitwarden_send::SendType::Text,
506            max_access_count: None,
507            access_count: 0,
508            disabled: false,
509            hide_email: false,
510            revision_date: Utc::now(),
511            deletion_date: Utc::now(),
512            expiration_date: None,
513            new_password: None,
514            has_password: false,
515            file: None,
516            emails: vec![],
517            auth_type: bitwarden_send::AuthType::None,
518        };
519        let encrypted_send = send.encrypt_composite(&mut ctx, user_key_old).unwrap();
520
521        // Rotate it
522        let sends = vec![encrypted_send];
523        let reencrypted_sends =
524            super::reencrypt_sends(sends.as_slice(), user_key_old, user_key_new, &mut ctx).unwrap();
525
526        // Decrypt and assert
527        let decrypted_send: SendView = reencrypted_sends[0]
528            .decrypt(&mut ctx, user_key_new)
529            .unwrap();
530
531        // The send seed must be the same
532        assert_eq!(send.key, decrypted_send.key);
533    }
534
535    #[test]
536    fn test_rotation_keyless_plain() {
537        let store: KeyStore<KeySlotIds> = KeyStore::default();
538        let mut ctx = store.context_mut();
539        let (old, new) = make_rotation_keys(&mut ctx);
540
541        let cipher = make_rotatable_cipher(&mut ctx, old, false, false, false);
542        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
543
544        assert_upgraded_to_blob(&mut ctx, &out[0], new);
545    }
546
547    #[test]
548    fn test_rotation_keyless_attachment() {
549        let store: KeyStore<KeySlotIds> = KeyStore::default();
550        let mut ctx = store.context_mut();
551        let (old, new) = make_rotation_keys(&mut ctx);
552
553        let cipher = make_rotatable_cipher(&mut ctx, old, false, true, false);
554        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
555
556        assert_upgraded_to_blob(&mut ctx, &out[0], new);
557        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
558    }
559
560    #[test]
561    fn test_rotation_keyed_plain() {
562        let store: KeyStore<KeySlotIds> = KeyStore::default();
563        let mut ctx = store.context_mut();
564        let (old, new) = make_rotation_keys(&mut ctx);
565
566        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, false);
567        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
568
569        assert_upgraded_to_blob(&mut ctx, &out[0], new);
570    }
571
572    #[test]
573    fn test_rotation_keyed_fido2() {
574        let store: KeyStore<KeySlotIds> = KeyStore::default();
575        let mut ctx = store.context_mut();
576        let (old, new) = make_rotation_keys(&mut ctx);
577
578        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, true);
579        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
580
581        assert_upgraded_to_blob(&mut ctx, &out[0], new);
582        assert_fido2_decryptable(&mut ctx, &out[0], new);
583    }
584
585    #[test]
586    fn test_rotation_keyed_attachment() {
587        let store: KeyStore<KeySlotIds> = KeyStore::default();
588        let mut ctx = store.context_mut();
589        let (old, new) = make_rotation_keys(&mut ctx);
590
591        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, false);
592        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
593
594        assert_upgraded_to_blob(&mut ctx, &out[0], new);
595        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
596    }
597
598    #[test]
599    fn test_rotation_keyed_fido2_and_attachment() {
600        let store: KeyStore<KeySlotIds> = KeyStore::default();
601        let mut ctx = store.context_mut();
602        let (old, new) = make_rotation_keys(&mut ctx);
603
604        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, true);
605        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
606
607        assert_upgraded_to_blob(&mut ctx, &out[0], new);
608        assert_fido2_decryptable(&mut ctx, &out[0], new);
609        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
610    }
611
612    /// The old and new user keys for a rotation: two distinct non-`User` slots (so the rotation
613    /// must honor the explicit keys rather than the cipher's `key_identifier()`), with a change of
614    /// algorithm mirroring production, which rotates onto an XChaCha20-Poly1305 key.
615    fn make_rotation_keys(
616        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
617    ) -> (SymmetricKeySlotId, SymmetricKeySlotId) {
618        let old = ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
619        let new =
620            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XChaCha20Poly1305);
621        (old, new)
622    }
623
624    /// Builds a legacy personal cipher encrypted under `user_key`, optionally carrying a per-item
625    /// cipher key, an attachment, and a FIDO2 credential.
626    fn make_rotatable_cipher(
627        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
628        user_key: SymmetricKeySlotId,
629        with_cipher_key: bool,
630        with_attachment: bool,
631        with_fido2: bool,
632    ) -> Cipher {
633        let mut view = make_cipher_view();
634        if with_fido2 {
635            let cred = Fido2CredentialView {
636                credential_id: "cred-123".to_string(),
637                key_type: "public-key".to_string(),
638                key_algorithm: "ECDSA".to_string(),
639                key_curve: "P-256".to_string(),
640                key_value: "key-value".to_string(),
641                rp_id: "example.com".to_string(),
642                user_handle: None,
643                user_name: None,
644                counter: "0".to_string(),
645                rp_name: None,
646                user_display_name: None,
647                discoverable: "true".to_string(),
648                creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
649            };
650            view.login.as_mut().unwrap().fido2_credentials = Some(vec![cred]);
651        }
652        let mut cipher = if with_cipher_key {
653            EncryptMode::Legacy(view)
654                .encrypt_composite(ctx, user_key)
655                .unwrap()
656        } else {
657            assert!(
658                !with_fido2,
659                "make_keyless_legacy_cipher drops FIDO2 credentials"
660            );
661            make_keyless_legacy_cipher(&view, user_key, ctx)
662        };
663        if with_attachment {
664            // The attachment content key and file name are wrapped under the cipher key: the
665            // per-item key for a keyed cipher, otherwise the user key directly.
666            let cipher_key = match &cipher.key {
667                Some(key) => ctx.unwrap_symmetric_key(user_key, key).unwrap(),
668                None => user_key,
669            };
670            let content_key = ctx.generate_symmetric_key();
671            cipher.attachments = Some(vec![Attachment {
672                id: Some("att1".to_string()),
673                url: None,
674                size: None,
675                size_name: None,
676                file_name: Some("secret.txt".encrypt(ctx, cipher_key).unwrap()),
677                key: Some(ctx.wrap_symmetric_key(cipher_key, content_key).unwrap()),
678            }]);
679        }
680        cipher
681    }
682
683    /// Asserts the rotated cipher is a blob whose body decrypts under `user_key` to the baseline
684    /// [`make_cipher_view`] fields.
685    fn assert_upgraded_to_blob(
686        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
687        rotated: &Cipher,
688        user_key: SymmetricKeySlotId,
689    ) {
690        assert!(rotated.is_blob_encrypted());
691        assert_decrypts_to(rotated, &make_cipher_view(), user_key, ctx);
692    }
693
694    /// Asserts the rotated cipher's FIDO2 credential decrypts to its original values under
695    /// `user_key`.
696    fn assert_fido2_decryptable(
697        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
698        rotated: &Cipher,
699        user_key: SymmetricKeySlotId,
700    ) {
701        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
702        let creds = dv
703            .login
704            .as_ref()
705            .unwrap()
706            .fido2_credentials
707            .as_ref()
708            .unwrap();
709        assert_eq!(creds[0].credential_id, "cred-123");
710        assert_eq!(creds[0].key_value, "key-value");
711    }
712
713    /// Asserts the rotated cipher's attachment key is present after key rotation.
714    fn assert_attachment_key_decryptable(
715        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
716        rotated: &Cipher,
717        user_key: SymmetricKeySlotId,
718    ) {
719        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
720        let att = &dv.attachments.as_ref().unwrap()[0];
721        assert_eq!(att.file_name.as_deref(), Some("secret.txt"));
722        assert!(
723            att.key.is_some(),
724            "attachment key must be present after rotation"
725        );
726    }
727}