Skip to content

[PM-24051] In identity and sync response & decryption options, add MasterPasswordUnlockDataResponse in response model #376

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use std::{collections::HashMap, num::NonZeroU32};

use bitwarden_api_api::models::UserDecryptionResponseModel;
use bitwarden_api_identity::models::KdfType;
use serde::{Deserialize, Serialize};
use serde_json::Value;
Expand Down Expand Up @@ -35,6 +36,9 @@ pub struct IdentityTokenSuccessResponse {
#[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")]
key_connector_url: Option<String>,

#[serde(rename = "userDecryptionOptions", alias = "UserDecryptionOptions")]
pub user_decryption_options: Option<UserDecryptionResponseModel>,

/// Stores unknown api response fields
extra: Option<HashMap<String, Value>>,
}
Expand All @@ -61,6 +65,7 @@ mod test {
force_password_reset: Default::default(),
api_use_key_connector: Default::default(),
key_connector_url: Default::default(),
user_decryption_options: Default::default(),
extra: Default::default(),
}
}
Expand Down
353 changes: 353 additions & 0 deletions crates/bitwarden-core/src/key_management/master_password.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,353 @@
#![allow(missing_docs)]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please remove this and add comments. We require comments on everything that is publicly exposed.


use std::{num::NonZeroU32, str::FromStr};

use bitwarden_api_api::models::{
master_password_unlock_response_model::MasterPasswordUnlockResponseModel, KdfType,
};
use bitwarden_crypto::{CryptoError, EncString, Kdf};
use bitwarden_error::bitwarden_error;
use serde::{Deserialize, Serialize};

use crate::{require, MissingFieldError};

#[bitwarden_error(flat)]
#[derive(Debug, thiserror::Error)]
pub enum MasterPasswordError {
#[error(transparent)]
Crypto(#[from] CryptoError),
#[error(transparent)]
MissingField(#[from] MissingFieldError),
}

#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(
feature = "wasm",
derive(tsify::Tsify),
tsify(into_wasm_abi, from_wasm_abi)
)]
pub struct MasterPasswordUnlockData {
pub kdf: Kdf,
pub master_key_wrapped_user_key: EncString,
pub salt: String,
Copy link
Contributor

@quexten quexten Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have an opaque string type MasterPasswordSalt in clients: https://github.com/bitwarden/clients/blob/61cd0c4f51d9fe390c93f61f2f55076de33b57cf/libs/common/src/key-management/master-password/types/master-password.types.ts#L15.

Ideally we would expose this from rust as we do for EncString

export type EncString = Tagged<string, "EncString">;
and replace the TS version with the SDK version so that we have type safety across the FFI boundary.

This one is optional, if we don't do it here it is tech debt we should record.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From what i understand, in cases EncString, we have an equivalent type in Rust SDK, that is different during Serialization: EncString becomed String type, hence why we export it in TS as string.

This is different here, since we don't have equivalent in our Rust code.
If we want one, then i think either of the two will be ok:

#[cfg_attr(feature = "wasm", tsify::declare(tsify::Tsify))]
pub type MasterPasswordSalt = String;

Or change the salt field to have different type in TS.

    #[cfg_attr(
        feature = "wasm",
        tsify(type = "MasterPasswordSalt")
    )]
    pub salt: String,

Copy link
Contributor

@quexten quexten Aug 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think my main concern here is that we want the tagged type, so that the typesafety stretches to TS.

A bit more verbose than I'd like, but this does the job, and is the same approach I use for the new PinProtectedKeyEnvelope (PasswordProtectedkeyEnvelope):

#[derive(Serialize, Deserialize)]
#[serde(transparent)]
#[cfg_attr(feature = "wasm", derive(Tsify), tsify(into_wasm_abi, from_wasm_abi))]
pub struct MasterPasswordSalt(
    #[cfg_attr(
        feature = "wasm",
        tsify(type = r#"Tagged<string, "MasterPasswordSalt">"#)
    )]
   String,
);

But again, we can do that later if we don't want to do in in this PR, I don't want to scope creep it.

}

