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        view.upgrade_to_cipher_key_encryption_with_external_key(ctx, current_key, new_key)
188            .map_err(|_| DataReencryptionError::Encryption)?;
189        Ok(view)
190    }
191}
192
193#[bitwarden_logging::instrument(name = "reencrypt_sends", fields(current_key = ?current_key, new_key = ?new_key))]
194fn reencrypt_sends(
195    sends: &[bitwarden_send::Send],
196    current_key: SymmetricKeySlotId,
197    new_key: SymmetricKeySlotId,
198    ctx: &mut KeyStoreContext<KeySlotIds>,
199) -> Result<Vec<bitwarden_send::Send>, DataReencryptionError> {
200    sends
201        .iter()
202        .map(|send| {
203            let _span = debug_span!("reencrypt_send", send_id = ?send.id).entered();
204            let send_view: SendView = send
205                .decrypt(ctx, current_key)
206                .map_err(|_| DataReencryptionError::Decryption)?;
207            send_view
208                .encrypt_composite(ctx, new_key)
209                .map_err(|_| DataReencryptionError::Encryption)
210        })
211        .collect::<Result<Vec<bitwarden_send::Send>, DataReencryptionError>>()
212}
213
214#[cfg(test)]
215mod tests {
216    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
217    use bitwarden_crypto::{CompositeEncryptable, Decryptable, KeyStore, PrimitiveEncryptable};
218    use bitwarden_send::SendView;
219    use bitwarden_vault::{
220        Attachment, Cipher, CipherRepromptType, CipherType, CipherView, EncryptMode,
221        Fido2CredentialFullView,
222    };
223    use chrono::Utc;
224
225    use super::check_for_old_attachments;
226    use crate::key_rotation::RotateUserKeysError;
227
228    const TEST_ENC_STRING: &str = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
229
230    fn make_test_cipher(attachments: Option<Vec<Attachment>>) -> Cipher {
231        Cipher {
232            id: None,
233            organization_id: None,
234            folder_id: None,
235            collection_ids: vec![],
236            key: None,
237            name: Some(TEST_ENC_STRING.parse().unwrap()),
238            notes: None,
239            r#type: CipherType::Login,
240            login: None,
241            identity: None,
242            card: None,
243            secure_note: None,
244            ssh_key: None,
245            bank_account: None,
246            passport: None,
247            drivers_license: None,
248            favorite: false,
249            reprompt: CipherRepromptType::None,
250            organization_use_totp: false,
251            edit: true,
252            permissions: None,
253            view_password: true,
254            local_data: None,
255            attachments,
256            fields: None,
257            password_history: None,
258            creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
259            deleted_date: None,
260            revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
261            archived_date: None,
262            data: None,
263        }
264    }
265
266    #[test]
267    fn test_check_for_old_attachments_no_attachments() {
268        let ciphers = vec![make_test_cipher(None)];
269        assert!(check_for_old_attachments(&ciphers).is_ok());
270    }
271
272    #[test]
273    fn test_check_for_old_attachments_empty_ciphers() {
274        assert!(check_for_old_attachments(&[]).is_ok());
275    }
276
277    #[test]
278    fn test_check_for_old_attachments_all_have_keys() {
279        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
280            id: Some("att1".to_string()),
281            url: None,
282            size: None,
283            size_name: None,
284            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
285            key: Some(TEST_ENC_STRING.parse().unwrap()),
286        }]))];
287        assert!(check_for_old_attachments(&ciphers).is_ok());
288    }
289
290    #[test]
291    fn test_check_for_old_attachments_one_missing_key() {
292        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
293            id: Some("att1".to_string()),
294            url: None,
295            size: None,
296            size_name: None,
297            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
298            key: None,
299        }]))];
300        assert!(matches!(
301            check_for_old_attachments(&ciphers),
302            Err(RotateUserKeysError::OldAttachments)
303        ));
304    }
305
306    #[test]
307    fn test_check_for_old_attachments_ignores_organization_ciphers() {
308        let mut cipher = make_test_cipher(Some(vec![Attachment {
309            id: Some("att1".to_string()),
310            url: None,
311            size: None,
312            size_name: None,
313            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
314            key: None,
315        }]));
316        cipher.organization_id = Some(bitwarden_core::OrganizationId::new_v4());
317        let ciphers = vec![cipher];
318        assert!(check_for_old_attachments(&ciphers).is_ok());
319    }
320
321    fn make_cipher_view() -> bitwarden_vault::CipherView {
322        use bitwarden_vault::{CipherView, LoginView};
323        CipherView {
324            id: None,
325            organization_id: None,
326            folder_id: None,
327            r#type: CipherType::Login,
328            name: "Test Cipher".to_string(),
329            notes: Some("Some cipher notes".to_string()),
330            favorite: false,
331            revision_date: Utc::now(),
332            deleted_date: None,
333            fields: None,
334            login: Some(LoginView {
335                username: Some("user".to_string()),
336                password: Some("pass".to_string()),
337                totp: None,
338                uris: None,
339                autofill_on_page_load: None,
340                fido2_credentials: None,
341                password_revision_date: None,
342            }),
343            card: None,
344            identity: None,
345            secure_note: None,
346            attachments: None,
347            attachment_decryption_failures: None,
348            organization_use_totp: false,
349            collection_ids: vec![],
350            reprompt: CipherRepromptType::None,
351            local_data: None,
352            key: None,
353            ssh_key: None,
354            bank_account: None,
355            passport: None,
356            drivers_license: None,
357            permissions: None,
358            view_password: false,
359            creation_date: Utc::now(),
360            archived_date: None,
361            edit: false,
362            password_history: None,
363        }
364    }
365
366    fn assert_decrypts_to(
367        cipher: &Cipher,
368        expected: &CipherView,
369        key: SymmetricKeySlotId,
370        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
371    ) {
372        use bitwarden_vault::CipherView;
373        let decrypted: CipherView = cipher.decrypt(ctx, key).unwrap();
374        assert_eq!(expected.name, decrypted.name);
375        assert_eq!(expected.notes, decrypted.notes);
376        assert_eq!(expected.r#type, decrypted.r#type);
377        assert_eq!(
378            expected.login.as_ref().unwrap().username,
379            decrypted.login.as_ref().unwrap().username
380        );
381        assert_eq!(
382            expected.login.as_ref().unwrap().password,
383            decrypted.login.as_ref().unwrap().password
384        );
385    }
386
387    #[test]
388    fn test_blob_gate_rewraps_existing_blob_without_re_encrypting() {
389        let store: KeyStore<KeySlotIds> = KeyStore::default();
390        let mut ctx = store.context_mut();
391
392        let user_key_old =
393            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
394        let user_key_new =
395            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XAes256Gcm);
396
397        // An already blob-encrypted cipher
398        let cipher = make_cipher_view();
399        let encrypted = EncryptMode::Blob(cipher.clone())
400            .encrypt_composite(&mut ctx, user_key_old)
401            .unwrap();
402        assert!(encrypted.is_blob_encrypted());
403
404        let reencrypted = super::reencrypt_ciphers(
405            std::slice::from_ref(&encrypted),
406            user_key_old,
407            user_key_new,
408            &mut ctx,
409        )
410        .unwrap();
411
412        // The sealed blob is left intact; only the wrapped cipher key is rewrapped
413        assert!(reencrypted[0].is_blob_encrypted());
414        assert_eq!(encrypted.data, reencrypted[0].data);
415        assert_ne!(encrypted.key, reencrypted[0].key);
416        assert_decrypts_to(&reencrypted[0], &cipher, user_key_new, &mut ctx);
417    }
418
419    #[test]
420    fn test_folders() {
421        let store: KeyStore<KeySlotIds> = KeyStore::default();
422        let mut ctx = store.context_mut();
423
424        let user_key_old =
425            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
426        let user_key_new =
427            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
428
429        // Create an encrypted folder
430        let folder = bitwarden_vault::FolderView {
431            id: None,
432            name: "Test Folder".to_string(),
433            revision_date: Utc::now(),
434        };
435        let encrypted_folder = folder.encrypt_composite(&mut ctx, user_key_old).unwrap();
436
437        // Rotate it
438        let folders = vec![encrypted_folder];
439        let reencrypted_folders =
440            super::reencrypt_folders(folders.as_slice(), user_key_old, user_key_new, &mut ctx)
441                .unwrap();
442
443        // Decrypt and assert
444        let decrypted_folder = reencrypted_folders[0]
445            .decrypt(&mut ctx, user_key_new)
446            .unwrap();
447        assert_eq!(folder, decrypted_folder);
448    }
449
450    #[test]
451    fn test_sends() {
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 send
461        let send = bitwarden_send::SendView {
462            id: None,
463            access_id: None,
464            name: "Test Send".to_string(),
465            notes: Some("Some notes".to_string()),
466            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
467            text: Some(bitwarden_send::SendTextView {
468                text: Some("This is a test send".to_string()),
469                hidden: false,
470            }),
471            data: None,
472            r#type: bitwarden_send::SendType::Text,
473            max_access_count: None,
474            access_count: 0,
475            disabled: false,
476            hide_email: false,
477            revision_date: Utc::now(),
478            deletion_date: Utc::now(),
479            expiration_date: None,
480            new_password: None,
481            has_password: false,
482            file: None,
483            emails: vec![],
484            auth_type: bitwarden_send::AuthType::None,
485        };
486        let encrypted_send = send.encrypt_composite(&mut ctx, user_key_old).unwrap();
487
488        // Rotate it
489        let sends = vec![encrypted_send];
490        let reencrypted_sends =
491            super::reencrypt_sends(sends.as_slice(), user_key_old, user_key_new, &mut ctx).unwrap();
492
493        // Decrypt and assert
494        let decrypted_send: SendView = reencrypted_sends[0]
495            .decrypt(&mut ctx, user_key_new)
496            .unwrap();
497
498        // The send seed must be the same
499        assert_eq!(send.key, decrypted_send.key);
500    }
501
502    #[test]
503    fn test_rotation_keyless_plain() {
504        let store: KeyStore<KeySlotIds> = KeyStore::default();
505        let mut ctx = store.context_mut();
506        let (old, new) = make_rotation_keys(&mut ctx);
507
508        let cipher = make_rotatable_cipher(&mut ctx, old, false, false, false);
509        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
510
511        assert_upgraded_to_blob(&mut ctx, &out[0], new);
512    }
513
514    #[test]
515    fn test_rotation_keyless_fido2() {
516        let store: KeyStore<KeySlotIds> = KeyStore::default();
517        let mut ctx = store.context_mut();
518        let (old, new) = make_rotation_keys(&mut ctx);
519
520        let cipher = make_rotatable_cipher(&mut ctx, old, false, false, true);
521        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
522
523        assert_upgraded_to_blob(&mut ctx, &out[0], new);
524        assert_fido2_decryptable(&mut ctx, &out[0], new);
525    }
526
527    #[test]
528    fn test_rotation_keyless_attachment() {
529        let store: KeyStore<KeySlotIds> = KeyStore::default();
530        let mut ctx = store.context_mut();
531        let (old, new) = make_rotation_keys(&mut ctx);
532
533        let cipher = make_rotatable_cipher(&mut ctx, old, false, true, false);
534        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
535
536        assert_upgraded_to_blob(&mut ctx, &out[0], new);
537        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
538    }
539
540    #[test]
541    fn test_rotation_keyless_fido2_and_attachment() {
542        let store: KeyStore<KeySlotIds> = KeyStore::default();
543        let mut ctx = store.context_mut();
544        let (old, new) = make_rotation_keys(&mut ctx);
545
546        let cipher = make_rotatable_cipher(&mut ctx, old, false, true, true);
547        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
548
549        assert_upgraded_to_blob(&mut ctx, &out[0], new);
550        assert_fido2_decryptable(&mut ctx, &out[0], new);
551        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
552    }
553
554    #[test]
555    fn test_rotation_keyed_plain() {
556        let store: KeyStore<KeySlotIds> = KeyStore::default();
557        let mut ctx = store.context_mut();
558        let (old, new) = make_rotation_keys(&mut ctx);
559
560        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, false);
561        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
562
563        assert_upgraded_to_blob(&mut ctx, &out[0], new);
564    }
565
566    #[test]
567    fn test_rotation_keyed_fido2() {
568        let store: KeyStore<KeySlotIds> = KeyStore::default();
569        let mut ctx = store.context_mut();
570        let (old, new) = make_rotation_keys(&mut ctx);
571
572        let cipher = make_rotatable_cipher(&mut ctx, old, true, false, true);
573        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
574
575        assert_upgraded_to_blob(&mut ctx, &out[0], new);
576        assert_fido2_decryptable(&mut ctx, &out[0], new);
577    }
578
579    #[test]
580    fn test_rotation_keyed_attachment() {
581        let store: KeyStore<KeySlotIds> = KeyStore::default();
582        let mut ctx = store.context_mut();
583        let (old, new) = make_rotation_keys(&mut ctx);
584
585        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, false);
586        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
587
588        assert_upgraded_to_blob(&mut ctx, &out[0], new);
589        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
590    }
591
592    #[test]
593    fn test_rotation_keyed_fido2_and_attachment() {
594        let store: KeyStore<KeySlotIds> = KeyStore::default();
595        let mut ctx = store.context_mut();
596        let (old, new) = make_rotation_keys(&mut ctx);
597
598        let cipher = make_rotatable_cipher(&mut ctx, old, true, true, true);
599        let out = super::reencrypt_ciphers(&[cipher], old, new, &mut ctx).unwrap();
600
601        assert_upgraded_to_blob(&mut ctx, &out[0], new);
602        assert_fido2_decryptable(&mut ctx, &out[0], new);
603        assert_attachment_key_decryptable(&mut ctx, &out[0], new);
604    }
605
606    /// The old and new user keys for a rotation: two distinct non-`User` slots (so the rotation
607    /// must honor the explicit keys rather than the cipher's `key_identifier()`), with a change of
608    /// algorithm mirroring production, which rotates onto an XChaCha20-Poly1305 key.
609    fn make_rotation_keys(
610        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
611    ) -> (SymmetricKeySlotId, SymmetricKeySlotId) {
612        let old = ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
613        let new =
614            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XChaCha20Poly1305);
615        (old, new)
616    }
617
618    /// Builds a legacy personal cipher encrypted under `user_key`, optionally carrying a per-item
619    /// cipher key, an attachment, and a FIDO2 credential.
620    fn make_rotatable_cipher(
621        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
622        user_key: SymmetricKeySlotId,
623        with_cipher_key: bool,
624        with_attachment: bool,
625        with_fido2: bool,
626    ) -> Cipher {
627        let mut view = make_cipher_view();
628        if with_fido2 {
629            let full = Fido2CredentialFullView {
630                credential_id: "cred-123".to_string(),
631                key_type: "public-key".to_string(),
632                key_algorithm: "ECDSA".to_string(),
633                key_curve: "P-256".to_string(),
634                key_value: "key-value".to_string(),
635                rp_id: "example.com".to_string(),
636                user_handle: None,
637                user_name: None,
638                counter: "0".to_string(),
639                rp_name: None,
640                user_display_name: None,
641                discoverable: "true".to_string(),
642                creation_date: "2024-06-07T14:12:36.150Z".parse().unwrap(),
643            };
644            view.login.as_mut().unwrap().fido2_credentials =
645                Some(vec![full.encrypt_composite(ctx, user_key).unwrap()]);
646        }
647        if with_cipher_key {
648            view.upgrade_to_cipher_key_encryption_with_external_key(ctx, user_key, user_key)
649                .unwrap();
650        }
651        let mut cipher = EncryptMode::Legacy(view)
652            .encrypt_composite(ctx, user_key)
653            .unwrap();
654        if with_attachment {
655            // The attachment content key and file name are wrapped under the cipher key: the
656            // per-item key for a keyed cipher, otherwise the user key directly.
657            let cipher_key = match &cipher.key {
658                Some(key) => ctx.unwrap_symmetric_key(user_key, key).unwrap(),
659                None => user_key,
660            };
661            let content_key = ctx.generate_symmetric_key();
662            cipher.attachments = Some(vec![Attachment {
663                id: Some("att1".to_string()),
664                url: None,
665                size: None,
666                size_name: None,
667                file_name: Some("secret.txt".encrypt(ctx, cipher_key).unwrap()),
668                key: Some(ctx.wrap_symmetric_key(cipher_key, content_key).unwrap()),
669            }]);
670        }
671        cipher
672    }
673
674    /// Asserts the rotated cipher is a blob whose body decrypts under `user_key` to the baseline
675    /// [`make_cipher_view`] fields.
676    fn assert_upgraded_to_blob(
677        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
678        rotated: &Cipher,
679        user_key: SymmetricKeySlotId,
680    ) {
681        assert!(rotated.is_blob_encrypted());
682        assert_decrypts_to(rotated, &make_cipher_view(), user_key, ctx);
683    }
684
685    /// Asserts the rotated cipher's FIDO2 credential decrypts to its original values under
686    /// `user_key`.
687    fn assert_fido2_decryptable(
688        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
689        rotated: &Cipher,
690        user_key: SymmetricKeySlotId,
691    ) {
692        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
693        let cipher_key = ctx
694            .unwrap_symmetric_key(user_key, dv.key.as_ref().unwrap())
695            .unwrap();
696        let creds: Vec<Fido2CredentialFullView> = dv
697            .login
698            .as_ref()
699            .unwrap()
700            .fido2_credentials
701            .as_ref()
702            .unwrap()
703            .decrypt(ctx, cipher_key)
704            .unwrap();
705        assert_eq!(creds[0].credential_id, "cred-123");
706        assert_eq!(creds[0].key_value, "key-value");
707    }
708
709    /// Asserts the rotated cipher's attachment key unwraps under `user_key`.
710    fn assert_attachment_key_decryptable(
711        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
712        rotated: &Cipher,
713        user_key: SymmetricKeySlotId,
714    ) {
715        let dv: CipherView = rotated.decrypt(ctx, user_key).unwrap();
716        let cipher_key = ctx
717            .unwrap_symmetric_key(user_key, dv.key.as_ref().unwrap())
718            .unwrap();
719        let att = &dv.attachments.as_ref().unwrap()[0];
720        assert_eq!(att.file_name.as_deref(), Some("secret.txt"));
721        let _ = ctx
722            .unwrap_symmetric_key(cipher_key, att.key.as_ref().unwrap())
723            .expect("attachment key must unwrap under the new cipher key");
724    }
725}