Skip to content

Commit 03d02e6

Browse files
authored
[PM-24051] MasterPasswordUnlockData model with response mapping and adds it to identity success response model (#376)
## 🎟️ Tracking https://bitwarden.atlassian.net/browse/PM-24051 ## 📔 Objective Adds `MasterPasswordUnlockData` model with `MasterPasswordUnlockResponseModel` mapping. This wi Adds `UserDecryptionOptionsResponseModel` into the identity success token response. This model includes the master password unlock field. The `/sync` response model is autogenerated by OpenApi and already includes user decryption option and the master password unlock fields. Includes Uniffi and WASM bindings for `MasterPasswordUnlock`. In further PR's we plan to use the `MasterPasswordUnlock` in `InitUserCryptoMethod` enum - this is still in discussion. ## ⏰ Reminders before review - Contributor guidelines followed - All formatters and local linters executed and passed - Written new unit and / or integration tests where applicable - Protected functional changes with optionality (feature flags) - Used internationalization (i18n) for all UI strings - CI builds passed - Communicated to DevOps any deployment requirements - Updated any necessary documentation (Confluence, contributing docs) or informed the documentation team ## 🦮 Reviewer guidelines <!-- Suggested interactions but feel free to use (or not) as you desire! --> - 👍 (`:+1:`) or similar for great changes - 📝 (`:memo:`) or ℹ️ (`:information_source:`) for notes or general info - ❓ (`:question:`) for questions - 🤔 (`:thinking:`) or 💭 (`:thought_balloon:`) for more open inquiry that's not quite a confirmed issue and could potentially benefit from discussion - 🎨 (`:art:`) for suggestions / improvements - ❌ (`:x:`) or ⚠️ (`:warning:`) for more significant problems or concerns needing attention - 🌱 (`:seedling:`) or ♻️ (`:recycle:`) for future improvements or indications of technical debt - ⛏ (`:pick:`) for minor or nitpick changes
1 parent 10d2ff5 commit 03d02e6

File tree

7 files changed

+386
-0
lines changed

7 files changed

+386
-0
lines changed

crates/bitwarden-core/src/auth/api/response/identity_success_response.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ use bitwarden_api_identity::models::KdfType;
44
use serde::{Deserialize, Serialize};
55
use serde_json::Value;
66

7+
use crate::auth::api::response::user_decryption_options_response::UserDecryptionOptionsResponseModel;
8+
79
#[derive(Serialize, Deserialize, Debug, PartialEq)]
810
pub(crate) struct IdentityTokenSuccessResponse {
911
pub access_token: String,
@@ -35,6 +37,9 @@ pub(crate) struct IdentityTokenSuccessResponse {
3537
#[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")]
3638
key_connector_url: Option<String>,
3739

40+
#[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")]
41+
user_decryption_options: Option<UserDecryptionOptionsResponseModel>,
42+
3843
/// Stores unknown api response fields
3944
extra: Option<HashMap<String, Value>>,
4045
}
@@ -61,6 +66,7 @@ mod test {
6166
force_password_reset: Default::default(),
6267
api_use_key_connector: Default::default(),
6368
key_connector_url: Default::default(),
69+
user_decryption_options: Default::default(),
6470
extra: Default::default(),
6571
}
6672
}

crates/bitwarden-core/src/auth/api/response/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ mod identity_token_response;
66
mod identity_two_factor_response;
77
pub(crate) mod two_factor_provider_data;
88
mod two_factor_providers;
9+
pub(crate) mod user_decryption_options_response;
910

1011
pub(crate) use identity_payload_response::*;
1112
pub(crate) use identity_refresh_response::*;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
use bitwarden_api_api::models::MasterPasswordUnlockResponseModel;
2+
use serde::{Deserialize, Serialize};
3+
4+
/// Provides user decryption options used to unlock user's vault.
5+
/// Currently, only master password unlock is supported.
6+
#[allow(dead_code)]
7+
#[derive(Serialize, Deserialize, Debug, PartialEq)]
8+
pub(crate) struct UserDecryptionOptionsResponseModel {
9+
/// Contains information needed to unlock user's vault with master password.
10+
/// None when user have no master password.
11+
#[serde(
12+
rename = "masterPasswordUnlock",
13+
skip_serializing_if = "Option::is_none"
14+
)]
15+
pub(crate) master_password_unlock: Option<MasterPasswordUnlockResponseModel>,
16+
}

