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..f079b392 --- /dev/null +++ b/src/api/core/key_connector.rs @@ -0,0 +1,109 @@ +use rocket::Route; +use rocket::serde::json::Json; +use serde_json::Value; + +use crate::{ + CONFIG, + api::{EmptyResult, JsonResult}, + auth::Headers, + db::{ + DbConn, + models::{Membership, MembershipType, UserId}, + }, +}; + +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, +} + +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 = "")] +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; + + 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; + 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; + + can_use_key_connector(&user.uuid, &conn).await?; + + 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(_) => {