acul021 12 hours ago
committed by GitHub
parent
commit
51c6334cf7
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 1
      migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql
  2. 1
      migrations/mysql/2026-06-26-000000_add_uses_key_connector/up.sql
  3. 1
      migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql
  4. 1
      migrations/postgresql/2026-06-26-000000_add_uses_key_connector/up.sql
  5. 1
      migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql
  6. 1
      migrations/sqlite/2026-06-26-000000_add_uses_key_connector/up.sql
  7. 109
      src/api/core/key_connector.rs
  8. 2
      src/api/core/mod.rs
  9. 9
      src/api/identity.rs
  10. 20
      src/config.rs
  11. 8
      src/db/models/organization.rs
  12. 9
      src/db/models/user.rs
  13. 1
      src/db/schema.rs
  14. 8
      src/sso.rs

1
migrations/mysql/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

1
migrations/postgresql/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

1
migrations/sqlite/2026-06-26-000000_add_uses_key_connector/down.sql

@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN uses_key_connector;

1
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;

109
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<Route> {
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<i32>,
kdf_parallelism: Option<i32>,
#[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 = "<data>")]
async fn post_set_key_connector_key(data: Json<SetKeyConnectorKeyData>, 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() })
}

2
src/api/core/mod.rs

@ -1,4 +1,5 @@
pub mod accounts; pub mod accounts;
pub mod key_connector;
pub mod two_factor; pub mod two_factor;
mod ciphers; mod ciphers;
@ -39,6 +40,7 @@ pub fn routes() -> Vec<Route> {
let mut routes = Vec::new(); let mut routes = Vec::new();
routes.append(&mut accounts::routes()); routes.append(&mut accounts::routes());
routes.append(&mut key_connector::routes());
routes.append(&mut ciphers::routes()); routes.append(&mut ciphers::routes());
routes.append(&mut emergency_access::routes()); routes.append(&mut emergency_access::routes());
routes.append(&mut events::routes()); routes.append(&mut events::routes());

9
src/api/identity.rs

@ -514,7 +514,9 @@ async fn authenticated_response(
let master_password_policy = master_password_policy(user, conn).await; 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 { let master_password_unlock = if has_master_password {
json!({ json!({
"Kdf": { "Kdf": {
@ -572,6 +574,11 @@ async fn authenticated_response(
result["Key"] = Value::String(user.akey.clone()); 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 { if let Some(token) = twofactor_token {
result["TwoFactorToken"] = Value::String(token); result["TwoFactorToken"] = Value::String(token);
} }

20
src/config.rs

@ -799,6 +799,12 @@ make_config! {
sso { sso {
/// Enabled /// Enabled
sso_enabled: bool, true, def, false; 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 /// Only SSO login |> Disable Email+Master Password login
sso_only: bool, true, def, false; sso_only: bool, true, def, false;
/// Allow email association |> Associate existing non-SSO user based on email /// 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())?; 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._enable_yubico {
if cfg.yubico_client_id.is_some() != cfg.yubico_secret_key.is_some() { 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") err!("Both `YUBICO_CLIENT_ID` and `YUBICO_SECRET_KEY` must be set for Yubikey OTP support")

8
src/db/models/organization.rs

@ -212,7 +212,7 @@ impl Organization {
"usePolicies": true, "usePolicies": true,
"useScim": false, // Not supported (Not AGPLv3 Licensed) "useScim": false, // Not supported (Not AGPLv3 Licensed)
"useSso": false, // Not supported "useSso": false, // Not supported
"useKeyConnector": false, // Not supported "useKeyConnector": CONFIG.key_connector_enabled(),
"usePasswordManager": true, "usePasswordManager": true,
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true, "selfHost": true,
@ -488,7 +488,7 @@ impl Membership {
"useResetPassword": CONFIG.mail_enabled(), "useResetPassword": CONFIG.mail_enabled(),
"ssoBound": false, // Not supported "ssoBound": false, // Not supported
"useSso": false, // Not supported "useSso": false, // Not supported
"useKeyConnector": false, "useKeyConnector": CONFIG.key_connector_enabled(),
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed) "useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"usePasswordManager": true, "usePasswordManager": true,
"useCustomPermissions": true, "useCustomPermissions": true,
@ -503,8 +503,8 @@ impl Membership {
"familySponsorshipFriendlyName": null, "familySponsorshipFriendlyName": null,
"familySponsorshipAvailable": false, "familySponsorshipAvailable": false,
"productTierType": 3, // Enterprise tier "productTierType": 3, // Enterprise tier
"keyConnectorEnabled": false, "keyConnectorEnabled": CONFIG.key_connector_enabled(),
"keyConnectorUrl": null, "keyConnectorUrl": if CONFIG.key_connector_enabled() { Value::String(CONFIG.key_connector_url()) } else { Value::Null },
"familySponsorshipLastSyncDate": null, "familySponsorshipLastSyncDate": null,
"familySponsorshipValidUntil": null, "familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null, "familySponsorshipToDelete": null,

9
src/db/models/user.rs

@ -69,6 +69,8 @@ pub struct User {
pub avatar_color: Option<String>, pub avatar_color: Option<String>,
pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore. pub external_id: Option<String>, // Todo: Needs to be removed in the future, this is not used anymore.
pub uses_key_connector: bool,
} }
#[derive(Identifiable, Queryable, Insertable)] #[derive(Identifiable, Queryable, Insertable)]
@ -154,6 +156,8 @@ impl User {
avatar_color: None, avatar_color: None,
external_id: None, // Todo: Needs to be removed in the future, this is not used anymore. 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(); let twofactor_enabled = !TwoFactor::find_by_user(&self.uuid, conn).await.is_empty();
// TODO: Might want to save the status field in the DB // 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 UserStatus::Invited
} else { } else {
UserStatus::Enabled UserStatus::Enabled
@ -286,7 +291,7 @@ impl User {
"providerOrganizations": [], "providerOrganizations": [],
"forcePasswordReset": false, "forcePasswordReset": false,
"avatarColor": self.avatar_color, "avatarColor": self.avatar_color,
"usesKeyConnector": false, "usesKeyConnector": self.uses_key_connector,
"creationDate": format_date(&self.created_at), "creationDate": format_date(&self.created_at),
"object": "profile", "object": "profile",
}) })

1
src/db/schema.rs

@ -217,6 +217,7 @@ table! {
api_key -> Nullable<Text>, api_key -> Nullable<Text>,
avatar_color -> Nullable<Text>, avatar_color -> Nullable<Text>,
external_id -> Nullable<Text>, external_id -> Nullable<Text>,
uses_key_connector -> Bool,
} }
} }

8
src/sso.rs

@ -385,9 +385,15 @@ pub fn create_auth_tokens(
fn create_auth_tokens_impl( fn create_auth_tokens_impl(
device: &Device, device: &Device,
refresh_token: Option<String>, refresh_token: Option<String>,
access_claims: auth::LoginJwtClaims, mut access_claims: auth::LoginJwtClaims,
access_token: String, access_token: String,
) -> ApiResult<AuthTokens> { ) -> ApiResult<AuthTokens> {
// 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 { let (nbf, exp, token) = if let Some(rt) = refresh_token {
match decode_token_claims("refresh_token", &rt) { match decode_token_claims("refresh_token", &rt) {
Err(_) => { Err(_) => {

Loading…
Cancel
Save