impl MasterPasswordUnlockData {
pub fn process_response(
response: MasterPasswordUnlockResponseModel,
) -> Result<MasterPasswordUnlockData, MasterPasswordError> {
let kdf = match response.kdf.kdf_type {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you do apply the suggestion from above, then this would require mapping to the different error types

KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 {
iterations: NonZeroU32::new(response.kdf.iterations as u32)
.ok_or(MissingFieldError(stringify!(response.kdf.iterations)))?,
},
KdfType::Argon2id => Kdf::Argon2id {
iterations: NonZeroU32::new(response.kdf.iterations as u32)
.ok_or(MissingFieldError(stringify!(response.kdf.iterations)))?,
memory: NonZeroU32::new(require!(response.kdf.memory) as u32)
.ok_or(MissingFieldError(stringify!(response.kdf.memory)))?,
parallelism: NonZeroU32::new(require!(response.kdf.parallelism) as u32)
.ok_or(MissingFieldError(stringify!(response.kdf.parallelism)))?,
},
};

let master_key_encrypted_user_key = require!(response.master_key_encrypted_user_key);
let master_key_wrapped_user_key =
EncString::from_str(master_key_encrypted_user_key.as_str())
.map_err(|e: CryptoError| MasterPasswordError::from(e))?;

let salt = require!(response.salt);

Ok(MasterPasswordUnlockData {
kdf,
master_key_wrapped_user_key,
salt,
})
}
}

#[cfg(test)]
mod tests {
use bitwarden_api_api::models::{KdfType, MasterPasswordUnlockKdfResponseModel};

use super::*;

const TEST_USER_KEY: &str = "2.Q/2PhzcC7GdeiMHhWguYAQ==|GpqzVdr0go0ug5cZh1n+uixeBC3oC90CIe0hd/HWA/pTRDZ8ane4fmsEIcuc8eMKUt55Y2q/fbNzsYu41YTZzzsJUSeqVjT8/iTQtgnNdpo=|dwI+uyvZ1h/iZ03VQ+/wrGEFYVewBUUl/syYgjsNMbE=";
const TEST_INVALID_USER_KEY: &str = "-1.8UClLa8IPE1iZT7chy5wzQ==|6PVfHnVk5S3XqEtQemnM5yb4JodxmPkkWzmDRdfyHtjORmvxqlLX40tBJZ+CKxQWmS8tpEB5w39rbgHg/gqs0haGdZG4cPbywsgGzxZ7uNI=";
const TEST_SALT: &str = "[email protected]";

fn create_pbkdf2_response(
iterations: i32,
encrypted_user_key: Option<String>,
salt: Option<String>,
) -> MasterPasswordUnlockResponseModel {
MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::PBKDF2_SHA256,
iterations,
memory: None,
parallelism: None,
}),
master_key_encrypted_user_key: encrypted_user_key,
salt,
}
}

fn create_argon2id_response(
iterations: i32,
memory: Option<i32>,
parallelism: Option<i32>,
encrypted_user_key: Option<String>,
salt: Option<String>,
) -> MasterPasswordUnlockResponseModel {
MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations,
memory,
parallelism,
}),
master_key_encrypted_user_key: encrypted_user_key,
salt,
}
}

#[test]
fn test_process_response_pbkdf2_success() {
let response = create_pbkdf2_response(
600_000,
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response).unwrap();

match result.kdf {
Kdf::PBKDF2 { iterations } => {
assert_eq!(iterations.get(), 600_000);
}
_ => panic!("Expected PBKDF2 KDF"),
}

assert_eq!(result.salt, TEST_SALT);
assert_eq!(
result.master_key_wrapped_user_key.to_string(),
TEST_USER_KEY
);
}

#[test]
fn test_process_response_argon2id_success() {
let response = create_argon2id_response(
3,
Some(64),
Some(4),
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response).unwrap();

match result.kdf {
Kdf::Argon2id {
iterations,
memory,
parallelism,
} => {
assert_eq!(iterations.get(), 3);
assert_eq!(memory.get(), 64);
assert_eq!(parallelism.get(), 4);
}
_ => panic!("Expected Argon2id KDF"),
}

assert_eq!(result.salt, TEST_SALT);
assert_eq!(
result.master_key_wrapped_user_key.to_string(),
TEST_USER_KEY
);
}

