From 149e84c02fc4c78d68e6c66e4ba49acaebe867ac Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Sat, 11 Jul 2026 15:49:36 +0200 Subject: [PATCH 1/2] Add Key Connector support for SSO users Adds the server-side endpoints the clients expect for Key Connector (set-key-connector-key, convert-to-key-connector, confirmation-details), a uses_key_connector flag on users plus migrations, and new KEY_CONNECTOR_* config options gated behind SSO_ENABLED. SSO logins now carry amr=external and the login response advertises the connector URL so the clients pick it up. The protocol was worked out from the GPL client code only, without looking at Bitwarden's key-connector repo. --- .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 1 + .../up.sql | 1 + .../down.sql | 1 + .../up.sql | 1 + src/api/core/key_connector.rs | 94 +++++++++++++++++++ src/api/core/mod.rs | 2 + src/api/identity.rs | 9 +- src/config.rs | 20 ++++ src/db/models/organization.rs | 8 +- src/db/models/user.rs | 9 +- src/db/schema.rs | 1 + src/sso.rs | 8 +- 14 files changed, 149 insertions(+), 8 deletions(-) create mode 100644 migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql create mode 100644 migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql create mode 100644 src/api/core/key_connector.rs diff --git a/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..4a25c4c0 --- /dev/null +++ b/migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..4a25c4c0 --- /dev/null +++ b/migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql new file mode 100644 index 00000000..2457cea0 --- /dev/null +++ b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN uses_key_connector; diff --git a/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql new file mode 100644 index 00000000..c63ee5fa --- /dev/null +++ b/migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN uses_key_connector BOOLEAN NOT NULL DEFAULT 0; diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs new file mode 100644 index 00000000..f7c60371 --- /dev/null +++ b/src/api/core/key_connector.rs @@ -0,0 +1,94 @@ +use rocket::Route; +use rocket::serde::json::Json; +use serde_json::Value; + +use crate::{ + CONFIG, + api::{EmptyResult, JsonResult}, + auth::Headers, + db::DbConn, +}; + +pub fn routes() -> Vec { + routes![post_set_key_connector_key, post_convert_to_key_connector, get_confirmation_details,] +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyPairData { + encrypted_private_key: String, + public_key: String, +} + +#[derive(Debug, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SetKeyConnectorKeyData { + key: String, + keys: KeyPairData, + kdf: i32, + kdf_iterations: i32, + kdf_memory: Option, + kdf_parallelism: Option, + #[allow(dead_code)] + org_identifier: String, +} + +// Called by the client to finish provisioning a new SSO user whose master key +// was just stored on the key connector. +#[post("/accounts/set-key-connector-key", data = "")] +async fn post_set_key_connector_key(data: Json, headers: Headers, conn: DbConn) -> EmptyResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + let data = data.into_inner(); + let mut user = headers.user; + + user.client_kdf_type = data.kdf; + user.client_kdf_iter = data.kdf_iterations; + user.client_kdf_memory = data.kdf_memory; + user.client_kdf_parallelism = data.kdf_parallelism; + + user.akey = data.key; + user.private_key = Some(data.keys.encrypted_private_key); + user.public_key = Some(data.keys.public_key); + + // Key connector users don't have a master password + user.password_hash = Vec::new(); + user.uses_key_connector = true; + + user.save(&conn).await +} + +// Migrates an existing password user to the key connector. The client has already +// uploaded the current master key to the connector at this point. +#[post("/accounts/convert-to-key-connector")] +async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + let mut user = headers.user; + user.password_hash = Vec::new(); + user.password_hint = None; + user.uses_key_connector = true; + + user.save(&conn).await +} + +#[get("/accounts/key-connector/confirmation-details/<_org_identifier>")] +fn get_confirmation_details(_org_identifier: &str, _headers: Headers) -> JsonResult { + if !CONFIG.key_connector_enabled() { + err!("Key Connector is not enabled on this server"); + } + + // SSO (and therefore the key connector) is global, so there is no real org to look up + Ok(Json(serde_json::json!({ + "OrganizationName": CONFIG.key_connector_org_name(), + "Object": "keyConnectorUserDecryptionOptionConfirmationDetails" + }))) +} + +pub fn key_connector_user_decryption_option() -> Value { + serde_json::json!({ "KeyConnectorUrl": CONFIG.key_connector_url() }) +} diff --git a/src/api/core/mod.rs b/src/api/core/mod.rs index 2ea8ab21..8d71d86b 100644 --- a/src/api/core/mod.rs +++ b/src/api/core/mod.rs @@ -1,4 +1,5 @@ pub mod accounts; +pub mod key_connector; pub mod two_factor; mod ciphers; @@ -39,6 +40,7 @@ pub fn routes() -> Vec { let mut routes = Vec::new(); routes.append(&mut accounts::routes()); + routes.append(&mut key_connector::routes()); routes.append(&mut ciphers::routes()); routes.append(&mut emergency_access::routes()); routes.append(&mut events::routes()); diff --git a/src/api/identity.rs b/src/api/identity.rs index 1597698f..c08a2b2a 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -514,7 +514,9 @@ async fn authenticated_response( let master_password_policy = master_password_policy(user, conn).await; - let has_master_password = !user.password_hash.is_empty(); + // Key connector users have no master password, the master key is stored on the connector + let uses_key_connector = CONFIG.key_connector_enabled() && user.uses_key_connector; + let has_master_password = !user.password_hash.is_empty() && !uses_key_connector; let master_password_unlock = if has_master_password { json!({ "Kdf": { @@ -572,6 +574,11 @@ async fn authenticated_response( result["Key"] = Value::String(user.akey.clone()); } + if uses_key_connector { + result["UserDecryptionOptions"]["KeyConnectorOption"] = + crate::api::core::key_connector::key_connector_user_decryption_option(); + } + if let Some(token) = twofactor_token { result["TwoFactorToken"] = Value::String(token); } diff --git a/src/config.rs b/src/config.rs index 49281b6c..8855210c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -799,6 +799,12 @@ make_config! { sso { /// Enabled sso_enabled: bool, true, def, false; + /// Key Connector enabled |> Store master keys on an external Key Connector (requires SSO) + key_connector_enabled: bool, true, def, false; + /// Key Connector URL |> Base URL of the Key Connector service, e.g. https://keyconnector.example.com + key_connector_url: String, true, def, String::new(); + /// Key Connector org name |> Name shown in the client's domain-confirmation dialog + key_connector_org_name: String, true, def, String::from("Key Connector"); /// Only SSO login |> Disable Email+Master Password login sso_only: bool, true, def, false; /// Allow email association |> Associate existing non-SSO user based on email @@ -1086,6 +1092,20 @@ fn validate_config(cfg: &ConfigItems, on_update: bool) -> Result<(), Error> { validate_sso_master_password_policy(cfg.sso_master_password_policy.as_ref())?; } + if cfg.key_connector_enabled { + if !cfg.sso_enabled { + err!("`KEY_CONNECTOR_ENABLED=true` requires `SSO_ENABLED=true`") + } + if cfg.sso_auth_only_not_session { + err!( + "Key Connector is incompatible with `SSO_AUTH_ONLY_NOT_SESSION=true` (the connector must validate Vaultwarden-issued access tokens)" + ) + } + if cfg.key_connector_url.is_empty() { + err!("`KEY_CONNECTOR_URL` must be set when Key Connector is enabled") + } + } + if cfg._enable_yubico { if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support") diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 72b1df0b..20792151 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -212,7 +212,7 @@ impl Organization { "usePolicies": true, "useScim": false, // Not supported (Not AGPLv3 Licensed) "useSso": false, // Not supported - "useKeyConnector": false, // Not supported + "useKeyConnector": CONFIG.key_connector_enabled(), "usePasswordManager": true, "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "selfHost": true, @@ -488,7 +488,7 @@ impl Membership { "useResetPassword": CONFIG.mail_enabled(), "ssoBound": false, // Not supported "useSso": false, // Not supported - "useKeyConnector": false, + "useKeyConnector": CONFIG.key_connector_enabled(), "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "usePasswordManager": true, "useCustomPermissions": true, @@ -503,8 +503,8 @@ impl Membership { "familySponsorshipFriendlyName": null, "familySponsorshipAvailable": false, "productTierType": 3, // Enterprise tier - "keyConnectorEnabled": false, - "keyConnectorUrl": null, + "keyConnectorEnabled": CONFIG.key_connector_enabled(), + "keyConnectorUrl": if CONFIG.key_connector_enabled() { Value::String(CONFIG.key_connector_url()) } else { Value::Null }, "familySponsorshipLastSyncDate": null, "familySponsorshipValidUntil": null, "familySponsorshipToDelete": null, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 24bee751..8ac2414b 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -69,6 +69,8 @@ pub struct User { pub avatar_color: Option, pub external_id: Option, // Todo: Needs to be removed in the future, this is not used anymore. + + pub uses_key_connector: bool, } #[derive(Identifiable, Queryable, Insertable)] @@ -154,6 +156,8 @@ impl User { avatar_color: None, external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. + + uses_key_connector: false, } } @@ -262,7 +266,8 @@ impl User { let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty(); // TODO: Might want to save the status field in the DB - let status = if self.password_hash.is_empty() { + // Key connector users have an empty password hash but are not invited + let status = if self.password_hash.is_empty() && !self.uses_key_connector { UserStatus::Invited } else { UserStatus::Enabled @@ -286,7 +291,7 @@ impl User { "providerOrganizations": [], "forcePasswordReset": false, "avatarColor": self.avatar_color, - "usesKeyConnector": false, + "usesKeyConnector": self.uses_key_connector, "creationDate": format_date(&self.created_at), "object": "profile", }) diff --git a/src/db/schema.rs b/src/db/schema.rs index af342186..c0d9c5fb 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -217,6 +217,7 @@ table! { api_key -> Nullable, avatar_color -> Nullable, external_id -> Nullable, + uses_key_connector -> Bool, } } diff --git a/src/sso.rs b/src/sso.rs index 01fbd906..56cc0e29 100644 --- a/src/sso.rs +++ b/src/sso.rs @@ -385,9 +385,15 @@ pub fn create_auth_tokens( fn create_auth_tokens_impl( device: &Device, refresh_token: Option, - access_claims: auth::LoginJwtClaims, + mut access_claims: auth::LoginJwtClaims, access_token: String, ) -> ApiResult { + // Mark the access token as externally authenticated (SSO). Bitwarden clients gate the + // Key Connector flow on `amr` containing "external" (TokenService.getIsExternal). + if !access_claims.amr.iter().any(|m| m == "external") { + access_claims.amr.push("external".to_owned()); + } + let (nbf, exp, token) = if let Some(rt) = refresh_token { match decode_token_claims("refresh_token", &rt) { Err(_) => { From 740531a9944c7fc5e76d98d431abb76d18acdf0c Mon Sep 17 00:00:00 2001 From: Luca Biemelt Date: Wed, 22 Jul 2026 10:41:49 +0200 Subject: [PATCH 2/2] Block owners and admins from enrolling in the Key Connector They administer the key connector itself and are expected to keep a master password, so refuse set-key-connector-key and convert-to-key-connector for anyone with an owner or admin membership, including pending invites. --- src/api/core/key_connector.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs index f7c60371..f079b392 100644 --- a/src/api/core/key_connector.rs +++ b/src/api/core/key_connector.rs @@ -6,7 +6,10 @@ use crate::{ CONFIG, api::{EmptyResult, JsonResult}, auth::Headers, - db::DbConn, + db::{ + DbConn, + models::{Membership, MembershipType, UserId}, + }, }; pub fn routes() -> Vec { @@ -33,6 +36,13 @@ pub struct SetKeyConnectorKeyData { org_identifier: String, } +async fn can_use_key_connector(user_uuid: &UserId, conn: &DbConn) -> EmptyResult { + if Membership::find_by_user(user_uuid, conn).await.iter().any(|m| m.atype >= MembershipType::Admin) { + err!("Owners and admins cannot use Key Connector and must keep a master password"); + } + Ok(()) +} + // Called by the client to finish provisioning a new SSO user whose master key // was just stored on the key connector. #[post("/accounts/set-key-connector-key", data = "")] @@ -44,6 +54,8 @@ async fn post_set_key_connector_key(data: Json, headers: let data = data.into_inner(); let mut user = headers.user; + can_use_key_connector(&user.uuid, &conn).await?; + user.client_kdf_type = data.kdf; user.client_kdf_iter = data.kdf_iterations; user.client_kdf_memory = data.kdf_memory; @@ -69,6 +81,9 @@ async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyR } let mut user = headers.user; + + can_use_key_connector(&user.uuid, &conn).await?; + user.password_hash = Vec::new(); user.password_hint = None; user.uses_key_connector = true;