diff --git a/Cargo.lock b/Cargo.lock index 24f977de9..ec9fe94b9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -526,7 +526,10 @@ dependencies = [ name = "bitwarden-auth" version = "2.0.0" dependencies = [ + "bitwarden-api-api", "bitwarden-core", + "bitwarden-crypto", + "bitwarden-encoding", "bitwarden-error", "bitwarden-test", "chrono", @@ -535,6 +538,7 @@ dependencies = [ "serde_json", "thiserror 2.0.12", "tokio", + "tracing", "tsify", "uniffi", "wasm-bindgen", @@ -600,6 +604,7 @@ dependencies = [ "bitwarden-uuid", "chrono", "getrandom 0.2.16", + "mockall", "rand 0.8.5", "rand_chacha 0.3.1", "reqwest", @@ -619,6 +624,7 @@ dependencies = [ "uuid", "wasm-bindgen", "wasm-bindgen-futures", + "wiremock", "zeroize", "zxcvbn", ] diff --git a/crates/bitwarden-api-api/src/models/set_key_connector_key_request_model.rs b/crates/bitwarden-api-api/src/models/set_key_connector_key_request_model.rs index 8c6053143..ba0a8c8c6 100644 --- a/crates/bitwarden-api-api/src/models/set_key_connector_key_request_model.rs +++ b/crates/bitwarden-api-api/src/models/set_key_connector_key_request_model.rs @@ -14,14 +14,22 @@ use crate::models; #[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] pub struct SetKeyConnectorKeyRequestModel { - #[serde(rename = "key", alias = "Key")] - pub key: String, - #[serde(rename = "keys", alias = "Keys")] - pub keys: Box, - #[serde(rename = "kdf", alias = "Kdf")] - pub kdf: models::KdfType, - #[serde(rename = "kdfIterations", alias = "KdfIterations")] - pub kdf_iterations: i32, + #[serde(rename = "key", alias = "Key", skip_serializing_if = "Option::is_none")] + pub key: Option, + #[serde( + rename = "keys", + alias = "Keys", + skip_serializing_if = "Option::is_none" + )] + pub keys: Option>, + #[serde(rename = "kdf", alias = "Kdf", skip_serializing_if = "Option::is_none")] + pub kdf: Option, + #[serde( + rename = "kdfIterations", + alias = "KdfIterations", + skip_serializing_if = "Option::is_none" + )] + pub kdf_iterations: Option, #[serde( rename = "kdfMemory", alias = "KdfMemory", @@ -34,25 +42,33 @@ pub struct SetKeyConnectorKeyRequestModel { skip_serializing_if = "Option::is_none" )] pub kdf_parallelism: Option, + #[serde( + rename = "keyConnectorKeyWrappedUserKey", + alias = "KeyConnectorKeyWrappedUserKey", + skip_serializing_if = "Option::is_none" + )] + pub key_connector_key_wrapped_user_key: Option, + #[serde( + rename = "accountKeys", + alias = "AccountKeys", + skip_serializing_if = "Option::is_none" + )] + pub account_keys: Option>, #[serde(rename = "orgIdentifier", alias = "OrgIdentifier")] pub org_identifier: String, } impl SetKeyConnectorKeyRequestModel { - pub fn new( - key: String, - keys: models::KeysRequestModel, - kdf: models::KdfType, - kdf_iterations: i32, - org_identifier: String, - ) -> SetKeyConnectorKeyRequestModel { + pub fn new(org_identifier: String) -> SetKeyConnectorKeyRequestModel { SetKeyConnectorKeyRequestModel { - key, - keys: Box::new(keys), - kdf, - kdf_iterations, + key: None, + keys: None, + kdf: None, + kdf_iterations: None, kdf_memory: None, kdf_parallelism: None, + key_connector_key_wrapped_user_key: None, + account_keys: None, org_identifier, } } diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index d0b94c621..4f3022b74 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -25,12 +25,16 @@ uniffi = ["bitwarden-core/uniffi", "dep:uniffi"] # Uniffi bindings # Note: dependencies must be alphabetized to pass the cargo sort check in the CI pipeline. [dependencies] +bitwarden-api-api = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } +bitwarden-crypto = { workspace = true } +bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } chrono = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } tsify = { workspace = true, optional = true } uniffi = { workspace = true, optional = true } wasm-bindgen = { workspace = true, optional = true } diff --git a/crates/bitwarden-auth/src/registration.rs b/crates/bitwarden-auth/src/registration.rs index b1b7e9e76..97acfbcc3 100644 --- a/crates/bitwarden-auth/src/registration.rs +++ b/crates/bitwarden-auth/src/registration.rs @@ -5,7 +5,19 @@ //! authentication method such as SSO or master password, and a decryption method such as //! key-connector, TDE, or master password. -use bitwarden_core::Client; +use bitwarden_api_api::models::SetKeyConnectorKeyRequestModel; +use bitwarden_core::{ + Client, OrganizationId, UserId, + key_management::{ + AccountCryptographyMakeKeysError, KeyConnectorApiClient, + account_cryptographic_state::WrappedAccountCryptographicState, + }, +}; +use bitwarden_crypto::EncString; +use bitwarden_encoding::B64; +use bitwarden_error::bitwarden_error; +use thiserror::Error; +use tracing::{error, info}; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -33,4 +45,116 @@ impl RegistrationClient { let api_client = &client.get_api_configurations().await.api_client; // Do API request here. It will be authenticated using the client's tokens. } + + /// Initializes a new cryptographic state for a user and posts it to the server; enrolls the + /// user to key connector unlock. + pub async fn post_keys_for_key_connector_registration( + &self, + key_connector_url: String, + org_id: OrganizationId, + user_id: UserId, + ) -> Result { + let client = &self.client.internal; + let api_client = &client.get_api_configurations().await.api_client; + let key_connector_api_client = + KeyConnectorApiClient::new(client, key_connector_url.as_str()); + + internal_post_keys_for_key_connector_registration( + self, + api_client, + &key_connector_api_client, + org_id, + user_id, + ) + .await + } +} + +async fn internal_post_keys_for_key_connector_registration( + registration_client: &RegistrationClient, + api_client: &bitwarden_api_api::apis::ApiClient, + key_connector_api_client: &KeyConnectorApiClient, + org_id: OrganizationId, + user_id: UserId, +) -> Result { + // First call crypto API to get all keys + info!("Initializing account cryptography"); + let registration_crypto_result = registration_client + .client + .crypto() + .make_user_key_connector_registration(user_id) + .map_err(UserRegistrationError::AccountCryptographyMakeKeys)?; + + info!("Posting key connector key to key connector server"); + key_connector_api_client + .post_or_put_key_connector_key(®istration_crypto_result.key_connector_key) + .await + .map_err(|e| { + error!("Failed to post key connector key to key connector server: {e:?}"); + UserRegistrationError::KeyConnectorApi + })?; + + info!("Posting user account cryptographic state to server"); + let request = SetKeyConnectorKeyRequestModel { + key_connector_key_wrapped_user_key: Some( + registration_crypto_result + .key_connector_key_wrapped_user_key + .to_string(), + ), + account_keys: Some(Box::new(registration_crypto_result.account_keys_request)), + ..SetKeyConnectorKeyRequestModel::new(org_id.to_string()) + }; + api_client + .accounts_key_management_api() + .post_set_key_connector_key(Some(request)) + .await + .map_err(|e| { + error!("Failed to post account cryptographic state to server: {e:?}"); + UserRegistrationError::Api + })?; + + info!("User initialized!"); + // Note: This passing out of state and keys is temporary. Once SDK state management is more + // mature, the account cryptographic state and keys should be set directly here. + Ok(KeyConnectorRegistrationResult { + account_cryptographic_state: registration_crypto_result.account_cryptographic_state, + key_connector_key: registration_crypto_result.key_connector_key.to_base64(), + key_connector_key_wrapped_user_key: registration_crypto_result + .key_connector_key_wrapped_user_key, + user_key: registration_crypto_result.user_key.to_encoded().into(), + }) +} + +/// Result of Key Connector registration process. +#[cfg_attr( + feature = "wasm", + derive(tsify::Tsify), + tsify(into_wasm_abi, from_wasm_abi) +)] +#[cfg_attr(feature = "uniffi", derive(uniffi::Record))] +#[derive(serde::Serialize, serde::Deserialize, Clone, Debug)] +pub struct KeyConnectorRegistrationResult { + /// The account cryptographic state of the user. + pub account_cryptographic_state: WrappedAccountCryptographicState, + /// The key connector key used for unlocking. + pub key_connector_key: B64, + /// The encrypted user key, wrapped with the key connector key. + pub key_connector_key_wrapped_user_key: EncString, + /// The decrypted user key. This can be used to get the consuming client to an unlocked state. + pub user_key: B64, +} + +/// Errors that can occur during user registration. +#[derive(Debug, Error)] +#[bitwarden_error(flat)] +pub enum UserRegistrationError { + /// Key Connector API call failed. + #[error("Key Connector Api call failed")] + KeyConnectorApi, + /// API call failed. + #[error("Api call failed")] + Api, + /// Account cryptography initialization failed. + #[error(transparent)] + AccountCryptographyMakeKeys(#[from] AccountCryptographyMakeKeysError), } diff --git a/crates/bitwarden-core/Cargo.toml b/crates/bitwarden-core/Cargo.toml index a3cdae74c..b3ae95611 100644 --- a/crates/bitwarden-core/Cargo.toml +++ b/crates/bitwarden-core/Cargo.toml @@ -76,8 +76,10 @@ rustls = { version = "0.23.19", default-features = false } rustls-platform-verifier = "0.6.0" [dev-dependencies] -rand_chacha = "0.3.1" +mockall = { version = ">=0.13.1, <0.15" } +rand_chacha = ">=0.3.1, <0.4.0" tokio = { workspace = true, features = ["rt"] } +wiremock = { workspace = true } zeroize = { version = ">=1.7.0, <2.0", features = ["derive", "aarch64"] } [lints] diff --git a/crates/bitwarden-core/src/key_management/account_cryptographic_state.rs b/crates/bitwarden-core/src/key_management/account_cryptographic_state.rs index fd058d5aa..75455cca7 100644 --- a/crates/bitwarden-core/src/key_management/account_cryptographic_state.rs +++ b/crates/bitwarden-core/src/key_management/account_cryptographic_state.rs @@ -12,9 +12,8 @@ use std::sync::RwLock; use bitwarden_api_api::models::{AccountKeysRequestModel, SecurityStateModel}; use bitwarden_crypto::{ - AsymmetricPublicCryptoKey, CoseSerializable, CryptoError, EncString, KeyStore, KeyStoreContext, + CoseSerializable, CryptoError, EncString, KeyStore, KeyStoreContext, PublicKeyEncryptionAlgorithm, SignatureAlgorithm, SignedPublicKey, SymmetricKeyAlgorithm, - VerifyingKey, }; use bitwarden_encoding::B64; use bitwarden_error::bitwarden_error; @@ -100,44 +99,41 @@ impl WrappedAccountCryptographicState { /// user key required to unlock this state. pub fn to_request_model( &self, - store: &KeyStore, + user_key: &SymmetricKeyId, + ctx: &mut KeyStoreContext, ) -> Result { - let verifying_key = self.verifying_key(store)?; + let private_key = match self { + WrappedAccountCryptographicState::V1 { private_key } + | WrappedAccountCryptographicState::V2 { private_key, .. } => private_key.clone(), + }; + let private_key_tmp_id = ctx.unwrap_private_key(*user_key, &private_key)?; + let public_key = ctx.get_public_key(private_key_tmp_id)?; + + let signature_keypair = match self { + WrappedAccountCryptographicState::V1 { .. } => None, + WrappedAccountCryptographicState::V2 { signing_key, .. } => { + let signing_key_tmp_id = ctx.unwrap_signing_key(*user_key, signing_key)?; + let verifying_key = ctx.get_verifying_key(signing_key_tmp_id)?; + Some((signing_key.clone(), verifying_key)) + } + }; + Ok(AccountKeysRequestModel { // Note: This property is deprecated and should be removed after a transition period. - user_key_encrypted_account_private_key: match self { - WrappedAccountCryptographicState::V1 { private_key } - | WrappedAccountCryptographicState::V2 { private_key, .. } => { - Some(private_key.to_string()) - } - }, + user_key_encrypted_account_private_key: Some(private_key.to_string()), // Note: This property is deprecated and should be removed after a transition period. - account_public_key: match self.public_key(store)? { - Some(pk) => Some(B64::from(pk.to_der()?).to_string()), - None => None, - }, - signature_key_pair: match self { - WrappedAccountCryptographicState::V1 { .. } => None, - WrappedAccountCryptographicState::V2 { signing_key, .. } => Some(Box::new( - bitwarden_api_api::models::SignatureKeyPairRequestModel { + account_public_key: Some(B64::from(public_key.to_der()?).to_string()), + signature_key_pair: signature_keypair + .as_ref() + .map(|(signing_key, verifying_key)| { + Box::new(bitwarden_api_api::models::SignatureKeyPairRequestModel { wrapped_signing_key: Some(signing_key.to_string()), - verifying_key: Some( - B64::from( - verifying_key - .as_ref() - .map(|vk| vk.to_cose()) - .ok_or(AccountCryptographyInitializationError::CorruptData)?, - ) - .to_string(), - ), - signature_algorithm: verifying_key.as_ref().map(|vk| { - match vk.algorithm() { - SignatureAlgorithm::Ed25519 => "ed25519".to_string(), - } + verifying_key: Some(B64::from(verifying_key.to_cose()).to_string()), + signature_algorithm: Some(match verifying_key.algorithm() { + SignatureAlgorithm::Ed25519 => "ed25519".to_string(), }), - }, - )), - }, + }) + }), public_key_encryption_key_pair: Some(Box::new( bitwarden_api_api::models::PublicKeyEncryptionKeyPairRequestModel { wrapped_private_key: match self { @@ -146,29 +142,25 @@ impl WrappedAccountCryptographicState { Some(private_key.to_string()) } }, - public_key: match self.public_key(store) { - Ok(Some(pk)) => Some(B64::from(pk.to_der()?).to_string()), - _ => None, - }, + public_key: Some(B64::from(public_key.to_der()?).to_string()), signed_public_key: match self.signed_public_key() { Ok(Some(spk)) => Some(spk.clone().into()), _ => None, }, }, )), - security_state: match self { - WrappedAccountCryptographicState::V1 { .. } => None, - WrappedAccountCryptographicState::V2 { security_state, .. } => { - // ensure we have a verifying key reference and convert the verified state's - // version to i32 for the API model - let vk_ref = verifying_key - .as_ref() - .ok_or(AccountCryptographyInitializationError::CorruptData)?; + security_state: match (self, signature_keypair.as_ref()) { + (_, None) | (WrappedAccountCryptographicState::V1 { .. }, Some(_)) => None, + ( + WrappedAccountCryptographicState::V2 { security_state, .. }, + Some((_, verifying_key)), + ) => { + // Convert the verified state's version to i32 for the API model Some(Box::new(SecurityStateModel { security_state: Some(security_state.into()), security_version: security_state - .clone() - .verify_and_unwrap(vk_ref) + .to_owned() + .verify_and_unwrap(verifying_key) .map_err(|_| AccountCryptographyInitializationError::TamperedData)? .version() as i32, })) @@ -229,16 +221,11 @@ impl WrappedAccountCryptographicState { return Err(AccountCryptographyInitializationError::WrongUserKeyType); } - // Some users have unreadable V1 private keys. In this case, we set no keys to - // state. - if let Ok(private_key_id) = ctx.unwrap_private_key(user_key, private_key) { - ctx.persist_asymmetric_key(private_key_id, AsymmetricKeyId::UserPrivateKey)?; - } else { - tracing::warn!( - "V1 private key could not be unwrapped, skipping setting private key" - ); - } + let private_key_id = ctx + .unwrap_private_key(user_key, private_key) + .map_err(|_| AccountCryptographyInitializationError::WrongUserKey)?; + ctx.persist_asymmetric_key(private_key_id, AsymmetricKeyId::UserPrivateKey)?; ctx.persist_symmetric_key(user_key, SymmetricKeyId::User)?; } WrappedAccountCryptographicState::V2 { @@ -286,46 +273,6 @@ impl WrappedAccountCryptographicState { Ok(()) } - /// Retrieve the verifying key from the wrapped state, if present. This requires the user key to - /// be present in the store. - fn verifying_key( - &self, - store: &KeyStore, - ) -> Result, AccountCryptographyInitializationError> { - match self { - WrappedAccountCryptographicState::V1 { .. } => Ok(None), - WrappedAccountCryptographicState::V2 { signing_key, .. } => { - let mut ctx = store.context_mut(); - let signing_key = ctx - .unwrap_signing_key(SymmetricKeyId::User, signing_key) - .map_err(|_| AccountCryptographyInitializationError::WrongUserKey)?; - ctx.get_verifying_key(signing_key) - .map(Some) - .map_err(|e| e.into()) - } - } - } - - /// Retrieve the public key from the wrapped state, if present. This requires the user key to - /// be present in the store. - fn public_key( - &self, - store: &KeyStore, - ) -> Result, AccountCryptographyInitializationError> { - match self { - WrappedAccountCryptographicState::V1 { private_key } - | WrappedAccountCryptographicState::V2 { private_key, .. } => { - let mut ctx = store.context_mut(); - let private_key = ctx - .unwrap_private_key(SymmetricKeyId::User, private_key) - .map_err(|_| AccountCryptographyInitializationError::WrongUserKey)?; - ctx.get_public_key(private_key) - .map(Some) - .map_err(|e| e.into()) - } - } - } - /// Retrieve the signed public key from the wrapped state, if present. fn signed_public_key( &self, @@ -343,7 +290,7 @@ impl WrappedAccountCryptographicState { mod tests { use std::{str::FromStr, sync::RwLock}; - use bitwarden_crypto::{KeyStore, PrimitiveEncryptable}; + use bitwarden_crypto::KeyStore; use super::*; use crate::key_management::{AsymmetricKeyId, SigningKeyId, SymmetricKeyId}; @@ -472,9 +419,12 @@ mod tests { wrapped_account_cryptography_state .set_to_context(&RwLock::new(None), user_key, &temp_store, temp_ctx) .unwrap(); + + let mut ctx = temp_store.context_mut(); let model = wrapped_account_cryptography_state - .to_request_model(&temp_store) + .to_request_model(&SymmetricKeyId::User, &mut ctx) .expect("to_private_keys_request_model should succeed"); + drop(ctx); let ctx = temp_store.context(); @@ -517,47 +467,4 @@ mod tests { model.security_state.unwrap().security_version as u64 ); } - - #[test] - fn test_set_to_context_v1_corrupt_private_key() { - // Test that a V1 account with a corrupt private key (valid EncString but invalid key data) - // can still initialize, but skips setting the private key - let temp_store: KeyStore = KeyStore::default(); - let mut temp_ctx = temp_store.context_mut(); - - let user_key = temp_ctx.make_symmetric_key(SymmetricKeyAlgorithm::Aes256CbcHmac); - let corrupt_private_key = "not a private key" - .encrypt(&mut temp_ctx, user_key) - .unwrap(); - - // Construct the V1 wrapped state with corrupt private key - let wrapped = WrappedAccountCryptographicState::V1 { - private_key: corrupt_private_key, - }; - - #[expect(deprecated)] - let user_key_material = temp_ctx - .dangerous_get_symmetric_key(user_key) - .unwrap() - .to_owned(); - drop(temp_ctx); - drop(temp_store); - - // Now attempt to set this wrapped state into a fresh store - let store: KeyStore = KeyStore::default(); - let mut ctx = store.context_mut(); - let user_key = ctx.add_local_symmetric_key(user_key_material); - let security_state = RwLock::new(None); - - wrapped - .set_to_context(&security_state, user_key, &store, ctx) - .unwrap(); - - let ctx = store.context(); - - // The user symmetric key should be set - assert!(ctx.has_symmetric_key(SymmetricKeyId::User)); - // But the private key should NOT be set (due to corruption) - assert!(!ctx.has_asymmetric_key(AsymmetricKeyId::UserPrivateKey)); - } } diff --git a/crates/bitwarden-core/src/key_management/crypto_client.rs b/crates/bitwarden-core/src/key_management/crypto_client.rs index a2afc8e17..a3c2e79cd 100644 --- a/crates/bitwarden-core/src/key_management/crypto_client.rs +++ b/crates/bitwarden-core/src/key_management/crypto_client.rs @@ -1,9 +1,13 @@ +use bitwarden_api_api::models::AccountKeysRequestModel; #[cfg(feature = "wasm")] use bitwarden_crypto::safe::PasswordProtectedKeyEnvelope; -use bitwarden_crypto::{CryptoError, Decryptable, Kdf, RotateableKeySet}; +use bitwarden_crypto::{ + CryptoError, Decryptable, Kdf, KeyConnectorKey, RotateableKeySet, SymmetricCryptoKey, +}; #[cfg(feature = "internal")] use bitwarden_crypto::{EncString, UnsignedSharedKey}; use bitwarden_encoding::B64; +use bitwarden_error::bitwarden_error; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -15,6 +19,9 @@ use super::crypto::{ #[cfg(feature = "internal")] use crate::key_management::{ SymmetricKeyId, + account_cryptographic_state::{ + AccountCryptographyInitializationError, WrappedAccountCryptographicState, + }, crypto::{ DerivePinKeyResponse, InitOrgCryptoRequest, InitUserCryptoRequest, UpdatePasswordResponse, derive_pin_key, derive_pin_user_key, enroll_admin_password_reset, get_user_encryption_key, @@ -22,7 +29,7 @@ use crate::key_management::{ }, }; use crate::{ - Client, + Client, UserId, client::encryption_settings::EncryptionSettingsError, error::StatefulCryptoError, key_management::crypto::{ @@ -193,6 +200,69 @@ impl CryptoClient { ) -> Result { derive_key_connector(request) } + + /// Creates a new V2 account cryptographic state for Key Connector registration. + /// This generates fresh cryptographic keys (private key, signing key, signed public key, + /// and security state) wrapped with a new user key. + /// + /// Returns the wrapped account cryptographic state that can be used for registration, + /// key connector key wrapped user key, key connector key and decrypted user key. + pub fn make_user_key_connector_registration( + &self, + user_id: UserId, + ) -> Result { + let mut ctx = self.client.internal.get_key_store().context_mut(); + let (user_key_id, wrapped_state) = + WrappedAccountCryptographicState::make(&mut ctx, user_id) + .map_err(AccountCryptographyMakeKeysError::AccountCryptographyInitialization)?; + #[expect(deprecated)] + let user_key = ctx.dangerous_get_symmetric_key(user_key_id)?.to_owned(); + + // Key Connector unlock method + let key_connector_key = KeyConnectorKey::make(); + + let wrapped_user_key = key_connector_key + .encrypt_user_key(&user_key) + .map_err(AccountCryptographyMakeKeysError::Crypto)?; + + let cryptography_state_request_model = wrapped_state + .to_request_model(&user_key_id, &mut ctx) + .map_err(AccountCryptographyMakeKeysError::AccountCryptographyInitialization)?; + + Ok(MakeKeyConnectorRegistrationResponse { + account_cryptographic_state: wrapped_state, + key_connector_key_wrapped_user_key: wrapped_user_key, + user_key, + account_keys_request: cryptography_state_request_model, + key_connector_key, + }) + } +} + +/// The response from `make_user_key_connector_registration`. +pub struct MakeKeyConnectorRegistrationResponse { + /// The account cryptographic state + pub account_cryptographic_state: WrappedAccountCryptographicState, + /// Encrypted user's user key, wrapped with the key connector key + pub key_connector_key_wrapped_user_key: EncString, + /// The user's user key + pub user_key: SymmetricCryptoKey, + /// The request model for the account cryptographic state (also called Account Keys) + pub account_keys_request: AccountKeysRequestModel, + /// The key connector key used for unlocking + pub key_connector_key: KeyConnectorKey, +} + +/// Errors that can occur during account cryptography key generation. +#[bitwarden_error(flat)] +#[derive(Debug, thiserror::Error)] +pub enum AccountCryptographyMakeKeysError { + /// Failed to initialize account cryptography + #[error("Failed to initialize account cryptography: {0}")] + AccountCryptographyInitialization(#[from] AccountCryptographyInitializationError), + /// Generic crypto error + #[error("Cryptography error: {0}")] + Crypto(#[from] CryptoError), } impl Client { @@ -209,7 +279,10 @@ mod tests { use bitwarden_crypto::{BitwardenLegacyKeyBytes, SymmetricCryptoKey}; use super::*; - use crate::client::test_accounts::test_bitwarden_com_account; + use crate::{ + client::test_accounts::test_bitwarden_com_account, + key_management::crypto::InitUserCryptoMethod, + }; #[tokio::test] async fn test_enroll_pin_envelope() { @@ -239,4 +312,46 @@ mod tests { let user_key_final = SymmetricCryptoKey::try_from(&secret).unwrap(); assert_eq!(user_key_initial, user_key_final); } + + #[tokio::test] + async fn test_make_user_key_connector_registration_success() { + let user_id = UserId::new_v4(); + let email = "test@bitwarden.com"; + let client = Client::init_test_account(test_bitwarden_com_account()).await; + + let make_keys_response = client + .crypto() + .make_user_key_connector_registration(user_id) + .unwrap(); + + // Initialize a new client using the key connector key + let unlock_client = Client::new(None); + unlock_client + .crypto() + .initialize_user_crypto(InitUserCryptoRequest { + user_id: Some(user_id), + kdf_params: Kdf::default(), + email: email.to_owned(), + account_cryptographic_state: make_keys_response.account_cryptographic_state, + method: InitUserCryptoMethod::KeyConnector { + user_key: make_keys_response.key_connector_key_wrapped_user_key, + master_key: make_keys_response.key_connector_key.to_base64(), + }, + }) + .await + .expect("initializing user crypto with key connector key should succeed"); + + // Verify we can retrieve the user encryption key + let retrieved_key = unlock_client + .crypto() + .get_user_encryption_key() + .await + .expect("should be able to get user encryption key"); + + // The retrieved key should be a valid symmetric key + let retrieved_symmetric_key = SymmetricCryptoKey::try_from(retrieved_key) + .expect("retrieved key should be valid symmetric key"); + + assert_eq!(retrieved_symmetric_key, make_keys_response.user_key); + } } diff --git a/crates/bitwarden-core/src/key_management/key_connector.rs b/crates/bitwarden-core/src/key_management/key_connector.rs new file mode 100644 index 000000000..973761030 --- /dev/null +++ b/crates/bitwarden-core/src/key_management/key_connector.rs @@ -0,0 +1,355 @@ +use std::sync::Arc; + +use bitwarden_crypto::KeyConnectorKey; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::{ApiError, client::internal::InternalClient}; + +#[allow(missing_docs)] +#[derive(Debug, Error)] +pub enum KeyConnectorApiError { + #[error("Invalid Key Connector URL")] + InvalidKeyConnectorUrl, + #[error(transparent)] + Api(#[from] ApiError), +} + +/// Client for interacting with the Key Connector API. +pub struct KeyConnectorApiClient { + #[doc(hidden)] + internal_client: Arc, + /// The base URL of the Key Connector API. + key_connector_url: String, +} + +#[cfg_attr(test, mockall::automock)] +impl KeyConnectorApiClient { + /// Create a new Key Connector API client. + pub fn new(internal_client: &Arc, key_connector_url: &str) -> Self { + Self { + internal_client: Arc::clone(internal_client), + key_connector_url: key_connector_url.to_string(), + } + } + + /// Sends the key connector key to the Key Connector API. + /// If a key already exists, it will be updated; otherwise, a new key will be created for the + /// user. + pub async fn post_or_put_key_connector_key( + &self, + key_connector_key: &KeyConnectorKey, + ) -> Result<(), KeyConnectorApiError> { + let request = KeyConnectorKeyRequestModel { + key: key_connector_key.to_base64().to_string(), + }; + + if self.get_key_connector_user_keys().await.is_ok() { + self.put_key_connector_user_keys(request).await?; + } else { + self.post_key_connector_user_keys(request).await?; + } + + Ok(()) + } + + async fn get_key_connector_user_keys( + &self, + ) -> Result { + let response = request_key_connector_user_keys( + &self.internal_client, + Method::GET, + &self.key_connector_url, + None::<()>, + ) + .await?; + + let body = response.text().await.map_err(KeyConnectorApiError::from)?; + let response_model = serde_json::from_str::(&body)?; + Ok(response_model) + } + + async fn post_key_connector_user_keys( + &self, + request_model: KeyConnectorKeyRequestModel, + ) -> Result<(), KeyConnectorApiError> { + request_key_connector_user_keys( + &self.internal_client, + Method::POST, + &self.key_connector_url, + Some(request_model), + ) + .await?; + + Ok(()) + } + + async fn put_key_connector_user_keys( + &self, + request_model: KeyConnectorKeyRequestModel, + ) -> Result<(), KeyConnectorApiError> { + request_key_connector_user_keys( + &self.internal_client, + Method::PUT, + &self.key_connector_url, + Some(request_model), + ) + .await?; + + Ok(()) + } +} + +async fn request_key_connector_user_keys( + client: &InternalClient, + method: Method, + key_connector_url: &str, + body: Option, +) -> Result { + let url = format!("{}/user-keys", key_connector_url); + + let config = client.get_api_configurations().await; + let mut request = client + .get_http_client() + .request(method, url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ACCEPT, "application/json"); + + if let Some(ref user_agent) = config.api_config.user_agent { + request = request.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref access_token) = config.api_config.oauth_access_token { + request = request.bearer_auth(access_token.clone()); + } + if let Some(ref body) = body { + request = + request.body(serde_json::to_string(&body).expect("Serialize should be infallible")) + } + + let response = request.send().await.map_err(KeyConnectorApiError::from)?; + + Ok(response.error_for_status()?) +} + +impl From for KeyConnectorApiError { + fn from(e: reqwest::Error) -> Self { + KeyConnectorApiError::Api(ApiError::Reqwest(e)) + } +} + +impl From for KeyConnectorApiError { + fn from(e: serde_json::Error) -> Self { + KeyConnectorApiError::Api(ApiError::Serde(e)) + } +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +struct KeyConnectorKeyRequestModel { + #[serde(rename = "key", alias = "Key")] + pub key: String, +} + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +struct KeyConnectorKeyResponseModel { + #[serde(rename = "key", alias = "Key")] + pub key: String, +} + +#[cfg(test)] +mod tests { + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, + }; + + use super::*; + use crate::{Client, ClientSettings, DeviceType}; + + const ACCESS_TOKEN: &str = "test_access_token"; + + async fn setup_mock_server_with_auth() -> (MockServer, Client) { + let server = MockServer::start().await; + + let settings = ClientSettings { + identity_url: format!("http://{}/identity", server.address()), + api_url: format!("http://{}/api", server.address()), + user_agent: "Bitwarden Rust-SDK [TEST]".into(), + device_type: DeviceType::SDK, + bitwarden_client_version: None, + }; + + let client = Client::new(Some(settings)); + + // Set up authentication token + client + .internal + .set_tokens(ACCESS_TOKEN.to_string(), None, 3600); + + (server, client) + } + + #[tokio::test] + async fn test_post_when_key_doesnt_exist() { + let (server, client) = setup_mock_server_with_auth().await; + let key_connector_url = format!("http://{}", server.address()); + + // Mock GET to return 404 (key doesn't exist) + Mock::given(method("GET")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + // Mock POST to succeed + Mock::given(method("POST")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .and(header("content-type", "application/json")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let key = KeyConnectorKey::make(); + let key_connector_api = + KeyConnectorApiClient::new(&client.internal, key_connector_url.as_str()); + let result = key_connector_api.post_or_put_key_connector_key(&key).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_put_when_key_exists() { + let (server, client) = setup_mock_server_with_auth().await; + let key_connector_url = format!("http://{}", server.address()); + + // Mock GET to return 200 (key exists) + Mock::given(method("GET")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "key": "existing_key_connector_key" + }))) + .expect(1) + .mount(&server) + .await; + + // Mock PUT to succeed + Mock::given(method("PUT")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .and(header("content-type", "application/json")) + .respond_with(ResponseTemplate::new(200)) + .expect(1) + .mount(&server) + .await; + + let key = KeyConnectorKey::make(); + let key_connector_api = + KeyConnectorApiClient::new(&client.internal, key_connector_url.as_str()); + let result = key_connector_api.post_or_put_key_connector_key(&key).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_error_when_both_get_and_post_fail() { + let (server, client) = setup_mock_server_with_auth().await; + let key_connector_url = format!("http://{}", server.address()); + + // Mock GET to return 500 (server error) + Mock::given(method("GET")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + // Mock POST to also fail (since GET failed, POST will be attempted) + Mock::given(method("POST")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + let key = KeyConnectorKey::make(); + let key_connector_api = + KeyConnectorApiClient::new(&client.internal, key_connector_url.as_str()); + let result = key_connector_api.post_or_put_key_connector_key(&key).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeyConnectorApiError::Api(_))); + } + + #[tokio::test] + async fn test_error_when_post_fails() { + let (server, client) = setup_mock_server_with_auth().await; + let key_connector_url = format!("http://{}", server.address()); + + // Mock GET to return 404 (key doesn't exist) + Mock::given(method("GET")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(404)) + .expect(1) + .mount(&server) + .await; + + // Mock POST to fail with 500 + Mock::given(method("POST")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + let key = KeyConnectorKey::make(); + let key_connector_api = + KeyConnectorApiClient::new(&client.internal, key_connector_url.as_str()); + let result = key_connector_api.post_or_put_key_connector_key(&key).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeyConnectorApiError::Api(_))); + } + + #[tokio::test] + async fn test_error_when_put_fails() { + let (server, client) = setup_mock_server_with_auth().await; + let key_connector_url = format!("http://{}", server.address()); + + // Mock GET to return 200 (key exists) + Mock::given(method("GET")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "key": "existing_key_connector_key" + }))) + .expect(1) + .mount(&server) + .await; + + // Mock PUT to fail with 500 + Mock::given(method("PUT")) + .and(path("/user-keys")) + .and(header("authorization", format!("Bearer {ACCESS_TOKEN}"))) + .respond_with(ResponseTemplate::new(500)) + .expect(1) + .mount(&server) + .await; + + let key = KeyConnectorKey::make(); + let key_connector_api = + KeyConnectorApiClient::new(&client.internal, key_connector_url.as_str()); + let result = key_connector_api.post_or_put_key_connector_key(&key).await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), KeyConnectorApiError::Api(_))); + } +} diff --git a/crates/bitwarden-core/src/key_management/mod.rs b/crates/bitwarden-core/src/key_management/mod.rs index bd7c140a5..e783d3019 100644 --- a/crates/bitwarden-core/src/key_management/mod.rs +++ b/crates/bitwarden-core/src/key_management/mod.rs @@ -19,7 +19,7 @@ pub mod crypto; #[cfg(feature = "internal")] mod crypto_client; #[cfg(feature = "internal")] -pub use crypto_client::CryptoClient; +pub use crypto_client::{AccountCryptographyMakeKeysError, CryptoClient}; #[cfg(feature = "internal")] mod master_password; @@ -38,6 +38,13 @@ mod user_decryption; #[cfg(feature = "internal")] pub use user_decryption::UserDecryptionData; +#[cfg(feature = "internal")] +mod key_connector; +#[cfg(test)] +pub use key_connector::MockKeyConnectorApiClient; +#[cfg(feature = "internal")] +pub use key_connector::{KeyConnectorApiClient, KeyConnectorApiError}; + use crate::OrganizationId; key_ids! { diff --git a/crates/bitwarden-crypto/src/keys/key_connector_key.rs b/crates/bitwarden-crypto/src/keys/key_connector_key.rs new file mode 100644 index 000000000..1c75e00cf --- /dev/null +++ b/crates/bitwarden-crypto/src/keys/key_connector_key.rs @@ -0,0 +1,196 @@ +use std::pin::Pin; + +use bitwarden_encoding::B64; +use generic_array::GenericArray; +use rand::Rng; +use typenum::U32; + +use crate::{ + BitwardenLegacyKeyBytes, CryptoError, EncString, KeyDecryptable, SymmetricCryptoKey, + keys::utils::stretch_key, +}; + +/// Key connector key, used to protect the user key. +#[derive(Clone)] +pub struct KeyConnectorKey(pub(super) Pin>>); + +impl KeyConnectorKey { + /// Make a new random key for KeyConnector. + pub fn make() -> Self { + let mut rng = rand::thread_rng(); + let mut key = Box::pin(GenericArray::::default()); + + rng.fill(key.as_mut_slice()); + KeyConnectorKey(key) + } + + #[allow(missing_docs)] + pub fn to_base64(&self) -> B64 { + B64::from(self.0.as_slice()) + } + + /// Wraps the user key with this key connector key. + pub fn encrypt_user_key( + &self, + user_key: &SymmetricCryptoKey, + ) -> crate::error::Result { + let stretched_key = stretch_key(&self.0)?; + let user_key_bytes = user_key.to_encoded(); + EncString::encrypt_aes256_hmac(user_key_bytes.as_ref(), &stretched_key) + } + + /// Unwraps the user key with this key connector key. + pub fn decrypt_user_key( + &self, + user_key: EncString, + ) -> crate::error::Result { + let dec: Vec = match user_key { + // Legacy. user_keys were encrypted using `Aes256Cbc_B64` a long time ago. We've since + // moved to using `Aes256Cbc_HmacSha256_B64`. However, we still need to support + // decrypting these old keys. + EncString::Aes256Cbc_B64 { .. } => { + let legacy_key = SymmetricCryptoKey::Aes256CbcKey(super::Aes256CbcKey { + enc_key: Box::pin(GenericArray::clone_from_slice(&self.0)), + }); + user_key.decrypt_with_key(&legacy_key)? + } + EncString::Aes256Cbc_HmacSha256_B64 { .. } => { + let stretched_key = SymmetricCryptoKey::Aes256CbcHmacKey(stretch_key(&self.0)?); + user_key.decrypt_with_key(&stretched_key)? + } + _ => { + return Err(CryptoError::OperationNotSupported( + crate::error::UnsupportedOperationError::EncryptionNotImplementedForKey, + )); + } + }; + + SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(dec)) + } +} + +impl std::fmt::Debug for KeyConnectorKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("KeyConnectorKey").finish() + } +} + +#[cfg(test)] +mod tests { + use bitwarden_encoding::B64; + use coset::iana::KeyOperation; + use rand_chacha::rand_core::SeedableRng; + + use super::KeyConnectorKey; + use crate::{BitwardenLegacyKeyBytes, SymmetricCryptoKey, UserKey}; + + const KEY_CONNECTOR_KEY_BYTES: [u8; 32] = [ + 31, 79, 104, 226, 150, 71, 177, 90, 194, 80, 172, 209, 17, 129, 132, 81, 138, 167, 69, 167, + 254, 149, 2, 27, 39, 197, 64, 42, 22, 195, 86, 75, + ]; + + #[test] + fn test_make_two_different_keys() { + let key1 = KeyConnectorKey::make(); + let key2 = KeyConnectorKey::make(); + assert_ne!(key1.0.as_slice(), key2.0.as_slice()); + } + + #[test] + fn test_to_base64() { + let key = KeyConnectorKey(Box::pin(KEY_CONNECTOR_KEY_BYTES.into())); + + assert_eq!( + "H09o4pZHsVrCUKzREYGEUYqnRaf+lQIbJ8VAKhbDVks=", + key.to_base64().to_string() + ); + } + + #[test] + fn test_encrypt_decrypt_user_key_aes256_cbc_hmac() { + let rng = rand_chacha::ChaCha8Rng::from_seed([0u8; 32]); + + let key_connector_key = KeyConnectorKey(Box::pin(KEY_CONNECTOR_KEY_BYTES.into())); + + let user_key = SymmetricCryptoKey::make_aes256_cbc_hmac_key_internal(rng); + let wrapped_user_key = key_connector_key.encrypt_user_key(&user_key).unwrap(); + let user_key = UserKey::new(user_key); + + let decrypted_user_key = key_connector_key + .decrypt_user_key(wrapped_user_key) + .unwrap(); + + let SymmetricCryptoKey::Aes256CbcHmacKey(user_key_unwrapped) = &decrypted_user_key else { + panic!("User key is not an Aes256CbcHmacKey"); + }; + + assert_eq!( + user_key_unwrapped.enc_key.as_slice(), + [ + 62, 0, 239, 47, 137, 95, 64, 214, 127, 91, 184, 232, 31, 9, 165, 161, 44, 132, 14, + 195, 206, 154, 127, 59, 24, 27, 225, 136, 239, 113, 26, 30 + ] + ); + assert_eq!( + user_key_unwrapped.mac_key.as_slice(), + [ + 152, 76, 225, 114, 185, 33, 111, 65, 159, 68, 83, 103, 69, 109, 86, 25, 49, 74, 66, + 163, 218, 134, 176, 1, 56, 123, 253, 184, 14, 12, 254, 66 + ] + ); + + assert_eq!( + decrypted_user_key, user_key.0, + "Decrypted key doesn't match user key" + ); + } + + #[test] + fn test_encrypt_decrypt_user_key_xchacha20_poly1305() { + let key_connector_key = KeyConnectorKey(Box::pin(KEY_CONNECTOR_KEY_BYTES.into())); + + let user_key_b64: B64 = "pQEEAlDib+JxbqMBlcd3KTUesbufAzoAARFvBIQDBAUGIFggt79surJXmqhPhYuuqi9ZyPfieebmtw2OsmN5SDrb4yUB".parse() + .unwrap(); + let user_key = + SymmetricCryptoKey::try_from(&BitwardenLegacyKeyBytes::from(&user_key_b64)).unwrap(); + let wrapped_user_key = key_connector_key.encrypt_user_key(&user_key).unwrap(); + let user_key = UserKey::new(user_key); + + let decrypted_user_key = key_connector_key + .decrypt_user_key(wrapped_user_key) + .unwrap(); + + let SymmetricCryptoKey::XChaCha20Poly1305Key(user_key_unwrapped) = &decrypted_user_key + else { + panic!("User key is not an XChaCha20Poly1305Key"); + }; + + assert_eq!( + user_key_unwrapped.enc_key.as_slice(), + [ + 183, 191, 108, 186, 178, 87, 154, 168, 79, 133, 139, 174, 170, 47, 89, 200, 247, + 226, 121, 230, 230, 183, 13, 142, 178, 99, 121, 72, 58, 219, 227, 37 + ] + ); + assert_eq!( + user_key_unwrapped.key_id.as_slice(), + [ + 226, 111, 226, 113, 110, 163, 1, 149, 199, 119, 41, 53, 30, 177, 187, 159 + ] + ); + assert_eq!( + user_key_unwrapped.supported_operations, + [ + KeyOperation::Encrypt, + KeyOperation::Decrypt, + KeyOperation::WrapKey, + KeyOperation::UnwrapKey + ] + ); + + assert_eq!( + decrypted_user_key, user_key.0, + "Decrypted key doesn't match user key" + ); + } +} diff --git a/crates/bitwarden-crypto/src/keys/mod.rs b/crates/bitwarden-crypto/src/keys/mod.rs index 5fbe667dd..d071c9dab 100644 --- a/crates/bitwarden-crypto/src/keys/mod.rs +++ b/crates/bitwarden-crypto/src/keys/mod.rs @@ -39,3 +39,6 @@ mod rotateable_key_set; pub use rotateable_key_set::RotateableKeySet; pub(crate) mod utils; pub use prf::derive_symmetric_key_from_prf; + +mod key_connector_key; +pub use key_connector_key::KeyConnectorKey;