#[test]
fn test_process_response_invalid_user_key_crypto_error() {
let response = create_pbkdf2_response(
600_000,
Some(TEST_INVALID_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(result, Err(MasterPasswordError::Crypto(_))));
}

#[test]
fn test_process_response_missing_encrypted_user_key() {
let response = create_pbkdf2_response(600_000, None, Some(TEST_SALT.to_string()));

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.master_key_encrypted_user_key"
)))
));
}

#[test]
fn test_process_response_missing_salt() {
let response = create_pbkdf2_response(600_000, Some(TEST_USER_KEY.to_string()), None);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.salt"
)))
));
}

#[test]
fn test_process_response_argon2id_missing_memory() {
let response = create_argon2id_response(
3,
None,
Some(4),
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.kdf.memory"
)))
));
}

#[test]
fn test_process_response_argon2id_missing_parallelism() {
let response = create_argon2id_response(
3,
Some(64),
None,
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.kdf.parallelism"
)))
));
}

#[test]
fn test_process_response_zero_iterations_pbkdf2() {
let response = create_pbkdf2_response(
0,
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.kdf.iterations"
)))
));
}

#[test]
fn test_process_response_zero_iterations_argon2id() {
let response = create_argon2id_response(
0,
Some(0),
Some(0),
Some(TEST_USER_KEY.to_string()),
Some(TEST_SALT.to_string()),
);

let result = MasterPasswordUnlockData::process_response(response);
assert!(matches!(
result,
Err(MasterPasswordError::MissingField(MissingFieldError(
"response.kdf.iterations"
)))
));
}

#[test]
fn test_serde_serialization_pbkdf2() {
let data = MasterPasswordUnlockData {
kdf: Kdf::PBKDF2 {
iterations: 600_000.try_into().unwrap(),
},
master_key_wrapped_user_key: TEST_USER_KEY.parse().unwrap(),
salt: TEST_SALT.to_string(),
};

let serialized = serde_json::to_string(&data).unwrap();
let deserialized: MasterPasswordUnlockData = serde_json::from_str(&serialized).unwrap();

match (data.kdf, deserialized.kdf) {
(Kdf::PBKDF2 { iterations: i1 }, Kdf::PBKDF2 { iterations: i2 }) => {
assert_eq!(i1, i2);
}
_ => panic!("KDF types don't match"),
}

assert_eq!(
data.master_key_wrapped_user_key.to_string(),
deserialized.master_key_wrapped_user_key.to_string()
);
assert_eq!(data.salt, deserialized.salt);
}

#[test]
fn test_serde_serialization_argon2id() {
let data = MasterPasswordUnlockData {
kdf: Kdf::Argon2id {
iterations: 3.try_into().unwrap(),
memory: 64.try_into().unwrap(),
parallelism: 4.try_into().unwrap(),
},
master_key_wrapped_user_key: TEST_USER_KEY.parse().unwrap(),
salt: TEST_SALT.to_string(),
};

let serialized = serde_json::to_string(&data).unwrap();
let deserialized: MasterPasswordUnlockData = serde_json::from_str(&serialized).unwrap();

match (data.kdf, deserialized.kdf) {
(
Kdf::Argon2id {
iterations: i1,
memory: m1,
parallelism: p1,
},
Kdf::Argon2id {
iterations: i2,
memory: m2,
parallelism: p2,
},
) => {
assert_eq!(i1, i2);
assert_eq!(m1, m2);
assert_eq!(p1, p2);
}
_ => panic!("KDF types don't match"),
}

assert_eq!(
data.master_key_wrapped_user_key.to_string(),
deserialized.master_key_wrapped_user_key.to_string()
);
assert_eq!(data.salt, deserialized.salt);
}
}
Loading
Loading