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            })
145            .await;
146    }
147
148    #[tokio::test]
149    async fn test_change_kdf_success_posts_and_persists() {
150        let api_client = ApiClient::new_mocked(|mock| {
151            mock.accounts_api
152                .expect_post_kdf()
153                .once()
154                .returning(|body| {
155                    let body = body.expect("body should be Some");
156                    // The unlock/authentication data must carry the new KDF settings.
157                    assert_eq!(body.authentication_data.kdf.iterations, 700_000);
158                    assert_eq!(body.unlock_data.kdf.iterations, 700_000);
159                    assert!(!body.master_password_hash.is_empty());
160                    Ok(())
161                });
162        });
163
164        let client =
165            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
166                .await;
167        client
168            .km_state_bridge()
169            .register_bridge(Box::new(InMemoryStateBridge::default()));
170        test_unlock_data(&client).await;
171
172        client
173            .user_crypto_management()
174            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
175            .await
176            .unwrap();
177
178        // The new KDF config and unlock data are persisted to state.
179        let bridge = client.km_state_bridge();
180        assert_eq!(bridge.get_kdf_config().await, Some(new_kdf()));
181        let unlock = bridge
182            .get_masterpassword_unlock_data()
183            .await
184            .expect("unlock data persisted");
185        assert_eq!(unlock.kdf, new_kdf());
186        // The internal client's KDF is updated as well.
187        assert_eq!(client.internal.get_kdf().await.unwrap(), new_kdf());
188    }
189
190    #[tokio::test]
191    async fn test_change_kdf_api_failure_does_not_persist() {
192        let api_client = ApiClient::new_mocked(|mock| {
193            mock.accounts_api
194                .expect_post_kdf()
195                .once()
196                .returning(|_body| Err(std::io::Error::other("Simulated error").into()));
197        });
198
199        let client =
200            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
201                .await;
202        client
203            .km_state_bridge()
204            .register_bridge(Box::new(InMemoryStateBridge::default()));
205        test_unlock_data(&client).await;
206
207        let current_kdf = client.internal.get_kdf().await.unwrap();
208
209        let result = client
210            .user_crypto_management()
211            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
212            .await;
213
214        assert!(matches!(result, Err(ChangeKdfError::Api(_))));
215        // The KDF config is untouched when the server call fails.
216        assert_eq!(client.km_state_bridge().get_kdf_config().await, None);
217        assert_eq!(client.internal.get_kdf().await.unwrap(), current_kdf);
218    }
219
220    #[tokio::test]
221    async fn test_change_kdf_missing_unlock_data_errors() {
222        let api_client = ApiClient::new_mocked(|_mock| {});
223        let client =
224            Client::init_test_account_with_api_client(test_bitwarden_com_account(), api_client)
225                .await;
226        client
227            .km_state_bridge()
228            .register_bridge(Box::new(InMemoryStateBridge::default()));
229
230        let result = client
231            .user_crypto_management()
232            .change_kdf(TEST_PASSWORD.to_string(), new_kdf())
233            .await;
234
235        assert!(matches!(
236            result,
237            Err(ChangeKdfError::MissingMasterPasswordUnlockData)
238        ));
239    }
240}