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                fido2_credentials: None,
344                password_revision_date: None,
345            }),
346            card: None,
347            identity: None,
348            secure_note: None,
349            attachments: None,
350            attachment_decryption_failures: None,
351            organization_use_totp: false,
352            collection_ids: vec![],
353            reprompt: CipherRepromptType::None,
354            local_data: None,
355            key: None,
356            ssh_key: None,
357            bank_account: None,
358            passport: None,
359            drivers_license: None,
360            permissions: None,
361            view_password: false,
362            creation_date: Utc::now(),
363            archived_date: None,
364            edit: false,
365            password_history: None,
366        }
367    }
368
369    fn assert_decrypts_to(
370        cipher: &Cipher,
371        expected: &CipherView,
372        key: SymmetricKeySlotId,
373        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
374    ) {
375        use bitwarden_vault::CipherView;
376        let decrypted: CipherView = cipher.decrypt(ctx, key).unwrap();
377        assert_eq!(expected.name, decrypted.name);
378        assert_eq!(expected.notes, decrypted.notes);
379        assert_eq!(expected.r#type, decrypted.r#type);
380        assert_eq!(
381            expected.login.as_ref().unwrap().username,
382            decrypted.login.as_ref().unwrap().username
383        );
384        assert_eq!(
385            expected.login.as_ref().unwrap().password,
386            decrypted.login.as_ref().unwrap().password
387        );
388    }
389
390    /// Builds a legacy cipher whose fields are encrypted directly under `key`, with no per-cipher
391    /// key. Encrypting a `CipherView` always produces one now, so this shape — which still exists
392    /// server-side for vaults predating per-cipher keys — has to be constructed by hand.
393    fn make_keyless_legacy_cipher(
394        view: &CipherView,
395        key: SymmetricKeySlotId,
396        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
397    ) -> Cipher {
398        use bitwarden_vault::Login;
399
400        let login = view.login.as_ref().unwrap();
401
402        Cipher {
403            name: Some(view.name.encrypt(ctx, key).unwrap()),
404            notes: view.notes.encrypt(ctx, key).unwrap(),
405            login: Some(Login {
406                username: login.username.encrypt(ctx, key).unwrap(),
407                password: login.password.encrypt(ctx, key).unwrap(),
408                password_revision_date: None,
409                uris: None,
410                totp: None,
411                autofill_on_page_load: None,
412                fido2_credentials: None,
413            }),
414            ..make_test_cipher(None)
415        }
416    }
417
418    #[test]
419    fn test_blob_gate_rewraps_existing_blob_without_re_encrypting() {
420        let store: KeyStore<KeySlotIds> = KeyStore::default();
421        let mut ctx = store.context_mut();
422
423        let user_key_old =
424            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
425        let user_key_new =
426            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XAes256Gcm);
427
428        // An already blob-encrypted cipher
429        let cipher = make_cipher_view();
430        let encrypted = EncryptMode::Blob(cipher.clone())
431            .encrypt_composite(&mut ctx, user_key_old)
432            .unwrap();
433        assert!(encrypted.is_blob_encrypted());
434
435        let reencrypted = super::reencrypt_ciphers(
436            std::slice::from_ref(&encrypted),
437            user_key_old,
438            user_key_new,
439            &mut ctx,
440        )
441        .unwrap();
442
443        // The sealed blob is left intact; only the wrapped cipher key is rewrapped
444        assert!(reencrypted[0].is_blob_encrypted());
445        assert_eq!(encrypted.data, reencrypted[0].data);
446        assert_ne!(encrypted.key, reencrypted[0].key);
447        assert_decrypts_to(&reencrypted[0], &cipher, user_key_new, &mut ctx);
448    }
449
450    #[test]
451    fn test_folders() {
452        let store: KeyStore<KeySlotIds> = KeyStore::default();
453        let mut ctx = store.context_mut();
454
455        let user_key_old =
456            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
457        let user_key_new =
458            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
459
460        // Create an encrypted folder
461        let folder = bitwarden_vault::FolderView {
462            id: None,
463            name: "Test Folder".to_string(),
464            revision_date: Utc::now(),
465        };
466        let encrypted_folder = folder.encrypt_composite(&mut ctx, user_key_old).unwrap();
467
468        // Rotate it
469        let folders = vec![encrypted_folder];
470        let reencrypted_folders =
471            super::reencrypt_folders(folders.as_slice(), user_key_old, user_key_new, &mut ctx)
472                .unwrap();
473
474        // Decrypt and assert
475        let decrypted_folder = reencrypted_folders[0]
476            .decrypt(&mut ctx, user_key_new)
477            .unwrap();
478        assert_eq!(folder, decrypted_folder);
479    }
480
481    #[test]
482    fn test_sends() {
483        let store: KeyStore<KeySlotIds> = KeyStore::default();
484        let mut ctx = store.context_mut();
485
486        let user_key_old =
487            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
488        let user_key_new =
489            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
490
491        // Create an encrypted send
492        let send = bitwarden_send::SendView {
493            id: None,
494            access_id: None,
495            name: "Test Send".to_string(),
496            notes: Some("Some notes".to_string()),
497            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
498            text: Some(bitwarden_send::SendTextView {
499                text: Some("This is a test send".to_string()),
500                hidden: false,
501            }),
502            data: None,
503            r#type: bitwarden_send::SendType::Text,
504            max_access_count: None,
505            access_count: 0,
506            disabled: false,
507            hide_email: false,
508            revision_date: Utc::now(),
509            deletion_date: Utc::now(),
510            expiration_date: None,
511            new_password: None,
512            has_password: false,
513            file: None,
514            emails: vec![],
515            auth_type: bitwarden_send::AuthType::None,
516        };
517        let encrypted_send = send.encrypt_composite(&mut ctx, user_key_old).unwrap();
518
519        // Rotate it
520        let sends = vec![encrypted_send];
521        let reencrypted_sends =
522            super::reencrypt_sends(sends.as_slice(), user_key_old, user_key_new, &mut ctx).unwrap();
523
524        // Decrypt and assert
525        let decrypted_send: SendView = reencrypted_sends[0]
526            .decrypt(&mut ctx, user_key_new)
527            .unwrap();
528
529        // The send seed must be the same
530        assert_eq!(send.key, decrypted_send.key);
531    }
532
533    #[test]
534    fn test_rotation_keyless_plain() {
535        let store: KeyStore<KeySlotIds> = KeyStore::default();
536        let mut ctx = store.context_mut();
537        let (old, new) = make_rotation_keys(&mut ctx);
538
539        let cipher = make_rotatable_cipher(&mut ctx, old, false, false, false);
540        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
541
542        assert_upgraded_to_blob(&mut ctx, &out[0], new);
543    }
544
545    #[test]
546    fn test_rotation_keyless_attachment() {
547        let store: KeyStore<KeySlotIds> = KeyStore::default();
548        let mut ctx = store.context_mut();
549        let (old, new) = make_rotation_keys(&mut ctx);
550
551        let cipher = make_rotatable_cipher(&mut ctx, old, false, true, false);
552        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
553
554        assert_upgraded_to_blob(&mut ctx, &out[0], new);
555        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
556    }
557
558    #[test]
559    fn test_rotation_keyed_plain() {
560        let store: KeyStore<KeySlotIds> = KeyStore::default();
561        let mut ctx = store.context_mut();
562        let (old, new) = make_rotation_keys(&mut ctx);
563
564        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, false);
565        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
566
567        assert_upgraded_to_blob(&mut ctx, &out[0], new);
568    }
569
570    #[test]
571    fn test_rotation_keyed_fido2() {
572        let store: KeyStore<KeySlotIds> = KeyStore::default();
573        let mut ctx = store.context_mut();
574        let (old, new) = make_rotation_keys(&mut ctx);
575
576        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, true);
577        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
578
579        assert_upgraded_to_blob(&mut ctx, &out[0], new);
580        assert_fido2_decryptable(&mut ctx, &out[0], new);
581    }
582
583    #[test]
584    fn test_rotation_keyed_attachment() {
585        let store: KeyStore<KeySlotIds> = KeyStore::default();
586        let mut ctx = store.context_mut();
587        let (old, new) = make_rotation_keys(&mut ctx);
588
589        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, false);
590        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
591
592        assert_upgraded_to_blob(&mut ctx, &out[0], new);
593        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
594    }
595
596    #[test]
597    fn test_rotation_keyed_fido2_and_attachment() {
598        let store: KeyStore<KeySlotIds> = KeyStore::default();
599        let mut ctx = store.context_mut();
600        let (old, new) = make_rotation_keys(&mut ctx);
601
602        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, true);
603        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
604
605        assert_upgraded_to_blob(&mut ctx, &out[0], new);
606        assert_fido2_decryptable(&mut ctx, &out[0], new);
607        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
608    }
609
610    /// The old and new user keys for a rotation: two distinct non-`User` slots (so the rotation
611    /// must honor the explicit keys rather than the cipher's `key_identifier()`), with a change of
612    /// algorithm mirroring production, which rotates onto an XChaCha20-Poly1305 key.
613    fn make_rotation_keys(
614        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
615    ) -> (SymmetricKeySlotId, SymmetricKeySlotId) {
616        let old = ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
617        let new =
618            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XChaCha20Poly1305);
619        (old, new)
620    }
621
622    /// Builds a legacy personal cipher encrypted under `user_key`, optionally carrying a per-item
623    /// cipher key, an attachment, and a FIDO2 credential.
624    fn make_rotatable_cipher(
625        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
626        user_key: SymmetricKeySlotId,
627        with_cipher_key: bool,
628        with_attachment: bool,
629        with_fido2: bool,
630    ) -> Cipher {
631        let mut view = make_cipher_view();
632        if with_fido2 {
633            let cred = Fido2CredentialView {
634                credential_id: "cred-123".to_string(),
635                key_type: "public-key".to_string(),
636                key_algorithm: "ECDSA".to_string(),
637                key_curve: "P-256".to_string(),
638                key_value: "key-value".to_string(),
639                rp_id: "example.com".to_string(),
640                user_handle: None,
641                user_name: None,
642                counter: "0".to_string(),
643                rp_name: None,
644                user_display_name: None,
645                discoverable: "true".to_string(),
646                creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
647            };
648            view.login.as_mut().unwrap().fido2_credentials = Some(vec![cred]);
649        }
650        let mut cipher = if with_cipher_key {
651            EncryptMode::Legacy(view)
652                .encrypt_composite(ctx, user_key)
653                .unwrap()
654        } else {
655            assert!(
656                !with_fido2,
657                "make_keyless_legacy_cipher drops FIDO2 credentials"
658            );
659            make_keyless_legacy_cipher(&view, user_key, ctx)
660        };
661        if with_attachment {
662            // The attachment content key and file name are wrapped under the cipher key: the
663            // per-item key for a keyed cipher, otherwise the user key directly.
664            let cipher_key = match &cipher.key {
665                Some(key) => ctx.unwrap_symmetric_key(user_key, key).unwrap(),
666                None => user_key,
667            };
668            let content_key = ctx.generate_symmetric_key();
669            cipher.attachments = Some(vec![Attachment {
670                id: Some("att1".to_string()),
671                url: None,
672                size: None,
673                size_name: None,
674                file_name: Some("secret.txt".encrypt(ctx, cipher_key).unwrap()),
675                key: Some(ctx.wrap_symmetric_key(cipher_key, content_key).unwrap()),
676            }]);
677        }
678        cipher
679    }
680
681    /// Asserts the rotated cipher is a blob whose body decrypts under `user_key` to the baseline
682    /// [`make_cipher_view`] fields.
683    fn assert_upgraded_to_blob(
684        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
685        rotated: &Cipher,
686        user_key: SymmetricKeySlotId,
687    ) {
688        assert!(rotated.is_blob_encrypted());
689        assert_decrypts_to(rotated, &make_cipher_view(), user_key, ctx);
690    }
691
692    /// Asserts the rotated cipher's FIDO2 credential decrypts to its original values under
693    /// `user_key`.
694    fn assert_fido2_decryptable(
695        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
696        rotated: &Cipher,
697        user_key: SymmetricKeySlotId,
698    ) {
699        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
700        let creds = dv
701            .login
702            .as_ref()
703            .unwrap()
704            .fido2_credentials
705            .as_ref()
706            .unwrap();
707        assert_eq!(creds[0].credential_id, "cred-123");
708        assert_eq!(creds[0].key_value, "key-value");
709    }
710
711    /// Asserts the rotated cipher's attachment key is present after key rotation.
712    fn assert_attachment_key_decryptable(
713        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
714        rotated: &Cipher,
715        user_key: SymmetricKeySlotId,
716    ) {
717        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
718        let att = &dv.attachments.as_ref().unwrap()[0];
719        assert_eq!(att.file_name.as_deref(), Some("secret.txt"));
720        assert!(
721            att.key.is_some(),
722            "attachment key must be present after rotation"
723        );
724    }
725}