Skip to content
Merged
Show file tree
Hide file tree
Changes from 41 commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
44773f7
Adds MasterPasswordUnlock into identity's response user decryption opโ€ฆ
mzieniukbw Aug 4, 2025
0712878
Adds MasterPasswordUnlock KDF change handling in sync
mzieniukbw Aug 4, 2025
8ca20a8
simplification
mzieniukbw Aug 5, 2025
28a8423
clippy fix
mzieniukbw Aug 5, 2025
b089ea0
formatting
mzieniukbw Aug 5, 2025
0d35393
no handling, just response parsing
mzieniukbw Aug 5, 2025
a08179d
test coverage
mzieniukbw Aug 5, 2025
b3647f2
wasm
mzieniukbw Aug 5, 2025
36d3136
wasm unit test coverage
mzieniukbw Aug 5, 2025
7c8664d
wasm unit test coverage
mzieniukbw Aug 5, 2025
9c7d50d
Added UserDecryption data, response model with handling
mzieniukbw Aug 5, 2025
70ad9d3
autogenerated wasm responses, UserDecryption struct, use of TryFrom
mzieniukbw Aug 7, 2025
110f4db
failing unit test
mzieniukbw Aug 7, 2025
ff86adf
lint
mzieniukbw Aug 7, 2025
fa253a3
lint
mzieniukbw Aug 7, 2025
4a95dc5
KdfType enum duplicate
mzieniukbw Aug 7, 2025
cd37e31
revert identity crate wasm, since there it's not used right now
mzieniukbw Aug 8, 2025
3bf880f
docs
mzieniukbw Aug 8, 2025
93a3d0c
formatting
mzieniukbw Aug 8, 2025
f0c4f2a
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Aug 13, 2025
3e6a46a
revert user decryption response parsing in wasm
mzieniukbw Aug 13, 2025
7c34725
fixed unit test
mzieniukbw Aug 13, 2025
042678c
revert wasm dependencies
mzieniukbw Aug 13, 2025
9676069
bring back wasm and uniffi bindings to MasterPasswordUnlockData, erroโ€ฆ
mzieniukbw Aug 13, 2025
7973f12
identity separate user decryption options response model
mzieniukbw Aug 15, 2025
5a4f314
review suggestions
mzieniukbw Aug 15, 2025
7e425ca
identity name prefix for UserDecryptionOptions
mzieniukbw Aug 15, 2025
c9aaa8c
IdentityUserDecryptionOptionsResponseModel mapping to UserDecryptionData
mzieniukbw Aug 15, 2025
2591773
clippy
mzieniukbw Aug 15, 2025
3212002
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Aug 19, 2025
77c2d68
missing kdf fields treated as missing field error
mzieniukbw Aug 19, 2025
a487fff
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Aug 19, 2025
ecfde52
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Aug 20, 2025
70dee0c
reducing the public scope
mzieniukbw Aug 25, 2025
ad732ce
UserDecryptionOptions rename
mzieniukbw Aug 25, 2025
5e6f1dd
UserDecryptionOptions documentation
mzieniukbw Aug 25, 2025
d11f653
simplify error with MasterPasswordError
mzieniukbw Aug 25, 2025
0e104bb
remove wasm and uniffi
mzieniukbw Aug 25, 2025
a9f46db
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Aug 25, 2025
3cef902
api crate private error
mzieniukbw Aug 25, 2025
ae10e35
public crate user decryption options
mzieniukbw Aug 25, 2025
6861006
change from unused_imports to dead_code
mzieniukbw Aug 27, 2025
6b98aaf
allow dead code on struct
mzieniukbw Sep 1, 2025
4a00571
Merge branch 'main' into km/pm-24051-add-master-password-unlock-decryโ€ฆ
mzieniukbw Sep 1, 2025
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ use bitwarden_api_identity::models::KdfType;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::auth::api::response::user_decryption_options_response::UserDecryptionOptionsResponseModel;

#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub(crate) struct IdentityTokenSuccessResponse {
pub access_token: String,
Expand Down Expand Up @@ -35,6 +37,9 @@ pub(crate) struct IdentityTokenSuccessResponse {
#[serde(rename = "keyConnectorUrl", alias = "KeyConnectorUrl")]
key_connector_url: Option<String>,

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

/// Stores unknown api response fields
extra: Option<HashMap<String, Value>>,
}
Expand All @@ -61,6 +66,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
1 change: 1 addition & 0 deletions crates/bitwarden-core/src/auth/api/response/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ mod identity_token_response;
mod identity_two_factor_response;
pub(crate) mod two_factor_provider_data;
mod two_factor_providers;
pub(crate) mod user_decryption_options_response;

pub(crate) use identity_payload_response::*;
pub(crate) use identity_refresh_response::*;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
use bitwarden_api_api::models::MasterPasswordUnlockResponseModel;
use serde::{Deserialize, Serialize};

/// Provides user decryption options used to unlock user's vault.
/// Currently, only master password unlock is supported.
#[derive(Serialize, Deserialize, Debug, PartialEq)]
pub(crate) struct UserDecryptionOptionsResponseModel {
/// Contains information needed to unlock user's vault with master password.
/// None when user have no master password.
#[serde(
rename = "masterPasswordUnlock",
skip_serializing_if = "Option::is_none"
)]
pub(crate) master_password_unlock: Option<MasterPasswordUnlockResponseModel>,
}
2 changes: 2 additions & 0 deletions crates/bitwarden-core/src/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ use crate::{NotAuthenticatedError, VaultLockedError, WrongPasswordError};
mod access_token;
// API is intentionally not visible outside of `auth` as these should be considered private.
mod api;
#[allow(unused_imports)]
pub(crate) use api::response::user_decryption_options_response;
#[allow(missing_docs)]
pub mod auth_client;
mod jwt_token;
Expand Down
308 changes: 308 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,308 @@
use std::num::NonZeroU32;

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

