From 77030ad24c3c3313c5ab9fadc414d78ccc899067 Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 4 Mar 2026 13:30:22 +0100 Subject: [PATCH 1/2] Draft key connector api --- Cargo.lock | 1 + .../Cargo.toml | 1 + .../src/key_connector_migration.rs | 359 ++++++++++++++++++ .../src/lib.rs | 1 + 4 files changed, 362 insertions(+) create mode 100644 crates/bitwarden-user-crypto-management/src/key_connector_migration.rs diff --git a/Cargo.lock b/Cargo.lock index 53230ed19..a3cfe4adc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1075,6 +1075,7 @@ name = "bitwarden-user-crypto-management" version = "2.0.0" dependencies = [ "bitwarden-api-api", + "bitwarden-api-key-connector", "bitwarden-core", "bitwarden-crypto", "bitwarden-encoding", diff --git a/crates/bitwarden-user-crypto-management/Cargo.toml b/crates/bitwarden-user-crypto-management/Cargo.toml index 7b55e2609..b21c04d38 100644 --- a/crates/bitwarden-user-crypto-management/Cargo.toml +++ b/crates/bitwarden-user-crypto-management/Cargo.toml @@ -31,6 +31,7 @@ uniffi = [ # 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-user-crypto-management/src/key_connector_migration.rs b/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs new file mode 100644 index 000000000..775f59cb1 --- /dev/null +++ b/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs @@ -0,0 +1,359 @@ +//! Client operations for migrating an initialized account to Key Connector unlock. + +use bitwarden_api_api::models::SetKeyConnectorKeyRequestModel; +use bitwarden_core::key_management::SymmetricKeyId; +use bitwarden_crypto::KeyConnectorKey; +use bitwarden_encoding::B64; +use bitwarden_error::bitwarden_error; +use thiserror::Error; +use tracing::{error, info}; +#[cfg(feature = "wasm")] +use wasm_bindgen::prelude::*; + +use crate::UserCryptoManagementClient; + +#[cfg_attr(feature = "wasm", wasm_bindgen)] +impl UserCryptoManagementClient { + /// Migrates an initialized account to Key Connector unlock. + /// + /// Requires the client to be unlocked so the current user key is available in memory. + pub async fn migrate_to_key_connector( + &self, + key_connector_url: String, + sso_org_identifier: String, + ) -> Result<(), MigrateToKeyConnectorError> { + let internal = &self.client.internal; + let api_configuration = internal.get_api_configurations(); + let key_connector_api_client = internal.get_key_connector_client(key_connector_url); + + internal_migrate_to_key_connector( + self, + &api_configuration.api_client, + &key_connector_api_client, + sso_org_identifier, + ) + .await + } +} + +async fn internal_migrate_to_key_connector( + user_crypto_management_client: &UserCryptoManagementClient, + api_client: &bitwarden_api_api::apis::ApiClient, + key_connector_api_client: &bitwarden_api_key_connector::apis::ApiClient, + sso_org_identifier: String, +) -> Result<(), MigrateToKeyConnectorError> { + let (wrapped_user_key, key_connector_key): (String, B64) = { + let key_store = user_crypto_management_client + .client + .internal + .get_key_store(); + let ctx = key_store.context(); + + #[allow(deprecated)] + let user_key = ctx + .dangerous_get_symmetric_key(SymmetricKeyId::User) + .map_err(|_| MigrateToKeyConnectorError::UserKeyNotAvailable)?; + + let key_connector_key = KeyConnectorKey::make(); + let wrapped_user_key = key_connector_key + .encrypt_user_key(user_key) + .map_err(|_| MigrateToKeyConnectorError::CryptoError)? + .to_string(); + + (wrapped_user_key, key_connector_key.into()) + }; + + info!("Posting key connector key to key connector server"); + post_key_to_key_connector(key_connector_api_client, &key_connector_key).await?; + + info!("Posting wrapped user key for key connector migration"); + let request = SetKeyConnectorKeyRequestModel { + key_connector_key_wrapped_user_key: Some(wrapped_user_key), + ..SetKeyConnectorKeyRequestModel::new(sso_org_identifier) + }; + + api_client + .accounts_key_management_api() + .post_set_key_connector_key(Some(request)) + .await + .map_err(|e| { + error!("Failed to post key connector migration request: {e:?}"); + MigrateToKeyConnectorError::ApiError + })?; + + info!("Successfully migrated account to key connector unlock"); + Ok(()) +} + +async fn post_key_to_key_connector( + key_connector_api_client: &bitwarden_api_key_connector::apis::ApiClient, + key_connector_key: &B64, +) -> Result<(), MigrateToKeyConnectorError> { + let request = + bitwarden_api_key_connector::models::user_key_request_model::UserKeyKeyRequestModel { + key: key_connector_key.to_string(), + }; + + let result = 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 + }; + + result.map_err(|e| { + error!("Failed to post key connector key to key connector server: {e:?}"); + MigrateToKeyConnectorError::KeyConnectorApiError + }) +} + +#[derive(Debug, Error)] +#[bitwarden_error(flat)] +pub enum MigrateToKeyConnectorError { + #[error("Current user key is not available")] + UserKeyNotAvailable, + #[error("Cryptographic error during key connector migration")] + CryptoError, + #[error("Bitwarden API call failed during key connector migration")] + ApiError, + #[error("Key Connector API call failed during key connector migration")] + KeyConnectorApiError, +} + +#[cfg(test)] +mod tests { + use bitwarden_api_api::apis::ApiClient; + use bitwarden_core::Client; + + use super::*; + + const TEST_SSO_ORG_IDENTIFIER: &str = "test-org"; + + fn unlocked_client() -> UserCryptoManagementClient { + let client = Client::new(None); + { + let key_store = client.internal.get_key_store(); + let mut ctx = key_store.context_mut(); + let local_user_key = + ctx.make_symmetric_key(bitwarden_crypto::SymmetricKeyAlgorithm::Aes256CbcHmac); + assert!( + ctx.persist_symmetric_key(local_user_key, SymmetricKeyId::User) + .is_ok() + ); + } + + UserCryptoManagementClient::new(client) + } + + #[tokio::test] + async fn test_migrate_to_key_connector_success() { + let user_crypto_management_client = unlocked_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_migrate_to_key_connector( + &user_crypto_management_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + ) + .await; + + assert!(result.is_ok()); + + 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_migrate_to_key_connector_key_connector_api_failure() { + let user_crypto_management_client = unlocked_client(); + + let api_client = ApiClient::new_mocked(|mock| { + 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_migrate_to_key_connector( + &user_crypto_management_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + ) + .await; + + assert!(matches!( + result, + Err(MigrateToKeyConnectorError::KeyConnectorApiError) + )); + + 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_migrate_to_key_connector_api_failure() { + let user_crypto_management_client = unlocked_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_migrate_to_key_connector( + &user_crypto_management_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + ) + .await; + + assert!(matches!(result, Err(MigrateToKeyConnectorError::ApiError))); + + 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_migrate_to_key_connector_user_key_not_available() { + let user_crypto_management_client = UserCryptoManagementClient::new(Client::new(None)); + + let api_client = ApiClient::new_mocked(|mock| { + 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().never(); + mock.user_keys_api.expect_post_user_key().never(); + mock.user_keys_api.expect_put_user_key().never(); + }); + + let result = internal_migrate_to_key_connector( + &user_crypto_management_client, + &api_client, + &key_connector_api_client, + TEST_SSO_ORG_IDENTIFIER.to_string(), + ) + .await; + + assert!(matches!( + result, + Err(MigrateToKeyConnectorError::UserKeyNotAvailable) + )); + + 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-user-crypto-management/src/lib.rs b/crates/bitwarden-user-crypto-management/src/lib.rs index 6f73a5912..5bc582af1 100644 --- a/crates/bitwarden-user-crypto-management/src/lib.rs +++ b/crates/bitwarden-user-crypto-management/src/lib.rs @@ -4,6 +4,7 @@ #[cfg(feature = "uniffi")] uniffi::setup_scaffolding!(); +mod key_connector_migration; mod key_rotation; mod user_crypto_management_client; pub use user_crypto_management_client::{ From 01f6983f8b6ea2f153d25341eb7fd5997b645d5d Mon Sep 17 00:00:00 2001 From: Bernd Schoolmann Date: Wed, 4 Mar 2026 13:47:51 +0100 Subject: [PATCH 2/2] Cleanup --- .../src/key_connector_migration.rs | 24 ++++--------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs b/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs index 775f59cb1..6ba81ccde 100644 --- a/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs +++ b/crates/bitwarden-user-crypto-management/src/key_connector_migration.rs @@ -1,8 +1,7 @@ //! Client operations for migrating an initialized account to Key Connector unlock. -use bitwarden_api_api::models::SetKeyConnectorKeyRequestModel; use bitwarden_core::key_management::SymmetricKeyId; -use bitwarden_crypto::KeyConnectorKey; +use bitwarden_crypto::{EncString, KeyConnectorKey}; use bitwarden_encoding::B64; use bitwarden_error::bitwarden_error; use thiserror::Error; @@ -20,7 +19,6 @@ impl UserCryptoManagementClient { pub async fn migrate_to_key_connector( &self, key_connector_url: String, - sso_org_identifier: String, ) -> Result<(), MigrateToKeyConnectorError> { let internal = &self.client.internal; let api_configuration = internal.get_api_configurations(); @@ -30,7 +28,6 @@ impl UserCryptoManagementClient { self, &api_configuration.api_client, &key_connector_api_client, - sso_org_identifier, ) .await } @@ -40,9 +37,8 @@ async fn internal_migrate_to_key_connector( user_crypto_management_client: &UserCryptoManagementClient, api_client: &bitwarden_api_api::apis::ApiClient, key_connector_api_client: &bitwarden_api_key_connector::apis::ApiClient, - sso_org_identifier: String, ) -> Result<(), MigrateToKeyConnectorError> { - let (wrapped_user_key, key_connector_key): (String, B64) = { + let (_wrapped_user_key, key_connector_key): (EncString, B64) = { let key_store = user_crypto_management_client .client .internal @@ -57,8 +53,7 @@ async fn internal_migrate_to_key_connector( let key_connector_key = KeyConnectorKey::make(); let wrapped_user_key = key_connector_key .encrypt_user_key(user_key) - .map_err(|_| MigrateToKeyConnectorError::CryptoError)? - .to_string(); + .map_err(|_| MigrateToKeyConnectorError::CryptoError)?; (wrapped_user_key, key_connector_key.into()) }; @@ -67,14 +62,9 @@ async fn internal_migrate_to_key_connector( post_key_to_key_connector(key_connector_api_client, &key_connector_key).await?; info!("Posting wrapped user key for key connector migration"); - let request = SetKeyConnectorKeyRequestModel { - key_connector_key_wrapped_user_key: Some(wrapped_user_key), - ..SetKeyConnectorKeyRequestModel::new(sso_org_identifier) - }; - api_client .accounts_key_management_api() - .post_set_key_connector_key(Some(request)) + .post_convert_to_key_connector() .await .map_err(|e| { error!("Failed to post key connector migration request: {e:?}"); @@ -139,8 +129,6 @@ mod tests { use super::*; - const TEST_SSO_ORG_IDENTIFIER: &str = "test-org"; - fn unlocked_client() -> UserCryptoManagementClient { let client = Client::new(None); { @@ -191,7 +179,6 @@ mod tests { &user_crypto_management_client, &api_client, &key_connector_api_client, - TEST_SSO_ORG_IDENTIFIER.to_string(), ) .await; @@ -244,7 +231,6 @@ mod tests { &user_crypto_management_client, &api_client, &key_connector_api_client, - TEST_SSO_ORG_IDENTIFIER.to_string(), ) .await; @@ -301,7 +287,6 @@ mod tests { &user_crypto_management_client, &api_client, &key_connector_api_client, - TEST_SSO_ORG_IDENTIFIER.to_string(), ) .await; @@ -338,7 +323,6 @@ mod tests { &user_crypto_management_client, &api_client, &key_connector_api_client, - TEST_SSO_ORG_IDENTIFIER.to_string(), ) .await;