Skip to main content

bitwarden_user_crypto_management/
change_kdf.rs

1//! Client operation for changing the account's KDF (key derivation function) settings.
2
3use bitwarden_api_api::models::ChangeKdfRequestModel;
4use bitwarden_core::{
5    ApiError, NotAuthenticatedError,
6    key_management::{
7        MasterPasswordAuthenticationData, MasterPasswordError, MasterPasswordUnlockData,
8        SymmetricKeySlotId,
9    },
10};
11use bitwarden_crypto::Kdf;
12use bitwarden_error::bitwarden_error;
13use thiserror::Error;
14use tracing::error;
15#[cfg(feature = "wasm")]
16use wasm_bindgen::prelude::*;
17
18use crate::UserCryptoManagementClient;
19
20#[cfg_attr(feature = "wasm", wasm_bindgen)]
21#[cfg_attr(feature = "uniffi", uniffi::export(async_runtime = "tokio"))]
22impl UserCryptoManagementClient {
23    /// Changes the account's KDF settings, and sets them on the server.
24    pub async fn change_kdf(&self, password: String, new_kdf: Kdf) -> Result<(), ChangeKdfError> {
25        let bridge = self.client.km_state_bridge();
26
27        let current_unlock_data = bridge
28            .get_masterpassword_unlock_data()
29            .await
30            .ok_or(ChangeKdfError::MissingMasterPasswordUnlockData)?;
31        let salt = current_unlock_data.salt;
32        let current_kdf = current_unlock_data.kdf;
33
34        // Re-derive the authentication and unlock data. The key store context must not be held
35        // across the await on the server request below, so it is scoped to this block.
36        let (old_authentication_data, authentication_data, unlock_data) = {
37            let ctx = self.client.internal.get_key_store().context();
38
39            let old_authentication_data =
40                MasterPasswordAuthenticationData::derive(&password, &current_kdf, &salt)?;
41            let authentication_data =
42                MasterPasswordAuthenticationData::derive(&password, &new_kdf, &salt)?;
43            let unlock_data = MasterPasswordUnlockData::derive(
44                &password,
45                &new_kdf,
46                &salt,
47                SymmetricKeySlotId::User,
48                &ctx,
49            )?;
50
51            (old_authentication_data, authentication_data, unlock_data)
52        };
53
54        // Build and post the change-KDF request to the server. The old authentication hash proves
55        // possession of the current password; the new authentication and unlock data replace it.
56        let request = ChangeKdfRequestModel {
57            master_password_hash: old_authentication_data
58                .master_password_authentication_hash
59                .to_string(),
60            authentication_data: Box::new((&authentication_data).into()),
61            unlock_data: Box::new((&unlock_data).into()),
62        };
63
64        self.client
65            .internal
66            .get_api_configurations()
67            .api_client
68            .accounts_api()
69            .post_kdf(Some(request))
70            .await
71            .map_err(|e| {
72                error!("Failed to post change-kdf request: {e:?}");
73                ApiError::from(e)
74            })?;
75
76        // Persist the new unlock data and KDF config to client-managed state via the state bridge,
77        // then update the KDF held in the internal client so in-memory state stays consistent.
78        bridge.set_masterpassword_unlock_data(&unlock_data).await;
79        bridge.set_kdf_config(&new_kdf).await;
80        self.client
81            .internal
82            .set_user_master_password_unlock(unlock_data)
83            .await?;
84
85        Ok(())
86    }
87}
88
89/// Errors that can occur while changing the account KDF settings.
90#[derive(Debug, Error)]
91#[bitwarden_error(flat)]
92pub enum ChangeKdfError {
93    /// Deriving the new authentication or unlock data failed.
94    #[error(transparent)]
95    MasterPassword(#[from] MasterPasswordError),
96    /// The current master-password unlock data is not available in the state bridge.
97    #[error("Master password unlock data is not available")]
98    MissingMasterPasswordUnlockData,
99    /// The client is not authenticated with a master password.
100    #[error(transparent)]
101    NotAuthenticated(#[from] NotAuthenticatedError),
102    /// The server rejected the change-KDF request.
103    #[error(transparent)]
104    Api(#[from] ApiError),
105}
106
107#[cfg(test)]
108mod tests {
109    use std::num::NonZeroU32;
110
111    use bitwarden_api_api::apis::ApiClient;
112    use bitwarden_core::{
113        Client,
114        client::test_accounts::test_bitwarden_com_account,
115        key_management::{
116            MasterPasswordUnlockData, state_bridge::test_support::InMemoryStateBridge,
117        },
118    };
119    use bitwarden_crypto::Kdf;
120
121    use super::*;
122    use crate::UserCryptoManagementClientExt;
123
124    const TEST_PASSWORD: &str = "asdfasdfasdf";
125    const TEST_EMAIL: &str = "[email protected]";
126    // A valid EncString; only the salt/kdf of the seeded unlock data are used by derivation.
127    const TEST_WRAPPED_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
128
129    fn new_kdf() -> Kdf {
130        Kdf::PBKDF2 {
131            iterations: NonZeroU32::new(700_000).unwrap(),
132        }
133    }
134
135    // Sets the master-password unlock data in the state bridge to a fixed value for testing.
136    async fn test_unlock_data(client: &Client) {
137        let current_kdf = client.internal.get_kdf().await.unwrap();
138        client
139            .km_state_bridge()
140            .set_masterpassword_unlock_data(&MasterPasswordUnlockData {
141                kdf: current_kdf,
142                master_key_wrapped_user_key: TEST_WRAPPED_USER_KEY.parse().unwrap(),
143                salt: TEST_EMAIL.to_string(),
144                contained_key_id: None,
145            })
146            .await;
147    }
148
149    #[tokio::test]
150    async fn test_change_kdf_success_posts_and_persists() {
151        let api_client = ApiClient::new_mocked(|mock| {
152            mock.accounts_api
153                .expect_post_kdf()
154                .once()
155                .returning(|body| {
156                    let body = body.expect("body should be Some");
157                    // The unlock/authentication data must carry the new KDF settings.
158                    assert_eq!(body.authentication_data.kdf.iterations, 700_000);
159                    assert_eq!(body.unlock_data.kdf.iterations, 700_000);
160                    assert!(!body.master_password_hash.is_empty());
161                    Ok(())
162                });
163        });
164
165        let client =
166            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
167                .await;
168        client
169            .km_state_bridge()
170            .register_bridge(Box::new(InMemoryStateBridge::default()));
171        test_unlock_data(&client).await;
172
173        client
174            .user_crypto_management()
175            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
176            .await
177            .unwrap();
178
179        // The new KDF config and unlock data are persisted to state.
180        let bridge = client.km_state_bridge();
181        assert_eq!(bridge.get_kdf_config().await, Some(new_kdf()));
182        let unlock = bridge
183            .get_masterpassword_unlock_data()
184            .await
185            .expect("unlock data persisted");
186        assert_eq!(unlock.kdf, new_kdf());
187        // The internal client's KDF is updated as well.
188        assert_eq!(client.internal.get_kdf().await.unwrap(), new_kdf());
189    }
190
191    #[tokio::test]
192    async fn test_change_kdf_api_failure_does_not_persist() {
193        let api_client = ApiClient::new_mocked(|mock| {
194            mock.accounts_api
195                .expect_post_kdf()
196                .once()
197                .returning(|_body| Err(std::io::Error::other("Simulated error").into()));
198        });
199
200        let client =
201            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
202                .await;
203        client
204            .km_state_bridge()
205            .register_bridge(Box::new(InMemoryStateBridge::default()));
206        test_unlock_data(&client).await;
207
208        let current_kdf = client.internal.get_kdf().await.unwrap();
209
210        let result = client
211            .user_crypto_management()
212            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
213            .await;
214
215        assert!(matches!(result, Err(ChangeKdfError::Api(_))));
216        // The KDF config is untouched when the server call fails.
217        assert_eq!(client.km_state_bridge().get_kdf_config().await, None);
218        assert_eq!(client.internal.get_kdf().await.unwrap(), current_kdf);
219    }
220
221    #[tokio::test]
222    async fn test_change_kdf_missing_unlock_data_errors() {
223        let api_client = ApiClient::new_mocked(|_mock| {});
224        let client =
225            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
226                .await;
227        client
228            .km_state_bridge()
229            .register_bridge(Box::new(InMemoryStateBridge::default()));
230
231        let result = client
232            .user_crypto_management()
233            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
234            .await;
235
236        assert!(matches!(
237            result,
238            Err(ChangeKdfError::MissingMasterPasswordUnlockData)
239        ));
240    }
241}