use crate::{require, MissingFieldError};

/// Error for master password related operations.
#[bitwarden_error(flat)]
#[derive(Debug, thiserror::Error)]
pub(crate) enum MasterPasswordError {
/// The wrapped encryption key could not be parsed because the encstring is malformed
#[error("Wrapped encryption key is malformed")]
EncryptionKeyMalformed,
/// The KDF data could not be parsed, because it has an invalid value
#[error("KDF is malformed")]
KdfMalformed,
/// The wrapped encryption key or salt fields are missing or KDF data is incomplete
#[error(transparent)]
MissingField(#[from] MissingFieldError),
}

/// Represents the data required to unlock with the master password.
#[derive(Serialize, Deserialize, Debug)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct MasterPasswordUnlockData {
/// The key derivation function used to derive the master key
kdf: Kdf,
/// The master key wrapped user key
master_key_wrapped_user_key: EncString,
/// The salt used in the KDF, typically the user's email
salt: String,
}

impl TryFrom<MasterPasswordUnlockResponseModel> for MasterPasswordUnlockData {
type Error = MasterPasswordError;

fn try_from(response: MasterPasswordUnlockResponseModel) -> Result<Self, Self::Error> {
let kdf = match response.kdf.kdf_type {
KdfType::PBKDF2_SHA256 => Kdf::PBKDF2 {
iterations: kdf_parse_nonzero_u32(response.kdf.iterations)?,
},
KdfType::Argon2id => Kdf::Argon2id {
iterations: kdf_parse_nonzero_u32(response.kdf.iterations)?,
memory: kdf_parse_nonzero_u32(require!(response.kdf.memory))?,
parallelism: kdf_parse_nonzero_u32(require!(response.kdf.parallelism))?,
},
};

let master_key_encrypted_user_key = require!(response.master_key_encrypted_user_key);
let salt = require!(response.salt);

Ok(MasterPasswordUnlockData {
kdf,
master_key_wrapped_user_key: master_key_encrypted_user_key
.parse()
.map_err(|_| MasterPasswordError::EncryptionKeyMalformed)?,
salt,
})
}
}

fn kdf_parse_nonzero_u32(value: impl TryInto<u32>) -> Result<NonZeroU32, MasterPasswordError> {
value
.try_into()
.ok()
.and_then(NonZeroU32::new)
.ok_or(MasterPasswordError::KdfMalformed)
}

#[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(
master_key_encrypted_user_key: Option<String>,
salt: Option<String>,
iterations: i32,
) -> MasterPasswordUnlockResponseModel {
MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::PBKDF2_SHA256,
iterations,
memory: None,
parallelism: None,
}),
master_key_encrypted_user_key,
salt,
}
}

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

let data = MasterPasswordUnlockData::try_from(response).unwrap();

if let Kdf::PBKDF2 { iterations } = data.kdf {
assert_eq!(iterations.get(), 600_000);
} else {
panic!("Expected PBKDF2 KDF")
}

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

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_success() {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 3,
memory: Some(64),
parallelism: Some(4),
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

let data = MasterPasswordUnlockData::try_from(response).unwrap();

if let Kdf::Argon2id {
iterations,
memory,
parallelism,
} = data.kdf
{
assert_eq!(iterations.get(), 3);
assert_eq!(memory.get(), 64);
assert_eq!(parallelism.get(), 4);
} else {
panic!("Expected Argon2id KDF")
}

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

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

let result = MasterPasswordUnlockData::try_from(response);
assert!(matches!(
result,
Err(MasterPasswordError::EncryptionKeyMalformed)
));
}

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

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

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

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

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_memory_none_missing_field_error(
) {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 3,
memory: None,
parallelism: Some(4),
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

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

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_memory_zero_kdf_malformed_error(
) {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 3,
memory: Some(0),
parallelism: Some(4),
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

let result = MasterPasswordUnlockData::try_from(response);
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
}

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_parallelism_none_missing_field_error(
) {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 3,
memory: Some(64),
parallelism: None,
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

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

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_parallelism_zero_kdf_malformed_error(
) {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 3,
memory: Some(64),
parallelism: Some(0),
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

let result = MasterPasswordUnlockData::try_from(response);
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
}

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

let result = MasterPasswordUnlockData::try_from(response);
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
}

#[test]
fn test_try_from_master_password_unlock_response_model_argon2id_kdf_iterations_zero_kdf_malformed_error(
) {
let response = MasterPasswordUnlockResponseModel {
kdf: Box::new(MasterPasswordUnlockKdfResponseModel {
kdf_type: KdfType::Argon2id,
iterations: 0,
memory: Some(64),
parallelism: Some(4),
}),
master_key_encrypted_user_key: Some(TEST_USER_KEY.to_string()),
salt: Some(TEST_SALT.to_string()),
};

let result = MasterPasswordUnlockData::try_from(response);
assert!(matches!(result, Err(MasterPasswordError::KdfMalformed)));
}
}
4 changes: 4 additions & 0 deletions crates/bitwarden-core/src/key_management/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ mod crypto_client;
#[cfg(feature = "internal")]
pub use crypto_client::CryptoClient;

#[cfg(feature = "internal")]
mod master_password;
#[cfg(feature = "internal")]
mod non_generic_wrappers;
#[allow(unused_imports)]
Expand All @@ -27,6 +29,8 @@ pub(crate) use non_generic_wrappers::*;
#[cfg(feature = "internal")]
mod security_state;
#[cfg(feature = "internal")]
mod user_decryption;
#[cfg(feature = "internal")]
pub use security_state::{SecurityState, SignedSecurityState};

use crate::OrganizationId;
Expand Down
Loading
Loading