diff --git a/Cargo.lock b/Cargo.lock index e2eef8832..64d68e10b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -522,11 +522,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "bitwarden-api-key-connector" +version = "2.0.0" +dependencies = [ + "async-trait", + "mockall", + "reqwest", + "serde", + "serde_json", + "tokio", + "wiremock", +] + [[package]] name = "bitwarden-auth" version = "2.0.0" dependencies = [ "bitwarden-api-api", + "bitwarden-api-key-connector", "bitwarden-core", "bitwarden-crypto", "bitwarden-encoding", @@ -598,6 +612,7 @@ dependencies = [ "async-trait", "bitwarden-api-api", "bitwarden-api-identity", + "bitwarden-api-key-connector", "bitwarden-crypto", "bitwarden-encoding", "bitwarden-error", diff --git a/Cargo.toml b/Cargo.toml index 3d423fa13..03a8ed056 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,6 +22,7 @@ keywords = ["bitwarden"] bitwarden = { path = "crates/bitwarden", version = "=1.0.0" } bitwarden-api-api = { path = "crates/bitwarden-api-api", version = "=2.0.0" } bitwarden-api-identity = { path = "crates/bitwarden-api-identity", version = "=2.0.0" } +bitwarden-api-key-connector = { path = "crates/bitwarden-api-key-connector", version = "=2.0.0" } bitwarden-auth = { path = "crates/bitwarden-auth", version = "=2.0.0" } bitwarden-cli = { path = "crates/bitwarden-cli", version = "=2.0.0" } bitwarden-collections = { path = "crates/bitwarden-collections", version = "=2.0.0" } diff --git a/crates/bitwarden-api-key-connector/Cargo.toml b/crates/bitwarden-api-key-connector/Cargo.toml new file mode 100644 index 000000000..24f6a9917 --- /dev/null +++ b/crates/bitwarden-api-key-connector/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "bitwarden-api-key-connector" +description = "Api bindings for the Bitwarden Key Connector API." +categories = ["api-bindings"] + +version.workspace = true +authors.workspace = true +edition.workspace = true +rust-version.workspace = true +homepage.workspace = true +repository.workspace = true +license-file.workspace = true +keywords.workspace = true + +[dependencies] +async-trait = { workspace = true } +mockall = { version = ">=0.13.1, <0.15" } +reqwest = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } + +[dev-dependencies] +wiremock = { workspace = true } diff --git a/crates/bitwarden-api-key-connector/src/apis/configuration.rs b/crates/bitwarden-api-key-connector/src/apis/configuration.rs new file mode 100644 index 000000000..d0cf2aac1 --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/apis/configuration.rs @@ -0,0 +1,24 @@ +#[derive(Debug, Clone)] +pub struct Configuration { + pub base_path: String, + pub user_agent: Option, + pub client: reqwest::Client, + pub oauth_access_token: Option, +} + +impl Configuration { + pub fn new() -> Configuration { + Configuration::default() + } +} + +impl Default for Configuration { + fn default() -> Self { + Configuration { + base_path: "https://key-connector.bitwarden.com".to_owned(), + user_agent: Some("api/key-connector/rust".to_owned()), + client: reqwest::Client::new(), + oauth_access_token: None, + } + } +} diff --git a/crates/bitwarden-api-key-connector/src/apis/mod.rs b/crates/bitwarden-api-key-connector/src/apis/mod.rs new file mode 100644 index 000000000..6c7a3c3a7 --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/apis/mod.rs @@ -0,0 +1,75 @@ +use std::sync::Arc; + +use crate::apis::configuration::Configuration; + +pub mod configuration; +pub mod user_keys_api; + +#[derive(Debug, Clone)] +pub struct ResponseContent { + pub status: reqwest::StatusCode, + pub content: String, +} + +#[allow(missing_docs)] +#[derive(Debug)] +pub enum Error { + Reqwest(reqwest::Error), + Serde(serde_json::Error), + Io(std::io::Error), + ResponseError(ResponseContent), +} + +impl From for Error { + fn from(e: reqwest::Error) -> Self { + Error::Reqwest(e) + } +} + +impl From for Error { + fn from(e: serde_json::Error) -> Self { + Error::Serde(e) + } +} + +impl From for Error { + fn from(e: std::io::Error) -> Self { + Error::Io(e) + } +} + +pub enum ApiClient { + Real(ApiClientReal), + Mock(ApiClientMock), +} + +pub struct ApiClientReal { + user_keys_api: user_keys_api::UserKeysApiClient, +} + +pub struct ApiClientMock { + pub user_keys_api: user_keys_api::MockUserKeysApi, +} + +impl ApiClient { + pub fn new(configuration: &Arc) -> Self { + Self::Real(ApiClientReal { + user_keys_api: user_keys_api::UserKeysApiClient::new(configuration.clone()), + }) + } + + pub fn new_mocked(func: impl FnOnce(&mut ApiClientMock)) -> Self { + let mut mock = ApiClientMock { + user_keys_api: user_keys_api::MockUserKeysApi::new(), + }; + func(&mut mock); + Self::Mock(mock) + } + + pub fn user_keys_api(&self) -> &dyn user_keys_api::UserKeysApi { + match self { + ApiClient::Real(real) => &real.user_keys_api, + ApiClient::Mock(mock) => &mock.user_keys_api, + } + } +} diff --git a/crates/bitwarden-api-key-connector/src/apis/user_keys_api.rs b/crates/bitwarden-api-key-connector/src/apis/user_keys_api.rs new file mode 100644 index 000000000..b5387f924 --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/apis/user_keys_api.rs @@ -0,0 +1,196 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use configuration::Configuration; +use mockall::automock; +use reqwest::Method; +use serde::Serialize; + +use crate::{ + apis::{Error, configuration}, + models::{ + user_key_request_model::UserKeyKeyRequestModel, + user_key_response_model::UserKeyResponseModel, + }, +}; + +#[automock] +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait::async_trait)] +pub trait UserKeysApi: Send + Sync { + /// GET /user-keys + async fn get_user_key(&self) -> Result; + + /// POST /user-keys + async fn post_user_key(&self, request_model: UserKeyKeyRequestModel) -> Result<(), Error>; + + /// PUT /user-keys + async fn put_user_key(&self, request_model: UserKeyKeyRequestModel) -> Result<(), Error>; +} + +pub struct UserKeysApiClient { + configuration: Arc, +} + +impl UserKeysApiClient { + pub fn new(configuration: Arc) -> Self { + Self { configuration } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl UserKeysApi for UserKeysApiClient { + async fn get_user_key(&self) -> Result { + let response = request(&self.configuration, Method::GET, None::<()>).await?; + + let body = response.text().await?; + let response_model = serde_json::from_str::(&body)?; + Ok(response_model) + } + + async fn post_user_key(&self, request_model: UserKeyKeyRequestModel) -> Result<(), Error> { + request(&self.configuration, Method::POST, Some(request_model)).await?; + + Ok(()) + } + + async fn put_user_key(&self, request_model: UserKeyKeyRequestModel) -> Result<(), Error> { + request(&self.configuration, Method::PUT, Some(request_model)).await?; + + Ok(()) + } +} + +async fn request( + configuration: &Arc, + method: Method, + body: Option, +) -> Result { + let url = format!("{}/user-keys", configuration.base_path); + + let mut request = configuration + .client + .request(method, url) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::ACCEPT, "application/json"); + + if let Some(ref user_agent) = configuration.user_agent { + request = request.header(reqwest::header::USER_AGENT, user_agent.clone()); + } + if let Some(ref access_token) = configuration.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?; + + Ok(response.error_for_status()?) +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use wiremock::{ + Mock, MockServer, ResponseTemplate, + matchers::{header, method, path}, + }; + + use crate::{ + apis::{ + configuration::Configuration, + user_keys_api::{UserKeysApi, UserKeysApiClient}, + }, + models::user_key_request_model::UserKeyKeyRequestModel, + }; + + const ACCESS_TOKEN: &str = "test_access_token"; + const KEY_CONNECTOR_KEY: &str = "test_key_connector_key"; + + async fn setup_mock_server_with_auth() -> (MockServer, Configuration) { + let server = MockServer::start().await; + + let configuration = Configuration { + base_path: format!("http://{}", server.address()), + user_agent: Some("Bitwarden Rust-SDK [TEST]".to_string()), + client: reqwest::Client::new(), + oauth_access_token: Some(ACCESS_TOKEN.to_string()), + }; + + (server, configuration) + } + + #[tokio::test] + async fn test_get() { + let (server, configuration) = setup_mock_server_with_auth().await; + + 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": KEY_CONNECTOR_KEY.to_string() + }))) + .expect(1) + .mount(&server) + .await; + + let api_client = UserKeysApiClient::new(Arc::new(configuration)); + + let result = api_client.get_user_key().await; + + assert!(result.is_ok()); + assert_eq!(KEY_CONNECTOR_KEY, result.unwrap().key); + } + + #[tokio::test] + async fn test_post() { + let (server, configuration) = setup_mock_server_with_auth().await; + + 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 request_model = UserKeyKeyRequestModel { + key: KEY_CONNECTOR_KEY.to_string(), + }; + + let api_client = UserKeysApiClient::new(Arc::new(configuration)); + + let result = api_client.post_user_key(request_model).await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn test_put() { + let (server, configuration) = setup_mock_server_with_auth().await; + + 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 request_model = UserKeyKeyRequestModel { + key: KEY_CONNECTOR_KEY.to_string(), + }; + + let api_client = UserKeysApiClient::new(Arc::new(configuration)); + + let result = api_client.put_user_key(request_model).await; + + assert!(result.is_ok()); + } +} diff --git a/crates/bitwarden-api-key-connector/src/lib.rs b/crates/bitwarden-api-key-connector/src/lib.rs new file mode 100644 index 000000000..733dbfabd --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/lib.rs @@ -0,0 +1,4 @@ +//! Client for interacting with the Key Connector API. + +pub mod apis; +pub mod models; diff --git a/crates/bitwarden-api-key-connector/src/models/mod.rs b/crates/bitwarden-api-key-connector/src/models/mod.rs new file mode 100644 index 000000000..832270c9a --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/models/mod.rs @@ -0,0 +1,2 @@ +pub mod user_key_request_model; +pub mod user_key_response_model; diff --git a/crates/bitwarden-api-key-connector/src/models/user_key_request_model.rs b/crates/bitwarden-api-key-connector/src/models/user_key_request_model.rs new file mode 100644 index 000000000..b00ccb738 --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/models/user_key_request_model.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserKeyKeyRequestModel { + #[serde(rename = "key", alias = "Key")] + pub key: String, +} diff --git a/crates/bitwarden-api-key-connector/src/models/user_key_response_model.rs b/crates/bitwarden-api-key-connector/src/models/user_key_response_model.rs new file mode 100644 index 000000000..a2c4f5fb9 --- /dev/null +++ b/crates/bitwarden-api-key-connector/src/models/user_key_response_model.rs @@ -0,0 +1,7 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Default, Debug, PartialEq, Serialize, Deserialize)] +pub struct UserKeyResponseModel { + #[serde(rename = "key", alias = "Key")] + pub key: String, +} diff --git a/crates/bitwarden-auth/Cargo.toml b/crates/bitwarden-auth/Cargo.toml index acf4d9440..e7378d9dd 100644 --- a/crates/bitwarden-auth/Cargo.toml +++ b/crates/bitwarden-auth/Cargo.toml @@ -27,6 +27,7 @@ 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-api-key-connector = { workspace = true } bitwarden-core = { workspace = true, features = ["internal"] } bitwarden-crypto = { workspace = true } bitwarden-encoding = { workspace = true } diff --git a/crates/bitwarden-auth/src/registration.rs b/crates/bitwarden-auth/src/registration.rs index 71d7250fc..e87711a2b 100644 --- a/crates/bitwarden-auth/src/registration.rs +++ b/crates/bitwarden-auth/src/registration.rs @@ -7,15 +7,17 @@ use bitwarden_api_api::models::{ DeviceKeysRequestModel, KeysRequestModel, OrganizationUserResetPasswordEnrollmentRequestModel, + SetKeyConnectorKeyRequestModel, }; use bitwarden_core::{ Client, OrganizationId, UserId, key_management::account_cryptographic_state::WrappedAccountCryptographicState, }; +use bitwarden_crypto::EncString; use bitwarden_encoding::B64; use bitwarden_error::bitwarden_error; use thiserror::Error; -use tracing::info; +use tracing::{error, info}; #[cfg(feature = "wasm")] use wasm_bindgen::prelude::*; @@ -69,6 +71,28 @@ impl RegistrationClient { let api_client = &client.get_api_configurations().await.api_client; internal_post_keys_for_tde_registration(self, api_client, request).await } + + /// 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, + sso_org_identifier: String, + user_id: UserId, + ) -> Result { + let client = &self.client.internal; + let configuration = &client.get_api_configurations().await; + let key_connector_client = client.get_key_connector_client(key_connector_url); + + internal_post_keys_for_key_connector_registration( + self, + &configuration.api_client, + &key_connector_client, + sso_org_identifier, + user_id, + ) + .await + } } async fn internal_post_keys_for_tde_registration( @@ -195,10 +219,109 @@ pub struct TdeRegistrationResponse { pub user_key: B64, } +async fn internal_post_keys_for_key_connector_registration( + registration_client: &RegistrationClient, + api_client: &bitwarden_api_api::apis::ApiClient, + key_connector_api_client: &bitwarden_api_key_connector::apis::ApiClient, + sso_org_identifier: String, + 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(|_| RegistrationError::Crypto)?; + + info!("Posting key connector key to key connector server"); + let request = + bitwarden_api_key_connector::models::user_key_request_model::UserKeyKeyRequestModel { + key: registration_crypto_result + .key_connector_key + .to_base64() + .to_string(), + }; + (if key_connector_api_client + .user_keys_api() + .get_user_key() + .await + .is_ok() + { + info!("User's key connector key exists, updating"); + key_connector_api_client + .user_keys_api() + .put_user_key(request) + .await + } else { + info!("User's key connector key does not exist, creating"); + key_connector_api_client + .user_keys_api() + .post_user_key(request) + .await + }) + .map_err(|e| { + error!("Failed to post key connector key to key connector server: {e:?}"); + RegistrationError::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(sso_org_identifier.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:?}"); + RegistrationError::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 RegistrationError { + /// Key Connector API call failed. + #[error("Key Connector Api call failed")] + KeyConnectorApi, /// API call failed. #[error("Api call failed")] Api, @@ -220,6 +343,7 @@ mod tests { const TEST_USER_ID: &str = "060000fb-0922-4dd3-b170-6e15cb5df8c8"; const TEST_ORG_ID: &str = "1bc9ac1e-f5aa-45f2-94bf-b181009709b8"; const TEST_DEVICE_ID: &str = "test-device-id"; + const TEST_SSO_ORG_IDENTIFIER: &str = "test-org"; const TEST_ORG_PUBLIC_KEY: &[u8] = &[ 48, 130, 1, 34, 48, 13, 6, 9, 42, 134, 72, 134, 247, 13, 1, 1, 1, 5, 0, 3, 130, 1, 15, 0, @@ -498,4 +622,175 @@ mod tests { mock.devices_api.checkpoint(); } } + + #[tokio::test] + async fn test_post_keys_for_key_connector_registration_success() { + let client = Client::new(None); + let registration_client = RegistrationClient::new(client); + + let api_client = ApiClient::new_mocked(|mock| { + mock.accounts_key_management_api + .expect_post_set_key_connector_key() + .once() + .returning(move |_body| Ok(())); + }); + + let key_connector_api_client = + bitwarden_api_key_connector::apis::ApiClient::new_mocked(|mock| { + mock.user_keys_api + .expect_get_user_key() + .once() + .returning(move || { + Err(bitwarden_api_key_connector::apis::Error::ResponseError( + bitwarden_api_key_connector::apis::ResponseContent { + status: reqwest::StatusCode::NOT_FOUND, + content: "Not Found".to_string(), + }, + )) + }); + mock.user_keys_api + .expect_post_user_key() + .once() + .returning(move |_body| Ok(())); + }); + + let result = internal_post_keys_for_key_connector_registration( + ®istration_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + UserId::new(uuid::uuid!(TEST_USER_ID)), + ) + .await; + assert!(result.is_ok()); + + // Assert that the mock expectations were met + if let ApiClient::Mock(mut mock) = api_client { + mock.accounts_key_management_api.checkpoint(); + } + if let bitwarden_api_key_connector::apis::ApiClient::Mock(mut mock) = + key_connector_api_client + { + mock.user_keys_api.checkpoint(); + } + } + + #[tokio::test] + async fn test_post_keys_for_key_connector_registration_key_connector_api_failure() { + let client = Client::new(None); + let registration_client = RegistrationClient::new(client); + + let api_client = ApiClient::new_mocked(|mock| { + // Should not be called if Key Connector API fails + mock.accounts_key_management_api + .expect_post_set_key_connector_key() + .never(); + }); + + let key_connector_api_client = + bitwarden_api_key_connector::apis::ApiClient::new_mocked(|mock| { + mock.user_keys_api + .expect_get_user_key() + .once() + .returning(move || { + Err(bitwarden_api_key_connector::apis::Error::ResponseError( + bitwarden_api_key_connector::apis::ResponseContent { + status: reqwest::StatusCode::NOT_FOUND, + content: "Not Found".to_string(), + }, + )) + }); + mock.user_keys_api + .expect_post_user_key() + .once() + .returning(move |_body| { + Err(bitwarden_api_key_connector::apis::Error::Serde( + serde_json::Error::io(std::io::Error::other("API error")), + )) + }); + }); + + let result = internal_post_keys_for_key_connector_registration( + ®istration_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + UserId::new(uuid::uuid!(TEST_USER_ID)), + ) + .await; + + assert!(result.is_err()); + assert!(matches!( + result.unwrap_err(), + RegistrationError::KeyConnectorApi + )); + + // Assert that the mock expectations were met + if let ApiClient::Mock(mut mock) = api_client { + mock.accounts_key_management_api.checkpoint(); + } + if let bitwarden_api_key_connector::apis::ApiClient::Mock(mut mock) = + key_connector_api_client + { + mock.user_keys_api.checkpoint(); + } + } + + #[tokio::test] + async fn test_post_keys_for_key_connector_registration_api_failure() { + let client = Client::new(None); + let registration_client = RegistrationClient::new(client); + + let api_client = ApiClient::new_mocked(|mock| { + mock.accounts_key_management_api + .expect_post_set_key_connector_key() + .once() + .returning(move |_body| { + Err(bitwarden_api_api::apis::Error::Serde( + serde_json::Error::io(std::io::Error::other("API error")), + )) + }); + }); + + let key_connector_api_client = + bitwarden_api_key_connector::apis::ApiClient::new_mocked(|mock| { + mock.user_keys_api + .expect_get_user_key() + .once() + .returning(move || { + Err(bitwarden_api_key_connector::apis::Error::ResponseError( + bitwarden_api_key_connector::apis::ResponseContent { + status: reqwest::StatusCode::NOT_FOUND, + content: "Not Found".to_string(), + }, + )) + }); + mock.user_keys_api + .expect_post_user_key() + .once() + .returning(move |_body| Ok(())); + }); + + let result = internal_post_keys_for_key_connector_registration( + ®istration_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + UserId::new(uuid::uuid!(TEST_USER_ID)), + ) + .await; + + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), RegistrationError::Api)); + + // Assert that the mock expectations were met + if let ApiClient::Mock(mut mock) = api_client { + mock.accounts_key_management_api.checkpoint(); + } + if let bitwarden_api_key_connector::apis::ApiClient::Mock(mut mock) = + key_connector_api_client + { + mock.user_keys_api.checkpoint(); + } + } } diff --git a/crates/bitwarden-core/Cargo.toml b/crates/bitwarden-core/Cargo.toml index a3cdae74c..1b8b22784 100644 --- a/crates/bitwarden-core/Cargo.toml +++ b/crates/bitwarden-core/Cargo.toml @@ -41,6 +41,7 @@ wasm = [ async-trait = { workspace = true } bitwarden-api-api = { workspace = true } bitwarden-api-identity = { workspace = true } +bitwarden-api-key-connector = { workspace = true } bitwarden-crypto = { workspace = true } bitwarden-encoding = { workspace = true } bitwarden-error = { workspace = true } diff --git a/crates/bitwarden-core/src/client/internal.rs b/crates/bitwarden-core/src/client/internal.rs index 74fe8307d..38390b95e 100644 --- a/crates/bitwarden-core/src/client/internal.rs +++ b/crates/bitwarden-core/src/client/internal.rs @@ -78,6 +78,22 @@ impl ApiConfigurations { *self = ApiConfigurations::new(identity, api, self.device_type); } + + pub(crate) fn get_key_connector_client( + self: &Arc, + key_connector_url: String, + ) -> bitwarden_api_key_connector::apis::ApiClient { + let api = self.api_config.clone(); + + let key_connector = bitwarden_api_key_connector::apis::configuration::Configuration { + base_path: key_connector_url, + user_agent: api.user_agent, + client: api.client, + oauth_access_token: api.oauth_access_token, + }; + + bitwarden_api_key_connector::apis::ApiClient::new(&Arc::new(key_connector)) + } } /// Access and refresh tokens used for authentication and authorization. @@ -216,6 +232,16 @@ impl InternalClient { } } + pub fn get_key_connector_client( + &self, + key_connector_url: String, + ) -> bitwarden_api_key_connector::apis::ApiClient { + self.__api_configurations + .read() + .expect("RwLock is not poisoned") + .get_key_connector_client(key_connector_url) + } + #[allow(missing_docs)] pub async fn get_api_configurations(&self) -> Arc { // At the moment we ignore the error result from the token renewal, if it fails, diff --git a/crates/bitwarden-core/src/key_management/crypto.rs b/crates/bitwarden-core/src/key_management/crypto.rs index 8be76d1d1..badb9b61e 100644 --- a/crates/bitwarden-core/src/key_management/crypto.rs +++ b/crates/bitwarden-core/src/key_management/crypto.rs @@ -9,10 +9,11 @@ use std::collections::HashMap; use bitwarden_api_api::models::AccountKeysRequestModel; use bitwarden_crypto::{ AsymmetricCryptoKey, AsymmetricPublicCryptoKey, CoseSerializable, CryptoError, DeviceKey, - EncString, Kdf, KeyDecryptable, KeyEncryptable, MasterKey, Pkcs8PrivateKeyBytes, - PrimitiveEncryptable, RotateableKeySet, SignatureAlgorithm, SignedPublicKey, SigningKey, - SpkiPublicKeyBytes, SymmetricCryptoKey, TrustDeviceResponse, UnsignedSharedKey, UserKey, - dangerous_get_v2_rotated_account_keys, derive_symmetric_key_from_prf, + EncString, Kdf, KeyConnectorKey, KeyDecryptable, KeyEncryptable, MasterKey, + Pkcs8PrivateKeyBytes, PrimitiveEncryptable, RotateableKeySet, SignatureAlgorithm, + SignedPublicKey, SigningKey, SpkiPublicKeyBytes, SymmetricCryptoKey, TrustDeviceResponse, + UnsignedSharedKey, UserKey, dangerous_get_v2_rotated_account_keys, + derive_symmetric_key_from_prf, safe::{PasswordProtectedKeyEnvelope, PasswordProtectedKeyEnvelopeError}, }; use bitwarden_encoding::B64; @@ -865,7 +866,7 @@ pub struct MakeTdeRegistrationResponse { pub reset_password_key: UnsignedSharedKey, } -/// Errors that can occur when making keys for TDE registration. +/// Errors that can occur when making keys for TDE or Key Connector registration. #[bitwarden_error(flat)] #[derive(Debug, thiserror::Error)] pub enum MakeKeysError { @@ -918,6 +919,52 @@ pub(crate) fn make_user_tde_registration( }) } +/// 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, +} + +/// Create the data needed to register for Key Connector +pub(crate) fn make_user_key_connector_registration( + client: &Client, + user_id: UserId, +) -> Result { + let mut ctx = client.internal.get_key_store().context_mut(); + let (user_key_id, wrapped_state) = WrappedAccountCryptographicState::make(&mut ctx, user_id) + .map_err(MakeKeysError::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(MakeKeysError::Crypto)?; + + let cryptography_state_request_model = + wrapped_state + .to_request_model(&user_key_id, &mut ctx) + .map_err(MakeKeysError::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, + }) +} + #[cfg(test)] mod tests { use std::num::NonZeroU32; @@ -926,6 +973,7 @@ mod tests { use super::*; use crate::Client; + const TEST_VECTOR_USER_KEY_V2_B64: &str = "pQEEAlACHUUoybNAuJoZzqNMxz2bAzoAARFvBIQDBAUGIFggAvGl4ifaUAomQdCdUPpXLHtypiQxHjZwRHeI83caZM4B"; const TEST_VECTOR_PRIVATE_KEY_V2: &str = "7.g1gdowE6AAERbwMZARwEUAIdRSjJs0C4mhnOo0zHPZuhBVgYthGLGqVLPeidY8mNMxpLJn3fyeSxyaWsWQTR6pxmRV2DyGZXly/0l9KK+Rsfetl9wvYIz0O4/RW3R6wf7eGxo5XmicV3WnFsoAmIQObxkKWShxFyjzg+ocKItQDzG7Gp6+MW4biTrAlfK51ML/ZS+PCjLmgI1QQr4eMHjiwA2TBKtKkxfjoTJkMXECpRVLEXOo8/mbIGYkuabbSA7oU+TJ0yXlfKDtD25gnyO7tjW/0JMFUaoEKRJOuKoXTN4n/ks4Hbxk0X5/DzfG05rxWad2UNBjNg7ehW99WrQ+33ckdQFKMQOri/rt8JzzrF1k11/jMJ+Y2TADKNHr91NalnUX+yqZAAe3sRt5Pv5ZhLIwRMKQi/1NrLcsQPRuUnogVSPOoMnE/eD6F70iU60Z6pvm1iBw2IvELZcrs/oxpO2SeCue08fIZW/jNZokbLnm90tQ7QeZTUpiPALhUgfGOa3J9VOJ7jQGCqDjd9CzV2DCVfhKCapeTbldm+RwEWBz5VvorH5vMx1AzbPRJxdIQuxcg3NqRrXrYC7fyZljWaPB9qP1tztiPtd1PpGEgxLByIfR6fqyZMCvOBsWbd0H6NhF8mNVdDw60+skFRdbRBTSCjCtKZeLVuVFb8ioH45PR5oXjtx4atIDzu6DKm6TTMCbR6DjZuZZ8GbwHxuUD2mDD3pAFhaof9kR3lQdjy7Zb4EzUUYskQxzcLPcqzp9ZgB3Rg91SStBCCMhdQ6AnhTy+VTGt/mY5AbBXNRSL6fI0r+P9K8CcEI4bNZCDkwwQr5v4O4ykSUzIvmVU0zKzDngy9bteIZuhkvGUoZlQ9UATNGPhoLfqq2eSvqEXkCbxTVZ5D+Ww9pHmWeVcvoBhcl5MvicfeQt++dY3tPjIfZq87nlugG4HiNbcv9nbVpgwe3v8cFetWXQgnO4uhx8JHSwGoSuxHFZtl2sdahjTHavRHnYjSABEFrViUKgb12UDD5ow1GAL62wVdSJKRf9HlLbJhN3PBxuh5L/E0wy1wGA9ecXtw/R1ktvXZ7RklGAt1TmNzZv6vI2J/CMXvndOX9rEpjKMbwbIDAjQ9PxiWdcnmc5SowT9f6yfIjbjXnRMWWidPAua7sgrtej4HP4Qjz1fpgLMLCRyF97tbMTmsAI5Cuj98Buh9PwcdyXj5SbVuHdJS1ehv9b5SWPsD4pwOm3+otVNK6FTazhoUl47AZoAoQzXfsXxrzqYzvF0yJkCnk9S1dcij1L569gQ43CJO6o6jIZFJvA4EmZDl95ELu+BC+x37Ip8dq4JLPsANDVSqvXO9tfDUIXEx25AaOYhW2KAUoDve/fbsU8d0UZR1o/w+ZrOQwawCIPeVPtbh7KFRVQi/rPI+Abl6XR6qMJbKPegliYGUuGF2oEMEc6QLTsMRCEPuw0S3kxbNfVPqml8nGhB2r8zUHBY1diJEmipVghnwH74gIKnyJ2C9nKjV8noUfKzqyV8vxUX2G5yXgodx8Jn0cWs3XhWuApFla9z4R28W/4jA1jK2WQMlx+b6xKUWgRk8+fYsc0HSt2fDrQ9pLpnjb8ME59RCxSPV++PThpnR2JtastZBZur2hBIJsGILCAmufUU4VC4gBKPhNfu/OK4Ktgz+uQlUa9fEC/FnkpTRQPxHuQjSQSNrIIyW1bIRBtnwjvvvNoui9FZJ"; #[allow(unused)] @@ -1716,4 +1764,53 @@ mod tests { "decrypted admin reset key should match the user's encryption key" ); } + + #[tokio::test] + async fn test_make_user_key_connector_registration_success() { + let user_id = UserId::new_v4(); + let email = "test@bitwarden.com"; + let registration_client = Client::new(None); + + let make_keys_response = + make_user_key_connector_registration(®istration_client, user_id); + assert!(make_keys_response.is_ok()); + let make_keys_response = make_keys_response.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 + .clone(), + 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); + + let decrypted_user_key = make_keys_response + .key_connector_key + .decrypt_user_key(make_keys_response.key_connector_key_wrapped_user_key); + assert_eq!(retrieved_symmetric_key, decrypted_user_key.unwrap()); + } } diff --git a/crates/bitwarden-core/src/key_management/crypto_client.rs b/crates/bitwarden-core/src/key_management/crypto_client.rs index 8a221fa55..117cda32a 100644 --- a/crates/bitwarden-core/src/key_management/crypto_client.rs +++ b/crates/bitwarden-core/src/key_management/crypto_client.rs @@ -9,8 +9,9 @@ use wasm_bindgen::prelude::*; use super::crypto::{ DeriveKeyConnectorError, DeriveKeyConnectorRequest, EnrollAdminPasswordResetError, - MakeKeyPairResponse, VerifyAsymmetricKeysRequest, VerifyAsymmetricKeysResponse, - derive_key_connector, make_key_pair, verify_asymmetric_keys, + MakeKeyConnectorRegistrationResponse, MakeKeyPairResponse, VerifyAsymmetricKeysRequest, + VerifyAsymmetricKeysResponse, derive_key_connector, make_key_pair, + make_user_key_connector_registration, verify_asymmetric_keys, }; #[cfg(feature = "internal")] use crate::key_management::{ @@ -205,6 +206,16 @@ impl CryptoClient { ) -> Result { make_user_tde_registration(&self.client, user_id, org_public_key) } + + /// 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. + pub fn make_user_key_connector_registration( + &self, + user_id: UserId, + ) -> Result { + make_user_key_connector_registration(&self.client, user_id) + } } impl Client { 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;