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    Ok(AccountDataRequestModel {
71        folders: Some(
72            reencrypted_folders
73                .into_iter()
74                .map(|folder| (&folder).into())
75                .collect(),
76        ),
77        ciphers: Some(
78            reencrypted_ciphers
79                .into_iter()
80                .map(|cipher| {
81                    EncryptionContext {
82                        // Encrypted for is not used in key-rotation, and ciphers are validated to
83                        // be correct server-side
84                        encrypted_for: UserId::new(Uuid::nil()),
85                        cipher,
86                    }
87                    .try_into()
88                    .map_err(|_| DataReencryptionError::DataConversion)
89                })
90                .collect::<Result<Vec<CipherWithIdRequestModel>, DataReencryptionError>>()?,
91        ),
92        sends: Some(
93            reencrypted_sends
94                .into_iter()
95                .map(|send| Ok(send.into()))
96                .collect::<Result<Vec<SendWithIdRequestModel>, DataReencryptionError>>()?,
97        ),
98    })
99}
100
101#[bitwarden_logging::instrument(name = "reencrypt_folders", fields(current_key = ?current_key, new_key = ?new_key))]
102fn reencrypt_folders(
103    folders: &[bitwarden_vault::Folder],
104    current_key: SymmetricKeySlotId,
105    new_key: SymmetricKeySlotId,
106    ctx: &mut KeyStoreContext<KeySlotIds>,
107) -> Result<Vec<bitwarden_vault::Folder>, DataReencryptionError> {
108    folders
109        .iter()
110        .map(|folder| {
111            let _span = debug_span!("reencrypt_folder", folder_id = ?folder.id).entered();
112            let folder_view: FolderView = folder
113                .decrypt(ctx, current_key)
114                .map_err(|_| DataReencryptionError::Decryption)?;
115            folder_view
116                .encrypt_composite(ctx, new_key)
117                .map_err(|_| DataReencryptionError::Encryption)
118        })
119        .collect::<Result<Vec<bitwarden_vault::Folder>, DataReencryptionError>>()
120}
121
122#[bitwarden_logging::instrument(name = "reencrypt_ciphers", fields(current_key = ?current_key, new_key = ?new_key))]
123fn reencrypt_ciphers(
124    ciphers: &[bitwarden_vault::Cipher],
125    current_key: SymmetricKeySlotId,
126    new_key: SymmetricKeySlotId,
127    ctx: &mut KeyStoreContext<KeySlotIds>,
128) -> Result<Vec<bitwarden_vault::Cipher>, DataReencryptionError> {
129    ciphers
130        .iter()
131        .map(|cipher| {
132            let _span = debug_span!("reencrypt_cipher", cipher_id = ?cipher.id).entered();
133
134            // Rotation always lands the account on the V2 security state, so every individual
135            // cipher ends up blob-encrypted. Ciphers that are already sealed blobs only need their
136            // per-item key re-wrapped; the sealed blob is left untouched. Legacy ciphers are
137            // decrypted and re-sealed as blobs.
138            if cipher.is_blob_encrypted() && cipher.key.is_some() {
139                debug!("Cipher already blob-encrypted, re-wrapping cipher key");
140                let mut cipher = cipher.clone();
141                cipher
142                    .rewrap_cipher_key(current_key, new_key, ctx)
143                    .map_err(|_| DataReencryptionError::CipherKeyRewrap)?;
144                Ok(cipher)
145            } else {
146                debug!("Upgrading legacy cipher to blob encryption");
147                let cipher_view = decrypt_for_blob_upgrade(cipher, current_key, new_key, ctx)?;
148                EncryptMode::Blob(cipher_view)
149                    .encrypt_composite(ctx, new_key)
150                    .map_err(|_| DataReencryptionError::Encryption)
151            }
152        })
153        .collect::<Result<Vec<bitwarden_vault::Cipher>, DataReencryptionError>>()
154}
155
156/// Decrypts a legacy cipher into a view that can be re-sealed as a blob under `new_key`.
157///
158/// If the cipher has a per-item key, it is re-wrapped under the new user key first so the cipher
159/// key (and the data sealed against it) stay consistent under the new key. Otherwise the cipher is
160/// decrypted under the current key and blob sealing will generate a fresh per-item key.
161fn decrypt_for_blob_upgrade(
162    cipher: &bitwarden_vault::Cipher,
163    current_key: SymmetricKeySlotId,
164    new_key: SymmetricKeySlotId,
165    ctx: &mut KeyStoreContext<KeySlotIds>,
166) -> Result<CipherView, DataReencryptionError> {
167    if cipher.key.is_some() {
168        let mut rewrapped = cipher.clone();
169        rewrapped
170            .rewrap_cipher_key(current_key, new_key, ctx)
171            .map_err(|_| DataReencryptionError::CipherKeyRewrap)?;
172        rewrapped
173            .decrypt(ctx, new_key)
174            .map_err(|_| DataReencryptionError::Decryption)
175    } else {
176        cipher
177            .decrypt(ctx, current_key)
178            .map_err(|_| DataReencryptionError::Decryption)
179    }
180}
181
182#[bitwarden_logging::instrument(name = "reencrypt_sends", fields(current_key = ?current_key, new_key = ?new_key))]
183fn reencrypt_sends(
184    sends: &[bitwarden_send::Send],
185    current_key: SymmetricKeySlotId,
186    new_key: SymmetricKeySlotId,
187    ctx: &mut KeyStoreContext<KeySlotIds>,
188) -> Result<Vec<bitwarden_send::Send>, DataReencryptionError> {
189    sends
190        .iter()
191        .map(|send| {
192            let _span = debug_span!("reencrypt_send", send_id = ?send.id).entered();
193            let send_view: SendView = send
194                .decrypt(ctx, current_key)
195                .map_err(|_| DataReencryptionError::Decryption)?;
196            send_view
197                .encrypt_composite(ctx, new_key)
198                .map_err(|_| DataReencryptionError::Encryption)
199        })
200        .collect::<Result<Vec<bitwarden_send::Send>, DataReencryptionError>>()
201}
202
203#[cfg(test)]
204mod tests {
205    use bitwarden_core::key_management::KeySlotIds;
206    use bitwarden_crypto::{CompositeEncryptable, Decryptable, KeyStore};
207    use bitwarden_send::SendView;
208    use bitwarden_vault::{Attachment, Cipher, CipherRepromptType, CipherType, EncryptMode};
209    use chrono::Utc;
210
211    use super::check_for_old_attachments;
212    use crate::key_rotation::RotateUserKeysError;
213
214    const TEST_ENC_STRING: &str = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
215
216    fn make_test_cipher(attachments: Option<Vec<Attachment>>) -> Cipher {
217        Cipher {
218            id: None,
219            organization_id: None,
220            folder_id: None,
221            collection_ids: vec![],
222            key: None,
223            name: Some(TEST_ENC_STRING.parse().unwrap()),
224            notes: None,
225            r#type: CipherType::Login,
226            login: None,
227            identity: None,
228            card: None,
229            secure_note: None,
230            ssh_key: None,
231            bank_account: None,
232            passport: None,
233            drivers_license: None,
234            favorite: false,
235            reprompt: CipherRepromptType::None,
236            organization_use_totp: false,
237            edit: true,
238            permissions: None,
239            view_password: true,
240            local_data: None,
241            attachments,
242            fields: None,
243            password_history: None,
244            creation_date: "2024-01-01T00:00:00Z".parse().unwrap(),
245            deleted_date: None,
246            revision_date: "2024-01-01T00:00:00Z".parse().unwrap(),
247            archived_date: None,
248            data: None,
249        }
250    }
251
252    #[test]
253    fn test_check_for_old_attachments_no_attachments() {
254        let ciphers = vec![make_test_cipher(None)];
255        assert!(check_for_old_attachments(&ciphers).is_ok());
256    }
257
258    #[test]
259    fn test_check_for_old_attachments_empty_ciphers() {
260        assert!(check_for_old_attachments(&[]).is_ok());
261    }
262
263    #[test]
264    fn test_check_for_old_attachments_all_have_keys() {
265        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
266            id: Some("att1".to_string()),
267            url: None,
268            size: None,
269            size_name: None,
270            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
271            key: Some(TEST_ENC_STRING.parse().unwrap()),
272        }]))];
273        assert!(check_for_old_attachments(&ciphers).is_ok());
274    }
275
276    #[test]
277    fn test_check_for_old_attachments_one_missing_key() {
278        let ciphers = vec![make_test_cipher(Some(vec![Attachment {
279            id: Some("att1".to_string()),
280            url: None,
281            size: None,
282            size_name: None,
283            file_name: Some(TEST_ENC_STRING.parse().unwrap()),
284            key: None,
285        }]))];
286        assert!(matches!(
287            check_for_old_attachments(&ciphers),
288            Err(RotateUserKeysError::OldAttachments)
289        ));
290    }
291
292    #[test]
293    fn test_check_for_old_attachments_ignores_organization_ciphers() {
294        let mut cipher = 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        cipher.organization_id = Some(bitwarden_core::OrganizationId::new_v4());
303        let ciphers = vec![cipher];
304        assert!(check_for_old_attachments(&ciphers).is_ok());
305    }
306
307    fn make_cipher_view() -> bitwarden_vault::CipherView {
308        use bitwarden_vault::{CipherView, LoginView};
309        CipherView {
310            id: None,
311            organization_id: None,
312            folder_id: None,
313            r#type: CipherType::Login,
314            name: "Test Cipher".to_string(),
315            notes: Some("Some cipher notes".to_string()),
316            favorite: false,
317            revision_date: Utc::now(),
318            deleted_date: None,
319            fields: None,
320            login: Some(LoginView {
321                username: Some("user".to_string()),
322                password: Some("pass".to_string()),
323                totp: None,
324                uris: None,
325                autofill_on_page_load: None,
326                fido2_credentials: None,
327                password_revision_date: None,
328            }),
329            card: None,
330            identity: None,
331            secure_note: None,
332            attachments: None,
333            attachment_decryption_failures: None,
334            organization_use_totp: false,
335            collection_ids: vec![],
336            reprompt: CipherRepromptType::None,
337            local_data: None,
338            key: None,
339            ssh_key: None,
340            bank_account: None,
341            passport: None,
342            drivers_license: None,
343            permissions: None,
344            view_password: false,
345            creation_date: Utc::now(),
346            archived_date: None,
347            edit: false,
348            password_history: None,
349        }
350    }
351
352    fn assert_decrypts_to(
353        cipher: &Cipher,
354        expected: &bitwarden_vault::CipherView,
355        key: bitwarden_core::key_management::SymmetricKeySlotId,
356        ctx: &mut bitwarden_crypto::KeyStoreContext<KeySlotIds>,
357    ) {
358        use bitwarden_vault::CipherView;
359        let decrypted: CipherView = cipher.decrypt(ctx, key).unwrap();
360        assert_eq!(expected.name, decrypted.name);
361        assert_eq!(expected.notes, decrypted.notes);
362        assert_eq!(expected.r#type, decrypted.r#type);
363        assert_eq!(
364            expected.login.as_ref().unwrap().username,
365            decrypted.login.as_ref().unwrap().username
366        );
367        assert_eq!(
368            expected.login.as_ref().unwrap().password,
369            decrypted.login.as_ref().unwrap().password
370        );
371    }
372
373    #[test]
374    fn test_ciphers() {
375        let store: KeyStore<KeySlotIds> = KeyStore::default();
376        let mut ctx = store.context_mut();
377
378        let user_key_old =
379            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
380        let user_key_new =
381            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
382
383        let cipher = make_cipher_view();
384        let encrypted_cipher = cipher.encrypt_composite(&mut ctx, user_key_old).unwrap();
385
386        // Rotate it
387        let ciphers = vec![encrypted_cipher];
388        let reencrypted_ciphers =
389            super::reencrypt_ciphers(ciphers.as_slice(), user_key_old, user_key_new, &mut ctx)
390                .unwrap();
391
392        // The keyless legacy cipher is upgraded to the blob format and decrypts under the new key
393        assert!(reencrypted_ciphers[0].is_blob_encrypted());
394        assert_decrypts_to(&reencrypted_ciphers[0], &cipher, user_key_new, &mut ctx);
395    }
396
397    #[test]
398    fn test_blob_gate_upgrades_keyed_legacy_cipher() {
399        let store: KeyStore<KeySlotIds> = KeyStore::default();
400        let mut ctx = store.context_mut();
401
402        let user_key_old =
403            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
404        let user_key_new =
405            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XChaCha20Poly1305);
406
407        // A legacy cipher that already carries a per-item cipher key
408        let mut cipher = make_cipher_view();
409        cipher.generate_cipher_key(&mut ctx, user_key_old).unwrap();
410        let encrypted = EncryptMode::Legacy(cipher.clone())
411            .encrypt_composite(&mut ctx, user_key_old)
412            .unwrap();
413        assert!(!encrypted.is_blob_encrypted());
414        assert!(encrypted.key.is_some());
415
416        let reencrypted =
417            super::reencrypt_ciphers(&[encrypted], user_key_old, user_key_new, &mut ctx).unwrap();
418
419        // The keyed legacy cipher is fully upgraded to the blob format
420        assert!(reencrypted[0].is_blob_encrypted());
421        assert_decrypts_to(&reencrypted[0], &cipher, user_key_new, &mut ctx);
422    }
423
424    #[test]
425    fn test_blob_gate_rewraps_existing_blob_without_re_encrypting() {
426        let store: KeyStore<KeySlotIds> = KeyStore::default();
427        let mut ctx = store.context_mut();
428
429        let user_key_old =
430            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
431        let user_key_new =
432            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::XChaCha20Poly1305);
433
434        // An already blob-encrypted cipher
435        let cipher = make_cipher_view();
436        let encrypted = EncryptMode::Blob(cipher.clone())
437            .encrypt_composite(&mut ctx, user_key_old)
438            .unwrap();
439        assert!(encrypted.is_blob_encrypted());
440
441        let reencrypted = super::reencrypt_ciphers(
442            std::slice::from_ref(&encrypted),
443            user_key_old,
444            user_key_new,
445            &mut ctx,
446        )
447        .unwrap();
448
449        // The sealed blob is left intact; only the wrapped cipher key is rewrapped
450        assert!(reencrypted[0].is_blob_encrypted());
451        assert_eq!(encrypted.data, reencrypted[0].data);
452        assert_ne!(encrypted.key, reencrypted[0].key);
453        assert_decrypts_to(&reencrypted[0], &cipher, user_key_new, &mut ctx);
454    }
455
456    #[test]
457    fn test_folders() {
458        let store: KeyStore<KeySlotIds> = KeyStore::default();
459        let mut ctx = store.context_mut();
460
461        let user_key_old =
462            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
463        let user_key_new =
464            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
465
466        // Create an encrypted folder
467        let folder = bitwarden_vault::FolderView {
468            id: None,
469            name: "Test Folder".to_string(),
470            revision_date: Utc::now(),
471        };
472        let encrypted_folder = folder.encrypt_composite(&mut ctx, user_key_old).unwrap();
473
474        // Rotate it
475        let folders = vec![encrypted_folder];
476        let reencrypted_folders =
477            super::reencrypt_folders(folders.as_slice(), user_key_old, user_key_new, &mut ctx)
478                .unwrap();
479
480        // Decrypt and assert
481        let decrypted_folder = reencrypted_folders[0]
482            .decrypt(&mut ctx, user_key_new)
483            .unwrap();
484        assert_eq!(folder, decrypted_folder);
485    }
486
487    #[test]
488    fn test_sends() {
489        let store: KeyStore<KeySlotIds> = KeyStore::default();
490        let mut ctx = store.context_mut();
491
492        let user_key_old =
493            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
494        let user_key_new =
495            ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac);
496
497        // Create an encrypted send
498        let send = bitwarden_send::SendView {
499            id: None,
500            access_id: None,
501            name: "Test Send".to_string(),
502            notes: Some("Some notes".to_string()),
503            key: Some("Pgui0FK85cNhBGWHAlBHBw".to_owned()),
504            text: Some(bitwarden_send::SendTextView {
505                text: Some("This is a test send".to_string()),
506                hidden: false,
507            }),
508            r#type: bitwarden_send::SendType::Text,
509            max_access_count: None,
510            access_count: 0,
511            disabled: false,
512            hide_email: false,
513            revision_date: Utc::now(),
514            deletion_date: Utc::now(),
515            expiration_date: None,
516            new_password: None,
517            has_password: false,
518            file: None,
519            emails: vec![],
520            auth_type: bitwarden_send::AuthType::None,
521        };
522        let encrypted_send = send.encrypt_composite(&mut ctx, user_key_old).unwrap();
523
524        // Rotate it
525        let sends = vec![encrypted_send];
526        let reencrypted_sends =
527            super::reencrypt_sends(sends.as_slice(), user_key_old, user_key_new, &mut ctx).unwrap();
528
529        // Decrypt and assert
530        let decrypted_send: SendView = reencrypted_sends[0]
531            .decrypt(&mut ctx, user_key_new)
532            .unwrap();
533
534        // The send seed must be the same
535        assert_eq!(send.key, decrypted_send.key);
536    }
537}