Skip to main content

bitwarden_user_crypto_management/key_rotation/
password_change_and_rotate_user_keys.rs

1//! Functionality for rotating user keys, bundled with a password change.
2use bitwarden_api_api::models::RotateUserAccountKeysAndDataRequestModel;
3use bitwarden_core::key_management::{KeySlotIds, MasterPasswordAuthenticationData};
4use bitwarden_crypto::{KeyStore, PublicKey};
5use serde::{Deserialize, Serialize};
6use tracing::{info, instrument};
7#[cfg(feature = "wasm")]
8use tsify::Tsify;
9#[cfg(feature = "wasm")]
10use wasm_bindgen::prelude::*;
11
12use crate::{
13    UserCryptoManagementClient,
14    key_rotation::{
15        RotateUserKeysError,
16        crypto::rotate_account_cryptographic_state_to_request_model,
17        data::{check_for_old_attachments, reencrypt_data},
18        rotation_context::make_rotation_context,
19        sync::sync_current_account_data,
20        unlock::{
21            ReencryptCommonUnlockDataInput, ReencryptMasterPasswordChangeAndUnlockInput,
22            reencrypt_master_password_change_unlock_data,
23        },
24    },
25};
26
27#[derive(Serialize, Deserialize, Clone)]
28#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
29#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
30pub struct PasswordChangeAndRotateUserKeysRequest {
31    pub old_password: String,
32    pub password: String,
33    pub hint: Option<String>,
34    pub trusted_emergency_access_public_keys: Vec<PublicKey>,
35    pub trusted_organization_public_keys: Vec<PublicKey>,
36}
37
38#[cfg_attr(feature = "wasm", wasm_bindgen)]
39impl UserCryptoManagementClient {
40    /// Combines a password change and user key rotation into a single request.
41    pub async fn password_change_and_rotate_user_keys(
42        &self,
43        request: PasswordChangeAndRotateUserKeysRequest,
44    ) -> Result<(), RotateUserKeysError> {
45        let api_client = &self.client.internal.get_api_configurations().api_client;
46        let key_store = self.client.internal.get_key_store();
47        internal_password_change_and_rotate_user_keys(key_store, api_client, request).await
48    }
49}
50
51#[instrument(
52    name = "password_change_and_rotate_user_keys",
53    level = "info",
54    skip_all,
55    err
56)]
57async fn internal_password_change_and_rotate_user_keys(
58    key_store: &KeyStore<KeySlotIds>,
59    api_client: &bitwarden_api_api::apis::ApiClient,
60    request: PasswordChangeAndRotateUserKeysRequest,
61) -> Result<(), RotateUserKeysError> {
62    let sync = sync_current_account_data(api_client)
63        .await
64        .map_err(|_| RotateUserKeysError::Api)?;
65
66    // Fail early if any cipher has old attachments that would become irrecoverable
67    check_for_old_attachments(&sync.ciphers)?;
68
69    // Create a separate scope so that the mutable context is not held across the await point
70    let post_request = {
71        let mut ctx = key_store.context_mut();
72
73        let rotation_context = make_rotation_context(
74            &sync,
75            request.trusted_organization_public_keys.as_slice(),
76            request.trusted_emergency_access_public_keys.as_slice(),
77            &mut ctx,
78        )?;
79
80        info!("Rotating account cryptographic state for user key rotation");
81        let account_keys_model = rotate_account_cryptographic_state_to_request_model(
82            &sync.wrapped_account_cryptographic_state,
83            &rotation_context.current_user_key_id,
84            &rotation_context.new_user_key_id,
85            &mut ctx,
86        )
87        .map_err(|_| RotateUserKeysError::Crypto)?;
88
89        info!("Re-encrypting account data for user key rotation");
90        let account_data_model = reencrypt_data(
91            sync.folders.as_slice(),
92            sync.ciphers.as_slice(),
93            sync.sends.as_slice(),
94            rotation_context.current_user_key_id,
95            rotation_context.new_user_key_id,
96            &mut ctx,
97        )
98        .map_err(|_| RotateUserKeysError::Crypto)?;
99
100        info!("Re-encrypting account unlock data for user key rotation");
101        let (kdf, salt) = sync.kdf_and_salt.ok_or(RotateUserKeysError::Api)?;
102        let unlock_data_model = reencrypt_master_password_change_unlock_data(
103            ReencryptMasterPasswordChangeAndUnlockInput {
104                password: request.password,
105                hint: request.hint,
106                kdf: kdf.clone(),
107                salt: salt.clone(),
108                common_unlock_data: ReencryptCommonUnlockDataInput {
109                    trusted_devices: sync.trusted_devices,
110                    webauthn_credentials: sync.passkeys,
111                    trusted_organization_keys: rotation_context.v1_organization_memberships,
112                    trusted_emergency_access_keys: rotation_context.v1_emergency_access_memberships,
113                },
114            },
115            rotation_context.current_user_key_id,
116            rotation_context.new_user_key_id,
117            &mut ctx,
118        )
119        .map_err(|_| RotateUserKeysError::Crypto)?;
120
121        let old_master_password_authentication_data =
122            MasterPasswordAuthenticationData::derive(&request.old_password, &kdf, &salt)
123                .map_err(|_| RotateUserKeysError::Crypto)?;
124
125        RotateUserAccountKeysAndDataRequestModel {
126            old_master_key_authentication_hash: Some(
127                old_master_password_authentication_data
128                    .master_password_authentication_hash
129                    .to_string(),
130            ),
131            account_keys: Box::new(account_keys_model),
132            account_data: Box::new(account_data_model),
133            account_unlock_data: Box::new(unlock_data_model),
134        }
135    };
136
137    info!("Posting rotated user account keys and data to server");
138    api_client
139        .accounts_key_management_api()
140        .password_change_and_rotate_user_account_keys(Some(post_request))
141        .await
142        .map_err(|_| RotateUserKeysError::Api)?;
143    info!("Successfully rotated user account keys and data");
144    Ok(())
145}
146
147#[cfg(test)]
148mod tests {
149    use bitwarden_api_api::{
150        apis::ApiClient,
151        models::{
152            DeviceAuthRequestResponseModelListResponseModel,
153            EmergencyAccessGranteeDetailsResponseModelListResponseModel, KdfType,
154            MasterPasswordUnlockKdfResponseModel, MasterPasswordUnlockResponseModel,
155            PrivateKeysResponseModel, ProfileOrganizationResponseModelListResponseModel,
156            ProfileResponseModel, PublicKeyEncryptionKeyPairResponseModel, SyncResponseModel,
157            UserDecryptionResponseModel, WebAuthnCredentialResponseModelListResponseModel,
158        },
159    };
160    use bitwarden_core::key_management::{KeySlotIds, SymmetricKeySlotId};
161    use bitwarden_crypto::{KeyStore, PublicKeyEncryptionAlgorithm, SymmetricKeyAlgorithm};
162
163    use super::*;
164
165    fn make_test_key_store_and_sync_response() -> (KeyStore<KeySlotIds>, SyncResponseModel) {
166        let store: KeyStore<KeySlotIds> = KeyStore::default();
167        let wrapped_private_key = {
168            let mut ctx = store.context_mut();
169            let user_key = ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac);
170            let _ = ctx.persist_symmetric_key(user_key, SymmetricKeySlotId::User);
171            let private_key = ctx.make_private_key(PublicKeyEncryptionAlgorithm::RsaOaepSha1);
172            ctx.wrap_private_key(SymmetricKeySlotId::User, private_key)
173                .unwrap()
174        };
175
176        let sync_response = SyncResponseModel {
177            object: Some("sync".to_string()),
178            profile: Some(Box::new(ProfileResponseModel {
179                id: Some(uuid::Uuid::new_v4()),
180                account_keys: Some(Box::new(PrivateKeysResponseModel {
181                    object: None,
182                    signature_key_pair: None,
183                    public_key_encryption_key_pair: Box::new(
184                        PublicKeyEncryptionKeyPairResponseModel {
185                            object: None,
186                            wrapped_private_key: Some(wrapped_private_key.to_string()),
187                            public_key: None,
188                            signed_public_key: None,
189                        },
190                    ),
191                    security_state: None,
192                })),
193                ..ProfileResponseModel::default()
194            })),
195            folders: Some(vec![]),
196            ciphers: Some(vec![]),
197            sends: Some(vec![]),
198            user_decryption: Some(Box::new(UserDecryptionResponseModel {
199                master_password_unlock: Some(Box::new(MasterPasswordUnlockResponseModel {
200                    kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
201                        kdf_type: KdfType::PBKDF2_SHA256,
202                        iterations: 600000,
203                        memory: None,
204                        parallelism: None,
205                    }),
206                    master_key_encrypted_user_key: None,
207                    salt: Some("test_salt".to_string()),
208                })),
209                web_authn_prf_options: None,
210                v2_upgrade_token: None,
211            })),
212            ..Default::default()
213        };
214
215        (store, sync_response)
216    }
217
218    fn mock_empty_sync_calls(mock: &mut bitwarden_api_api::apis::ApiClientMock) {
219        mock.organizations_api
220            .expect_get_user()
221            .once()
222            .returning(|| {
223                Ok(ProfileOrganizationResponseModelListResponseModel {
224                    object: None,
225                    data: Some(vec![]),
226                    continuation_token: None,
227                })
228            });
229        mock.emergency_access_api
230            .expect_get_contacts()
231            .once()
232            .returning(|| {
233                Ok(
234                    EmergencyAccessGranteeDetailsResponseModelListResponseModel {
235                        object: None,
236                        data: Some(vec![]),
237                        continuation_token: None,
238                    },
239                )
240            });
241        mock.devices_api.expect_get_all().once().returning(|| {
242            Ok(DeviceAuthRequestResponseModelListResponseModel {
243                object: None,
244                data: Some(vec![]),
245                continuation_token: None,
246            })
247        });
248        mock.web_authn_api.expect_get().once().returning(|| {
249            Ok(WebAuthnCredentialResponseModelListResponseModel {
250                object: None,
251                data: Some(vec![]),
252                continuation_token: None,
253            })
254        });
255    }
256
257    #[tokio::test]
258    async fn test_password_change_and_rotate_user_keys_sync_api_failure_returns_api_error() {
259        let store: KeyStore<KeySlotIds> = KeyStore::default();
260        let api_client = ApiClient::new_mocked(|mock| {
261            mock.sync_api.expect_get().once().returning(|_| {
262                Err(serde_json::Error::io(std::io::Error::other("network error")).into())
263            });
264            mock.accounts_key_management_api
265                .expect_password_change_and_rotate_user_account_keys()
266                .never();
267        });
268
269        let result = internal_password_change_and_rotate_user_keys(
270            &store,
271            &api_client,
272            PasswordChangeAndRotateUserKeysRequest {
273                old_password: "old_password".to_string(),
274                password: "new_password".to_string(),
275                hint: None,
276                trusted_organization_public_keys: vec![],
277                trusted_emergency_access_public_keys: vec![],
278            },
279        )
280        .await;
281
282        assert!(matches!(result, Err(RotateUserKeysError::Api)));
283        if let ApiClient::Mock(mut mock) = api_client {
284            mock.sync_api.checkpoint();
285            mock.accounts_key_management_api.checkpoint();
286        }
287    }
288
289    #[tokio::test]
290    async fn test_password_change_and_rotate_user_keys_missing_kdf_returns_api_error() {
291        let (key_store, mut sync_response) = make_test_key_store_and_sync_response();
292        // Remove master_password_unlock so kdf_and_salt resolves to None
293        if let Some(user_decryption) = sync_response.user_decryption.as_mut() {
294            user_decryption.master_password_unlock = None;
295        }
296        let api_client = ApiClient::new_mocked(|mock| {
297            mock.sync_api
298                .expect_get()
299                .once()
300                .returning(move |_| Ok(sync_response.clone()));
301            mock_empty_sync_calls(mock);
302            mock.accounts_key_management_api
303                .expect_password_change_and_rotate_user_account_keys()
304                .never();
305        });
306
307        let result = internal_password_change_and_rotate_user_keys(
308            &key_store,
309            &api_client,
310            PasswordChangeAndRotateUserKeysRequest {
311                old_password: "old_password".to_string(),
312                password: "new_password".to_string(),
313                hint: None,
314                trusted_organization_public_keys: vec![],
315                trusted_emergency_access_public_keys: vec![],
316            },
317        )
318        .await;
319
320        assert!(matches!(result, Err(RotateUserKeysError::Api)));
321        if let ApiClient::Mock(mut mock) = api_client {
322            mock.sync_api.checkpoint();
323            mock.organizations_api.checkpoint();
324            mock.emergency_access_api.checkpoint();
325            mock.devices_api.checkpoint();
326            mock.web_authn_api.checkpoint();
327            mock.accounts_key_management_api.checkpoint();
328        }
329    }
330
331    #[tokio::test]
332    async fn test_password_change_and_rotate_user_keys_success() {
333        let (key_store, sync_response) = make_test_key_store_and_sync_response();
334        let api_client = ApiClient::new_mocked(|mock| {
335            mock.sync_api
336                .expect_get()
337                .once()
338                .returning(move |_| Ok(sync_response.clone()));
339            mock_empty_sync_calls(mock);
340            mock.accounts_key_management_api
341                .expect_password_change_and_rotate_user_account_keys()
342                .once()
343                .returning(|_| Ok(()));
344        });
345
346        let result = internal_password_change_and_rotate_user_keys(
347            &key_store,
348            &api_client,
349            PasswordChangeAndRotateUserKeysRequest {
350                old_password: "old_password".to_string(),
351                password: "new_password".to_string(),
352                hint: None,
353                trusted_organization_public_keys: vec![],
354                trusted_emergency_access_public_keys: vec![],
355            },
356        )
357        .await;
358
359        assert!(result.is_ok());
360        if let ApiClient::Mock(mut mock) = api_client {
361            mock.sync_api.checkpoint();
362            mock.organizations_api.checkpoint();
363            mock.emergency_access_api.checkpoint();
364            mock.devices_api.checkpoint();
365            mock.web_authn_api.checkpoint();
366            mock.accounts_key_management_api.checkpoint();
367        }
368    }
369
370    #[tokio::test]
371    async fn test_password_change_and_rotate_user_keys_post_api_failure_returns_api_error() {
372        let (key_store, sync_response) = make_test_key_store_and_sync_response();
373        let api_client = ApiClient::new_mocked(|mock| {
374            mock.sync_api
375                .expect_get()
376                .once()
377                .returning(move |_| Ok(sync_response.clone()));
378            mock_empty_sync_calls(mock);
379            mock.accounts_key_management_api
380                .expect_password_change_and_rotate_user_account_keys()
381                .once()
382                .returning(|_| {
383                    Err(serde_json::Error::io(std::io::Error::other("API error")).into())
384                });
385        });
386
387        let result = internal_password_change_and_rotate_user_keys(
388            &key_store,
389            &api_client,
390            PasswordChangeAndRotateUserKeysRequest {
391                old_password: "old_password".to_string(),
392                password: "new_password".to_string(),
393                hint: None,
394                trusted_organization_public_keys: vec![],
395                trusted_emergency_access_public_keys: vec![],
396            },
397        )
398        .await;
399
400        assert!(matches!(result, Err(RotateUserKeysError::Api)));
401        if let ApiClient::Mock(mut mock) = api_client {
402            mock.sync_api.checkpoint();
403            mock.organizations_api.checkpoint();
404            mock.emergency_access_api.checkpoint();
405            mock.devices_api.checkpoint();
406            mock.web_authn_api.checkpoint();
407            mock.accounts_key_management_api.checkpoint();
408        }
409    }
410
411    #[tokio::test]
412    async fn test_password_change_and_rotate_old_attachments_returns_error() {
413        use bitwarden_api_api::models::{
414            AttachmentResponseModel, CipherDetailsResponseModel, CipherType,
415        };
416
417        let (key_store, mut sync_response) = make_test_key_store_and_sync_response();
418        let enc_string = "2.STIyTrfDZN/JXNDN9zNEMw==|NDLum8BHZpPNYhJo9ggSkg==|UCsCLlBO3QzdPwvMAWs2VVwuE6xwOx/vxOooPObqnEw=";
419
420        // Add a cipher with an old attachment (key is None)
421        sync_response.ciphers = Some(vec![CipherDetailsResponseModel {
422            id: Some(uuid::Uuid::new_v4()),
423            organization_id: None,
424            r#type: Some(CipherType::Login),
425            name: Some(enc_string.to_string()),
426            revision_date: Some("2024-01-01T00:00:00Z".to_string()),
427            creation_date: Some("2024-01-01T00:00:00Z".to_string()),
428            attachments: Some(vec![AttachmentResponseModel {
429                id: Some("att1".to_string()),
430                file_name: Some(enc_string.to_string()),
431                key: None, // Old attachment - no per-attachment key
432                ..AttachmentResponseModel::new()
433            }]),
434            ..CipherDetailsResponseModel::new()
435        }]);
436
437        let api_client = ApiClient::new_mocked(|mock| {
438            mock.sync_api
439                .expect_get()
440                .once()
441                .returning(move |_| Ok(sync_response.clone()));
442            mock_empty_sync_calls(mock);
443            // Rotation API should never be called
444            mock.accounts_key_management_api
445                .expect_password_change_and_rotate_user_account_keys()
446                .never();
447        });
448
449        let result = internal_password_change_and_rotate_user_keys(
450            &key_store,
451            &api_client,
452            PasswordChangeAndRotateUserKeysRequest {
453                old_password: "old_password".to_string(),
454                password: "new_password".to_string(),
455                hint: None,
456                trusted_organization_public_keys: vec![],
457                trusted_emergency_access_public_keys: vec![],
458            },
459        )
460        .await;
461
462        assert!(matches!(result, Err(RotateUserKeysError::OldAttachments)));
463        if let ApiClient::Mock(mut mock) = api_client {
464            mock.sync_api.checkpoint();
465            mock.organizations_api.checkpoint();
466            mock.emergency_access_api.checkpoint();
467            mock.devices_api.checkpoint();
468            mock.web_authn_api.checkpoint();
469            mock.accounts_key_management_api.checkpoint();
470        }
471    }
472}