crates/bitwarden-core/src/auth/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ use crate::{NotAuthenticatedError, VaultLockedError, WrongPasswordError};
99
mod access_token;
1010
// API is intentionally not visible outside of `auth` as these should be considered private.
1111
mod api;
12+
#[cfg(feature = "internal")]
13+
pub(crate) use api::response::user_decryption_options_response::UserDecryptionOptionsResponseModel;
1214
#[allow(missing_docs)]
1315
pub mod auth_client;
1416
mod jwt_token;
Lines changed: 310 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,310 @@
1+
use std::num::NonZeroU32;
2+
3+
use bitwarden_api_api::models::{
4+
master_password_unlock_response_model::MasterPasswordUnlockResponseModel, KdfType,
5+
};
6+
use bitwarden_crypto::{EncString, Kdf};
7+
use bitwarden_error::bitwarden_error;
8+
use serde::{Deserialize, Serialize};
9+
10+
use crate::{require, MissingFieldError};
11+
12+
/// Error for master password related operations.
13+
#[allow(dead_code)]
14+
#[bitwarden_error(flat)]
15+
#[derive(Debug, thiserror::Error)]
16+
pub(crate) enum MasterPasswordError {
17+
/// The wrapped encryption key could not be parsed because the encstring is malformed
18+
#[error("Wrapped encryption key is malformed")]
19+
EncryptionKeyMalformed,
20+
/// The KDF data could not be parsed, because it has an invalid value
21+
#[error("KDF is malformed")]
22+
KdfMalformed,
23+
/// The wrapped encryption key or salt fields are missing or KDF data is incomplete
24+
#[error(transparent)]
25+
MissingField(#[from] MissingFieldError),
26+
}
27+
28+
/// Represents the data required to unlock with the master password.
29+
#[allow(dead_code)]
30+
#[derive(Serialize, Deserialize, Debug)]
31+
#[serde(rename_all = "camelCase", deny_unknown_fields)]
32+
pub(crate) struct MasterPasswordUnlockData {
33+
/// The key derivation function used to derive the master key
34+
kdf: Kdf,
35+
/// The master key wrapped user key
36+
master_key_wrapped_user_key: EncString,
37+
/// The salt used in the KDF, typically the user's email
38+
salt: String,
39+
}
40+
41+
impl TryFrom<MasterPasswordUnlockResponseModel> for MasterPasswordUnlockData {
42+
type Error = MasterPasswordError;
43+
44+
fn try_from(response: MasterPasswordUnlockResponseModel) -> Result<Self, Self::Error> {
45+
let kdf = match response.kdf.kdf_type {
46+
KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 {
47+
iterations: kdf_parse_nonzero_u32(response.kdf.iterations)?,
48+
},
49+
KdfType::Argon2id => Kdf::Argon2id {
50+
iterations: kdf_parse_nonzero_u32(response.kdf.iterations)?,
51+
memory: kdf_parse_nonzero_u32(require!(response.kdf.memory))?,
52+
parallelism: kdf_parse_nonzero_u32(require!(response.kdf.parallelism))?,
53+
},
54+
};
55+
56+
let master_key_encrypted_user_key = require!(response.master_key_encrypted_user_key);
57+
let salt = require!(response.salt);
58+
59+
Ok(MasterPasswordUnlockData {
60+
kdf,
61+
master_key_wrapped_user_key: master_key_encrypted_user_key
62+
.parse()
63+
.map_err(|_| MasterPasswordError::EncryptionKeyMalformed)?,
64+
salt,
65+
})
66+
}
67+
}
68+
69+
fn kdf_parse_nonzero_u32(value: impl TryInto<u32>) -> Result<NonZeroU32, MasterPasswordError> {
70+
value
71+
.try_into()
72+
.ok()
73+
.and_then(NonZeroU32::new)
74+
.ok_or(MasterPasswordError::KdfMalformed)
75+
}
76+
77+
#[cfg(test)]
78+
mod tests {
79+
use bitwarden_api_api::models::{KdfType, MasterPasswordUnlockKdfResponseModel};
80+
81+
use super::*;
82+
83+
const TEST_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
84+
const TEST_INVALID_USER_KEY: &str = "-1.8UClLa8IPE1iZT7chy5wzQ==|6PVfHnVk5S3XqEtQemnM5yb4JodxmPkkWzmDRdfyHtjORmvxqlLX40tBJZ+CKxQWmS8tpEB5w39rbgHg/gqs0haGdZG4cPbywsgGzxZ7uNI=";
85+
const TEST_SALT: &str = "[email protected]";
86+
87+
fn create_pbkdf2_response(
88+
master_key_encrypted_user_key: Option<String>,
89+
salt: Option<String>,
90+
iterations: i32,
91+
) -> MasterPasswordUnlockResponseModel {
92+
MasterPasswordUnlockResponseModel {
93+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
94+
kdf_type: KdfType::PBKDF2_SHA256,
95+
iterations,
96+
memory: None,
97+
parallelism: None,
98+
}),
99+
master_key_encrypted_user_key,
100+
salt,
101+
}
102+
}
103+
104+
#[test]
105+
fn test_try_from_master_password_unlock_response_model_pbkdf2_success() {
106+
let response = create_pbkdf2_response(
107+
Some(TEST_USER_KEY.to_string()),
108+
Some(TEST_SALT.to_string()),
109+
600_000,
110+
);
111+
112+
let data = MasterPasswordUnlockData::try_from(response).unwrap();
113+
114+
if let Kdf::PBKDF2 { iterations } = data.kdf {
115+
assert_eq!(iterations.get(), 600_000);
116+
} else {
117+
panic!("Expected PBKDF2 KDF")
118+
}
119+
120+
assert_eq!(data.salt, TEST_SALT);
121+
assert_eq!(data.master_key_wrapped_user_key.to_string(), TEST_USER_KEY);
122+
}
123+
124+
#[test]
125+
fn test_try_from_master_password_unlock_response_model_argon2id_success() {
126+
let response = MasterPasswordUnlockResponseModel {
127+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
128+
kdf_type: KdfType::Argon2id,
129+
iterations: 3,
130+
memory: Some(64),
131+
parallelism: Some(4),
132+
}),
133+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
134+
salt: Some(TEST_SALT.to_string()),
135+
};
136+
137+
let data = MasterPasswordUnlockData::try_from(response).unwrap();
138+
139+
if let Kdf::Argon2id {
140+
iterations,
141+
memory,
142+
parallelism,
143+
} = data.kdf
144+
{
145+
assert_eq!(iterations.get(), 3);
146+
assert_eq!(memory.get(), 64);
147+
assert_eq!(parallelism.get(), 4);
148+
} else {
149+
panic!("Expected Argon2id KDF")
150+
}
151+
152+
assert_eq!(data.salt, TEST_SALT);
153+
assert_eq!(data.master_key_wrapped_user_key.to_string(), TEST_USER_KEY);
154+
}
155+
156+
#[test]
157+
fn test_try_from_master_password_unlock_response_model_invalid_user_key_encryption_kdf_malformed_error(
158+
) {
159+
let response = create_pbkdf2_response(
160+
Some(TEST_INVALID_USER_KEY.to_string()),
161+
Some(TEST_SALT.to_string()),
162+
600_000,
163+
);
164+
165+
let result = MasterPasswordUnlockData::try_from(response);
166+
assert!(matches!(
167+
result,
168+
Err(MasterPasswordError::EncryptionKeyMalformed)
169+
));
170+
}
171+
172+
#[test]
173+
fn test_try_from_master_password_unlock_response_model_user_key_none_missing_field_error() {
174+
let response = create_pbkdf2_response(None, Some(TEST_SALT.to_string()), 600_000);
175+
176+
let result = MasterPasswordUnlockData::try_from(response);
177+
assert!(matches!(
178+
result,
179+
Err(MasterPasswordError::MissingField(MissingFieldError(
180+
"response.master_key_encrypted_user_key"
181+
)))
182+
));
183+
}
184+
185+
#[test]
186+
fn test_try_from_master_password_unlock_response_model_salt_none_missing_field_error() {
187+
let response = create_pbkdf2_response(Some(TEST_USER_KEY.to_string()), None, 600_000);
188+
189+
let result = MasterPasswordUnlockData::try_from(response);
190+
assert!(matches!(
191+
result,
192+
Err(MasterPasswordError::MissingField(MissingFieldError(
193+
"response.salt"
194+
)))
195+
));
196+
}
197+
198+
#[test]
199+
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_memory_none_missing_field_error(
200+
) {
201+
let response = MasterPasswordUnlockResponseModel {
202+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
203+
kdf_type: KdfType::Argon2id,
204+
iterations: 3,
205+
memory: None,
206+
parallelism: Some(4),
207+
}),
208+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
209+
salt: Some(TEST_SALT.to_string()),
210+
};
211+
212+
let result = MasterPasswordUnlockData::try_from(response);
213+
assert!(matches!(
214+
result,
215+
Err(MasterPasswordError::MissingField(MissingFieldError(
216+
"response.kdf.memory"
217+
)))
218+
));
219+
}
220+
221+
#[test]
222+
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_memory_zero_kdf_malformed_error(
223+
) {
224+
let response = MasterPasswordUnlockResponseModel {
225+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
226+
kdf_type: KdfType::Argon2id,
227+
iterations: 3,
228+
memory: Some(0),
229+
parallelism: Some(4),
230+
}),
231+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
232+
salt: Some(TEST_SALT.to_string()),
233+
};
234+
235+
let result = MasterPasswordUnlockData::try_from(response);
236+
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
237+
}
238+
239+
#[test]
240+
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_parallelism_none_missing_field_error(
241+
) {
242+
let response = MasterPasswordUnlockResponseModel {
243+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
244+
kdf_type: KdfType::Argon2id,
245+
iterations: 3,
246+
memory: Some(64),
247+
parallelism: None,
248+
}),
249+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
250+
salt: Some(TEST_SALT.to_string()),
251+
};
252+
253+
let result = MasterPasswordUnlockData::try_from(response);
254+
assert!(matches!(
255+
result,
256+
Err(MasterPasswordError::MissingField(MissingFieldError(
257+
"response.kdf.parallelism"
258+
)))
259+
));
260+
}
261+
262+
#[test]
263+
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_parallelism_zero_kdf_malformed_error(
264+
) {
265+
let response = MasterPasswordUnlockResponseModel {
266+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
267+
kdf_type: KdfType::Argon2id,
268+
iterations: 3,
269+
memory: Some(64),
270+
parallelism: Some(0),
271+
}),
272+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
273+
salt: Some(TEST_SALT.to_string()),
274+
};
275+
276+
let result = MasterPasswordUnlockData::try_from(response);
277+
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
278+
}
279+
280+
#[test]
281+
fn test_try_from_master_password_unlock_response_model_pbkdf2_kdf_iterations_zero_kdf_malformed_error(
282+
) {
283+
let response = create_pbkdf2_response(
284+
Some(TEST_USER_KEY.to_string()),
285+
Some(TEST_SALT.to_string()),
286+
0,
287+
);
288+
289+
let result = MasterPasswordUnlockData::try_from(response);
290+
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
291+
}
292+
293+
#[test]
294+
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_iterations_zero_kdf_malformed_error(
295+
) {
296+
let response = MasterPasswordUnlockResponseModel {
297+
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
298+
kdf_type: KdfType::Argon2id,
299+
iterations: 0,
300+
memory: Some(64),
301+
parallelism: Some(4),
302+
}),
303+
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
304+
salt: Some(TEST_SALT.to_string()),
305+
};
306+
307+
let result = MasterPasswordUnlockData::try_from(response);
308+
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
309+
}
310+
}

crates/bitwarden-core/src/key_management/mod.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,13 +19,17 @@ mod crypto_client;
1919
#[cfg(feature = "internal")]
2020
pub use crypto_client::CryptoClient;
2121

22+
#[cfg(feature = "internal")]
23+
mod master_password;
2224
#[cfg(feature = "internal")]
2325
mod non_generic_wrappers;
2426
#[cfg(feature = "internal")]
2527
pub(crate) use non_generic_wrappers::*;
2628
#[cfg(feature = "internal")]
2729
mod security_state;
2830
#[cfg(feature = "internal")]
31+
mod user_decryption;
32+
#[cfg(feature = "internal")]
2933
pub use security_state::{SecurityState, SignedSecurityState};
3034

3135
use crate::OrganizationId;

0 commit comments

Comments
 (0)