Browse Source

feat: SSO improvements — auto-redirect, Key Connector, logout redirect, auto-enrollment

Add six new opt-in configuration flags that enhance the SSO experience:

SSO_AUTO_REDIRECT (requires SSO_ONLY=true):
  Automatically redirect users to the SSO provider login page instead of
  showing the Vaultwarden login form. Uses PKCE with S256 challenge.

SSO_IDENTIFIER:
  Custom organization identifier for SSO flows.

SSO_LOGOUT_REDIRECT (requires SSO_AUTO_REDIRECT=true):
  On logout, redirect to the SSO provider's OIDC end_session endpoint.
  Uses localStorage to detect logout vs fresh visit.

SSO_KEY_CONNECTOR (requires SSO_ONLY=true, SSO_KEY_CONNECTOR_SECRET):
  Built-in Key Connector — SSO users never need a master password.
  All stored keys are encrypted at rest with AES-256-GCM using a key
  derived from SSO_KEY_CONNECTOR_SECRET via HKDF-SHA256 with per-key
  salts. File format: salt(32) || nonce(12) || ciphertext || tag(16).
  The secret only exists as an env var, never on disk.

SSO_KEY_CONNECTOR_SECRET:
  Required 256-bit hex secret for encrypting Key Connector keys at rest.
  Can be sourced from external KMS (AWS KMS, HashiCorp Vault) via
  deployment tooling for stronger security guarantees.

SSO_AUTO_ENROLL:
  Auto-create organization and enroll SSO users on first login.

Startup validation ensures SSO_KEY_CONNECTOR_SECRET is set and valid
(64 hex chars) when SSO_KEY_CONNECTOR is enabled.

Addresses: #2583 (Key Connector), #6191 (auto-redirect), #6316 (SSO_ONLY flows)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
pull/6949/head
Brendan Kite 2 months ago
parent
commit
6fc40c09b6
  1. 141
      src/api/core/accounts.rs
  2. 79
      src/api/identity.rs
  3. 164
      src/api/web.rs
  4. 29
      src/config.rs
  5. 253
      src/crypto.rs
  6. 20
      src/db/models/organization.rs
  7. 2
      src/db/models/user.rs

141
src/api/core/accounts.rs

@ -16,8 +16,8 @@ use crate::{
db::{
models::{
AuthRequest, AuthRequestId, Cipher, CipherId, Device, DeviceId, DeviceType, EmergencyAccess,
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, OrgPolicy,
OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
EmergencyAccessId, EventType, Folder, FolderId, Invitation, Membership, MembershipId, MembershipStatus,
OrgPolicy, OrgPolicyType, Organization, OrganizationId, Send, SendId, User, UserId, UserKdfType,
},
DbConn,
},
@ -72,6 +72,12 @@ pub fn routes() -> Vec<rocket::Route> {
get_auth_request_response,
get_auth_requests,
get_auth_requests_pending,
// Key Connector endpoints (SSO passwordless)
get_key_connector_user_keys,
post_key_connector_user_keys,
post_set_key_connector_key,
post_convert_to_key_connector,
get_key_connector_confirmation_details,
]
}
@ -1700,3 +1706,134 @@ pub async fn purge_auth_requests(pool: DbPool) {
error!("Failed to get DB connection while purging auth requests")
}
}
#[get("/key-connector/user-keys")]
async fn get_key_connector_user_keys(headers: Headers) -> JsonResult {
if !CONFIG.sso_key_connector() {
err!("Key Connector is not enabled");
}
match crypto::load_kc_key(headers.user.uuid.as_ref()) {
Ok(key) => Ok(Json(json!({ "key": key }))),
Err(_) => err!("Key not found"),
}
}
#[post("/key-connector/user-keys", data = "<data>")]
async fn post_key_connector_user_keys(data: Json<KeyConnectorKeyData>, headers: Headers) -> EmptyResult {
if !CONFIG.sso_key_connector() {
err!("Key Connector is not enabled");
}
if data.key.len() > 1024 {
err!("Key data too large");
}
crypto::save_kc_key(headers.user.uuid.as_ref(), &data.key)?;
info!("Stored Key Connector key for user {}", headers.user.email);
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct KeyConnectorKeyData {
key: String,
}
#[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.sso_key_connector() {
err!("Key Connector is not enabled");
}
let data = data.into_inner();
let mut user = headers.user;
set_kdf_data(&mut user, &data.kdf)?;
user.akey = data.key;
if let Some(keys) = data.keys {
user.private_key = Some(keys.encrypted_private_key);
user.public_key = Some(keys.public_key);
}
if let Some(ref identifier) = data.org_identifier {
if !identifier.is_empty() {
if let Some(mut org) = Organization::find_by_name(identifier, &conn).await {
if let Some(mut membership) = Membership::find_by_user_and_org(&user.uuid, &org.uuid, &conn).await {
if membership.akey.is_empty() {
if let Some(ref user_pub_key) = user.public_key {
if org.public_key.is_none() {
if let Ok(keys) = crypto::generate_org_keys() {
org.public_key = Some(keys.public_key);
org.private_key = Some(keys.encrypted_private_key);
org.save(&conn).await?;
if let Err(e) = crypto::save_org_sym_key(org.uuid.as_ref(), &keys.org_sym_key) {
warn!("Failed to save org symmetric key: {e}");
}
}
}
if let Ok(org_sym_key) = crypto::load_org_sym_key(org.uuid.as_ref()) {
if let Ok(akey) = crypto::encrypt_org_key_for_user(&org_sym_key, user_pub_key) {
membership.akey = akey;
membership.status = MembershipStatus::Confirmed as i32;
membership.save(&conn).await?;
}
}
}
}
}
}
}
}
user.save(&conn).await?;
info!("Set Key Connector key for user {}", user.email);
Ok(())
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct SetKeyConnectorKeyData {
#[serde(flatten)]
kdf: KDFData,
key: String,
keys: Option<KeysData>,
org_identifier: Option<String>,
}
#[get("/accounts/key-connector/confirmation-details/<identifier>")]
async fn get_key_connector_confirmation_details(identifier: &str, headers: Headers, conn: DbConn) -> JsonResult {
if !CONFIG.sso_key_connector() {
err!("Key Connector is not enabled");
}
let org = if identifier == crate::sso::FAKE_IDENTIFIER {
match Membership::find_main_user_org(&headers.user.uuid, &conn).await {
Some(member) => Organization::find_by_uuid(&member.org_uuid, &conn).await,
None => None,
}
} else {
Organization::find_by_name(identifier, &conn).await
};
match org {
Some(org) => Ok(Json(json!({
"name": org.name,
"keyConnectorUrl": format!("{}/api/key-connector", CONFIG.domain()),
}))),
None => err!("Organization not found"),
}
}
#[post("/accounts/convert-to-key-connector")]
async fn post_convert_to_key_connector(headers: Headers, conn: DbConn) -> EmptyResult {
if !CONFIG.sso_key_connector() {
err!("Key Connector is not enabled");
}
if !crypto::has_kc_key(headers.user.uuid.as_ref()) {
err!("No Key Connector key stored for this user");
}
let mut user = headers.user;
user.password_hash = Vec::new();
user.save(&conn).await?;
info!("User {} converted to Key Connector (passwordless)", user.email);
Ok(())
}

79
src/api/identity.rs

@ -22,10 +22,12 @@ use crate::{
},
auth,
auth::{generate_organization_api_key_login_claims, AuthMethod, ClientHeaders, ClientIp, ClientVersion},
crypto,
db::{
models::{
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, OIDCCodeWrapper, OrganizationApiKey,
OrganizationId, SsoAuth, SsoUser, TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
AuthRequest, AuthRequestId, Device, DeviceId, EventType, Invitation, Membership, MembershipStatus,
MembershipType, OIDCCodeWrapper, Organization, OrganizationApiKey, OrganizationId, SsoAuth, SsoUser,
TwoFactor, TwoFactorIncomplete, TwoFactorType, User, UserId,
},
DbConn,
},
@ -301,6 +303,69 @@ async fn _sso_login(
// Set the user_uuid here to be passed back used for event logging.
*user_id = Some(user.uuid.clone());
// SSO auto-enroll: create organization and membership on first SSO login
if CONFIG.sso_auto_enroll() {
if Membership::find_by_user(&user.uuid, conn).await.is_empty() {
let sso_id_cfg = CONFIG.sso_identifier();
let org_name = if sso_id_cfg.is_empty() { "SSO Organization".to_string() } else { sso_id_cfg };
let mut org = match Organization::find_by_name(&org_name, conn).await {
Some(org) => org,
None => {
let org = Organization::new(org_name, &user.email, None, None);
org.save(conn).await?;
info!("Created SSO organization: {}", org.name);
org
}
};
let mut member = Membership::new(user.uuid.clone(), org.uuid.clone(), None);
member.access_all = true;
member.atype = MembershipType::User as i32;
// If user already has RSA keys (returning user), set up org keys + akey now
if let Some(ref user_pub_key) = user.public_key {
if org.public_key.is_none() {
match crypto::generate_org_keys() {
Ok(keys) => {
org.public_key = Some(keys.public_key);
org.private_key = Some(keys.encrypted_private_key);
org.save(conn).await?;
if let Err(e) = crypto::save_org_sym_key(org.uuid.as_ref(), &keys.org_sym_key) {
warn!("Failed to save org symmetric key: {e}");
}
}
Err(e) => warn!("Failed to generate org keys: {e}"),
}
}
match crypto::load_org_sym_key(org.uuid.as_ref()) {
Ok(org_sym_key) => {
match crypto::encrypt_org_key_for_user(&org_sym_key, user_pub_key) {
Ok(akey) => {
member.akey = akey;
member.status = MembershipStatus::Confirmed as i32;
}
Err(e) => {
warn!("Failed to encrypt org key for user: {e}");
member.status = MembershipStatus::Accepted as i32;
}
}
}
Err(_) => {
member.status = MembershipStatus::Accepted as i32;
}
}
} else {
// New user without keys — will be set up during Key Connector flow
member.status = MembershipStatus::Accepted as i32;
}
member.save(conn).await?;
info!("Auto-enrolled user {} in SSO organization {}", user.email, org.name);
}
}
// We passed 2FA get auth tokens
let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?;
@ -513,6 +578,11 @@ async fn authenticated_response(
"UserDecryptionOptions": {
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"KeyConnectorOption": if CONFIG.sso_key_connector() && !has_master_password {
json!({ "KeyConnectorUrl": format!("{}/api/key-connector", CONFIG.domain()) })
} else {
Value::Null
},
"Object": "userDecryptionOptions"
},
});
@ -666,6 +736,11 @@ async fn _user_api_key_login(
"UserDecryptionOptions": {
"HasMasterPassword": has_master_password,
"MasterPasswordUnlock": master_password_unlock,
"KeyConnectorOption": if CONFIG.sso_key_connector() && !has_master_password {
json!({ "KeyConnectorUrl": format!("{}/api/key-connector", CONFIG.domain()) })
} else {
Value::Null
},
"Object": "userDecryptionOptions"
},
});

164
src/api/web.rs

@ -14,6 +14,7 @@ use crate::{
auth::decode_file_download,
db::models::{AttachmentId, CipherId},
error::Error,
sso,
util::Cached,
CONFIG,
};
@ -24,6 +25,9 @@ pub fn routes() -> Vec<Route> {
let mut routes = routes![attachments, alive, alive_head, static_files];
if CONFIG.web_vault_enabled() {
routes.append(&mut routes![web_index, web_index_direct, web_index_head, app_id, web_files, vaultwarden_css]);
if CONFIG.sso_enabled() && CONFIG.sso_only() && CONFIG.sso_auto_redirect() {
routes.append(&mut routes![vaultwarden_sso_js, sso_auto_redirect, sso_auto_redirect_js]);
}
}
#[cfg(debug_assertions)]
@ -106,8 +110,23 @@ fn vaultwarden_css() -> Cached<Css<String>> {
}
#[get("/")]
async fn web_index() -> Cached<Option<NamedFile>> {
Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false)
async fn web_index() -> Cached<Option<Html<String>>> {
let path = Path::new(&CONFIG.web_vault_folder()).join("index.html");
match tokio::fs::read_to_string(&path).await {
Ok(mut html) => {
// When SSO auto-redirect is enabled, inject a script that redirects the login page
// to the SSO provider and hides the default login UI to prevent flash
if CONFIG.sso_enabled() && CONFIG.sso_only() && CONFIG.sso_auto_redirect() {
html = html.replace(
"</head>",
"<style>app-root{display:none!important}html,body{background:#0f1419!important}</style>\
<script src=\"vaultwarden-sso.js\"></script></head>",
);
}
Cached::short(Some(Html(html)), false)
}
Err(_) => Cached::short(None, false),
}
}
// Make sure that `/index.html` redirect to actual domain path.
@ -246,3 +265,144 @@ pub fn static_files(filename: &str) -> Result<(ContentType, &'static [u8]), Erro
_ => err!(format!("Static file not found: {filename}")),
}
}
/// Inline JS injected into index.html that intercepts the login page and redirects to the
/// SSO auto-redirect page. Non-login pages (SSO callback, vault, etc.) show `app-root` normally.
///
/// When SSO_LOGOUT_REDIRECT is also enabled, tracks the active session in localStorage.
/// On logout (page reloads to login hash while flag exists), redirects to the SSO provider's
/// end_session endpoint to properly terminate the SSO session before the next auto-redirect.
#[get("/vaultwarden-sso.js")]
fn vaultwarden_sso_js() -> Cached<(ContentType, String)> {
let js = if CONFIG.sso_enabled() && CONFIG.sso_only() && CONFIG.sso_auto_redirect() {
let logout_redirect = CONFIG.sso_logout_redirect();
if logout_redirect {
let sso_authority = CONFIG.sso_authority();
let sso_client_id = CONFIG.sso_client_id();
let domain = CONFIG.domain();
let safe_authority: String = sso_authority.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_'))
.collect();
let safe_client_id: String = sso_client_id.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
.collect();
let safe_domain: String = domain.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_'))
.collect();
// With logout redirect: track active session via localStorage flag.
// Login hash + flag present = logout → redirect to IdP end_session.
// Login hash + no flag = fresh visit → auto-redirect to SSO.
// Other hash = active session → set flag, show app.
format!(
"(function(){{\
var h=window.location.hash||'';\
if(!h||h==='#'||h==='#/'||h==='#/login'||h.indexOf('#/login?')===0){{\
if(localStorage.getItem('__vw_sso_active')){{\
localStorage.removeItem('__vw_sso_active');\
window.location.replace('{safe_authority}/protocol/openid-connect/logout\
?client_id={safe_client_id}&post_logout_redirect_uri='+encodeURIComponent('{safe_domain}/sso-auto-redirect'));\
}}else{{\
var p=window.location.pathname;\
if(p.charAt(p.length-1)!=='/') p+='/';\
window.location.replace(p+'sso-auto-redirect');\
}}\
}}else{{\
localStorage.setItem('__vw_sso_active','1');\
var s=document.createElement('style');\
s.textContent='app-root{{display:block!important}}';\
document.head.appendChild(s);\
}}\
}})();",
safe_authority = safe_authority,
safe_client_id = safe_client_id,
safe_domain = safe_domain,
)
} else {
// Without logout redirect: simple auto-redirect, no session tracking.
"(function(){\
var h=window.location.hash||'';\
if(!h||h==='#'||h==='#/'||h==='#/login'||h.indexOf('#/login?')===0){\
var p=window.location.pathname;\
if(p.charAt(p.length-1)!=='/') p+='/';\
window.location.replace(p+'sso-auto-redirect');\
} else {\
var s=document.createElement('style');\
s.textContent='app-root{display:block!important}';\
document.head.appendChild(s);\
}\
})();".to_string()
}
} else {
String::new()
};
Cached::ttl((ContentType::JavaScript, js), 86_400, false)
}
/// Minimal HTML page that loads the PKCE redirect script as an external resource.
#[get("/sso-auto-redirect")]
fn sso_auto_redirect() -> Cached<Html<&'static str>> {
Cached::short(
Html(r#"<!DOCTYPE html>
<html><head><meta charset="utf-8">
<style>html,body{margin:0;background:#0f1419}</style>
</head><body>
<script src="sso-auto-redirect.js"></script>
</body></html>"#),
false,
)
}
/// PKCE-based SSO auto-redirect script. Generates code verifier, challenge, and state,
/// stores them in sessionStorage (where the web vault expects them), then redirects to
/// Vaultwarden's /identity/connect/authorize endpoint which forwards to the SSO provider.
#[get("/sso-auto-redirect.js")]
fn sso_auto_redirect_js() -> Cached<(ContentType, String)> {
let domain = CONFIG.domain();
// Sanitize values for safe JS string embedding (prevent XSS via config injection)
let safe_domain: String = domain
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, ':' | '/' | '.' | '-' | '_'))
.collect();
let sso_id_cfg = CONFIG.sso_identifier();
let sso_id = if sso_id_cfg.is_empty() { sso::FAKE_IDENTIFIER.to_string() } else { sso_id_cfg };
let safe_sso_id: String = sso_id
.chars()
.filter(|c| c.is_alphanumeric() || matches!(c, '-' | '_'))
.collect();
let js = format!(
r#"(async function(){{
var cv='';var a=new Uint8Array(64);crypto.getRandomValues(a);
for(var i=0;i<a.length;i++)cv+=a[i].toString(16).padStart(2,'0');
var enc=new TextEncoder().encode(cv);
var hash=await crypto.subtle.digest('SHA-256',enc);
var u8=new Uint8Array(hash);var b='';
for(var i=0;i<u8.length;i++)b+=String.fromCharCode(u8[i]);
var cc=btoa(b).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/g,'');
var sa=new Uint8Array(32);crypto.getRandomValues(sa);
var state='';for(var i=0;i<sa.length;i++)state+=sa[i].toString(16).padStart(2,'0');
state+='_identifier={safe_sso_id}';
sessionStorage.setItem('global_ssoLogin_ssoCodeVerifier',JSON.stringify(cv));
sessionStorage.setItem('global_ssoLogin_ssoState',JSON.stringify(state));
sessionStorage.setItem('global_ssoLogin_organizationSsoIdentifier',JSON.stringify('{safe_sso_id}'));
sessionStorage.setItem('global_ssoLogin_ssoEmail',JSON.stringify(''));
localStorage.setItem('global_ssoLogin_organizationSsoIdentifier',JSON.stringify('{safe_sso_id}'));
var p=new URLSearchParams({{
client_id:'web',
redirect_uri:'{safe_domain}/sso-connector.html',
response_type:'code',
scope:'api offline_access',
state:state,
code_challenge:cc,
code_challenge_method:'S256'
}});
window.location.replace('{safe_domain}/identity/connect/authorize?'+p.toString());
}})();"#,
safe_domain = safe_domain,
safe_sso_id = safe_sso_id,
);
Cached::short((ContentType::JavaScript, js), false)
}

29
src/config.rs

@ -829,6 +829,18 @@ make_config! {
sso_client_cache_expiration: u64, true, def, 0;
/// Log all tokens |> `LOG_LEVEL=debug` or `LOG_LEVEL=info,vaultwarden::sso=debug` is required
sso_debug_tokens: bool, true, def, false;
/// Auto-redirect to SSO |> Automatically redirect users to the SSO provider login page instead of showing the Vaultwarden login form. Requires SSO_ONLY=true.
sso_auto_redirect: bool, true, def, false;
/// SSO organization identifier |> Identifier sent during SSO auto-redirect. Must match the identifier used in organization invitations. Leave empty to use default.
sso_identifier: String, true, def, String::new();
/// SSO logout redirect |> On logout, redirect to the SSO provider's end_session endpoint to terminate the SSO session. Prevents auto-re-login when SSO_AUTO_REDIRECT is enabled.
sso_logout_redirect: bool, true, def, false;
/// SSO Key Connector |> Enable built-in Key Connector support. Allows SSO users to use their vault without ever setting a master password. The server stores user master keys encrypted at rest. Requires SSO_ONLY=true and SSO_KEY_CONNECTOR_SECRET.
sso_key_connector: bool, true, def, false;
/// SSO Key Connector secret |> Required when SSO_KEY_CONNECTOR=true. 64-char hex string (256 bits) used to encrypt user keys at rest via AES-256-GCM. Generate with: openssl rand -hex 32. CRITICAL: back up this secret — losing it means all Key Connector users lose vault access.
sso_key_connector_secret: Pass, true, option;
/// SSO auto-enroll |> Automatically create an organization and enroll SSO users on first login. The organization name matches SSO_IDENTIFIER (or a default).
sso_auto_enroll: bool, true, def, false;
},
/// Yubikey settings
@ -1096,6 +1108,23 @@ fn validate_config(cfg: &ConfigItems) -> Result<(), Error> {
validate_internal_sso_issuer_url(&cfg.sso_authority)?;
validate_internal_sso_redirect_url(&cfg.sso_callback_path)?;
validate_sso_master_password_policy(&cfg.sso_master_password_policy)?;
if cfg.sso_key_connector {
match &cfg.sso_key_connector_secret {
None => err!("`SSO_KEY_CONNECTOR_SECRET` is required when `SSO_KEY_CONNECTOR` is enabled. Generate with: openssl rand -hex 32"),
Some(secret) if secret.len() != 64 => err!("`SSO_KEY_CONNECTOR_SECRET` must be exactly 64 hex characters (256 bits)"),
Some(secret) if data_encoding::HEXLOWER.decode(secret.as_bytes()).is_err() => {
err!("`SSO_KEY_CONNECTOR_SECRET` must be valid lowercase hex")
}
_ => {}
}
if !cfg.sso_only {
err!("`SSO_KEY_CONNECTOR` requires `SSO_ONLY=true`")
}
}
if cfg.sso_auto_redirect && !cfg.sso_only {
err!("`SSO_AUTO_REDIRECT` requires `SSO_ONLY=true`")
}
}
if cfg._enable_yubico {

253
src/crypto.rs

@ -113,3 +113,256 @@ pub fn ct_eq<T: AsRef<[u8]>, U: AsRef<[u8]>>(a: T, b: U) -> bool {
use subtle::ConstantTimeEq;
a.as_ref().ct_eq(b.as_ref()).into()
}
//
// Organization key generation for SSO auto-enrollment
//
use data_encoding::BASE64;
pub struct OrgKeys {
pub public_key: String,
pub encrypted_private_key: String,
pub org_sym_key: Vec<u8>,
}
/// Generates organization keys: RSA-2048 keypair + 64-byte symmetric key.
/// Private key is encrypted as a type 2 EncString (AES-CBC-256 + HMAC-SHA256).
pub fn generate_org_keys() -> Result<OrgKeys, crate::error::Error> {
use openssl::pkey::PKey;
use openssl::rsa::Rsa;
use openssl::sign::Signer;
use openssl::symm::{encrypt as aes_encrypt, Cipher};
let org_sym_key: [u8; 64] = get_random_bytes();
let enc_key = &org_sym_key[..32];
let mac_key = &org_sym_key[32..];
let rsa = Rsa::generate(2048)?;
let pkey = PKey::from_rsa(rsa)?;
let pub_key_der = pkey.public_key_to_der()?;
let public_key = BASE64.encode(&pub_key_der);
let priv_key_der = pkey.private_key_to_der()?;
let iv: [u8; 16] = get_random_bytes();
let ciphertext = aes_encrypt(Cipher::aes_256_cbc(), enc_key, Some(&iv), &priv_key_der)?;
let hmac_pkey = PKey::hmac(mac_key)?;
let mut signer = Signer::new(openssl::hash::MessageDigest::sha256(), &hmac_pkey)?;
signer.update(&iv)?;
signer.update(&ciphertext)?;
let mac = signer.sign_to_vec()?;
let encrypted_private_key =
format!("2.{}|{}|{}", BASE64.encode(&iv), BASE64.encode(&ciphertext), BASE64.encode(&mac));
Ok(OrgKeys {
public_key,
encrypted_private_key,
org_sym_key: org_sym_key.to_vec(),
})
}
/// Encrypts the org symmetric key with a user's RSA public key (type 4 EncString, RSA-OAEP-SHA1).
pub fn encrypt_org_key_for_user(
org_sym_key: &[u8],
user_public_key_b64: &str,
) -> Result<String, crate::error::Error> {
use openssl::encrypt::Encrypter;
use openssl::pkey::PKey;
use openssl::rsa::Padding;
let user_pub_der = BASE64
.decode(user_public_key_b64.as_bytes())
.map_err(|e| crate::error::Error::new("Invalid user public key", e.to_string()))?;
let user_pub = PKey::public_key_from_der(&user_pub_der)?;
let mut encrypter = Encrypter::new(&user_pub)?;
encrypter.set_rsa_padding(Padding::PKCS1_OAEP)?;
encrypter.set_rsa_oaep_md(openssl::hash::MessageDigest::sha1())?;
encrypter.set_rsa_mgf1_md(openssl::hash::MessageDigest::sha1())?;
let buffer_len = encrypter.encrypt_len(org_sym_key)?;
let mut encrypted = vec![0u8; buffer_len];
let encrypted_len = encrypter.encrypt(org_sym_key, &mut encrypted)?;
encrypted.truncate(encrypted_len);
Ok(format!("4.{}", BASE64.encode(&encrypted)))
}
//
// Key Connector encrypted key storage
//
// Keys are encrypted with AES-256-GCM before writing to disk. The encryption
// key is derived via HKDF-SHA256 from SSO_KEY_CONNECTOR_SECRET with a unique
// per-file salt. File format: salt(32) || nonce(12) || ciphertext || tag(16).
//
// The secret never touches disk — it exists only as an env var. For stronger
// guarantees, source the secret from an external KMS at deployment time.
//
use std::path::PathBuf;
fn kc_keys_dir() -> PathBuf {
PathBuf::from(crate::CONFIG.data_folder()).join("kc_keys")
}
fn org_keys_dir() -> PathBuf {
PathBuf::from(crate::CONFIG.data_folder()).join("org_keys")
}
fn validate_storage_id(id: &str) -> Result<(), crate::error::Error> {
if id.is_empty() || id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(crate::error::Error::new("Invalid storage identifier", ""));
}
Ok(())
}
/// Derives a per-key encryption key from the KC secret via HKDF-SHA256.
fn derive_kc_encryption_key(salt: &[u8]) -> Result<[u8; 32], crate::error::Error> {
use ring::hkdf;
let secret_hex = crate::CONFIG
.sso_key_connector_secret()
.ok_or_else(|| crate::error::Error::new("SSO_KEY_CONNECTOR_SECRET is required", ""))?;
let secret = HEXLOWER
.decode(secret_hex.as_bytes())
.map_err(|e| crate::error::Error::new("Invalid SSO_KEY_CONNECTOR_SECRET", e.to_string()))?;
if secret.len() != 32 {
return Err(crate::error::Error::new("SSO_KEY_CONNECTOR_SECRET must be 64 hex chars", ""));
}
let hk_salt = hkdf::Salt::new(hkdf::HKDF_SHA256, salt);
let prk = hk_salt.extract(&secret);
let okm = prk
.expand(&[b"vaultwarden-kc-key"], HkdfLen(32))
.map_err(|_| crate::error::Error::new("HKDF expand failed", ""))?;
let mut key = [0u8; 32];
okm.fill(&mut key).map_err(|_| crate::error::Error::new("HKDF fill failed", ""))?;
Ok(key)
}
// ring::hkdf requires a type implementing Len trait for output length
struct HkdfLen(usize);
impl ring::hkdf::KeyType for HkdfLen {
fn len(&self) -> usize {
self.0
}
}
/// Encrypts data with AES-256-GCM. The `aad` (additional authenticated data) binds
/// the ciphertext to the owner's identity, preventing file-swapping attacks.
/// Returns: salt(32) || nonce(12) || ciphertext || tag(16).
fn encrypt_at_rest(plaintext: &[u8], aad: &[u8]) -> Result<Vec<u8>, crate::error::Error> {
use openssl::symm::{encrypt_aead, Cipher};
let salt: [u8; 32] = get_random_bytes();
let nonce: [u8; 12] = get_random_bytes();
let key = derive_kc_encryption_key(&salt)?;
let mut tag = [0u8; 16];
let ciphertext = encrypt_aead(Cipher::aes_256_gcm(), &key, Some(&nonce), aad, plaintext, &mut tag)?;
let mut out = Vec::with_capacity(32 + 12 + ciphertext.len() + 16);
out.extend_from_slice(&salt);
out.extend_from_slice(&nonce);
out.extend_from_slice(&ciphertext);
out.extend_from_slice(&tag);
Ok(out)
}
fn decrypt_at_rest(data: &[u8], aad: &[u8]) -> Result<Vec<u8>, crate::error::Error> {
use openssl::symm::{decrypt_aead, Cipher};
if data.len() < 32 + 12 + 16 {
return Err(crate::error::Error::new("Encrypted data too short", ""));
}
let salt = &data[..32];
let nonce = &data[32..44];
let tag = &data[data.len() - 16..];
let ciphertext = &data[44..data.len() - 16];
let key = derive_kc_encryption_key(salt)?;
decrypt_aead(Cipher::aes_256_gcm(), &key, Some(nonce), aad, ciphertext, tag)
.map_err(|e| crate::error::Error::new("Decryption failed (wrong secret?)", e.to_string()))
}
pub fn save_kc_key(user_uuid: &str, key: &str) -> Result<(), crate::error::Error> {
validate_storage_id(user_uuid)?;
let dir = kc_keys_dir();
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(user_uuid), encrypt_at_rest(key.as_bytes(), user_uuid.as_bytes())?)?;
Ok(())
}
pub fn load_kc_key(user_uuid: &str) -> Result<String, crate::error::Error> {
validate_storage_id(user_uuid)?;
let path = kc_keys_dir().join(user_uuid);
let encrypted = std::fs::read(&path).map_err(|e| crate::error::Error::new("Key not found", e.to_string()))?;
let plaintext = decrypt_at_rest(&encrypted, user_uuid.as_bytes())?;
String::from_utf8(plaintext).map_err(|e| crate::error::Error::new("Invalid key data", e.to_string()))
}
pub fn has_kc_key(user_uuid: &str) -> bool {
validate_storage_id(user_uuid).is_ok() && kc_keys_dir().join(user_uuid).exists()
}
pub fn save_org_sym_key(org_uuid: &str, key: &[u8]) -> Result<(), crate::error::Error> {
validate_storage_id(org_uuid)?;
let dir = org_keys_dir();
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join(org_uuid), encrypt_at_rest(key, org_uuid.as_bytes())?)?;
Ok(())
}
pub fn load_org_sym_key(org_uuid: &str) -> Result<Vec<u8>, crate::error::Error> {
validate_storage_id(org_uuid)?;
let path = org_keys_dir().join(org_uuid);
let encrypted = std::fs::read(&path).map_err(|e| crate::error::Error::new("Org key not found", e.to_string()))?;
decrypt_at_rest(&encrypted, org_uuid.as_bytes())
}
#[cfg(test)]
mod kc_tests {
use super::*;
#[test]
fn test_org_key_generation() {
let keys = generate_org_keys().unwrap();
assert!(!keys.public_key.is_empty());
assert!(keys.encrypted_private_key.starts_with("2."));
assert_eq!(keys.org_sym_key.len(), 64);
}
#[test]
fn test_org_key_encrypt_for_user() {
// Generate an org key and a user keypair, verify encryption produces type 4 EncString
let org_keys = generate_org_keys().unwrap();
use openssl::rsa::Rsa;
use openssl::pkey::PKey;
let rsa = Rsa::generate(2048).unwrap();
let pkey = PKey::from_rsa(rsa).unwrap();
let pub_der = pkey.public_key_to_der().unwrap();
let pub_b64 = BASE64.encode(&pub_der);
let akey = encrypt_org_key_for_user(&org_keys.org_sym_key, &pub_b64).unwrap();
assert!(akey.starts_with("4."));
// Verify we can decrypt it back
use openssl::encrypt::Decrypter;
use openssl::rsa::Padding;
let encrypted_b64 = &akey[2..];
let encrypted = BASE64.decode(encrypted_b64.as_bytes()).unwrap();
let mut decrypter = Decrypter::new(&pkey).unwrap();
decrypter.set_rsa_padding(Padding::PKCS1_OAEP).unwrap();
decrypter.set_rsa_oaep_md(openssl::hash::MessageDigest::sha1()).unwrap();
decrypter.set_rsa_mgf1_md(openssl::hash::MessageDigest::sha1()).unwrap();
let buf_len = decrypter.decrypt_len(&encrypted).unwrap();
let mut decrypted = vec![0u8; buf_len];
let len = decrypter.decrypt(&encrypted, &mut decrypted).unwrap();
decrypted.truncate(len);
assert_eq!(decrypted, org_keys.org_sym_key);
}
}

20
src/db/models/organization.rs

@ -198,8 +198,8 @@ impl Organization {
"useTotp": true,
"usePolicies": true,
"useScim": false, // Not supported (Not AGPLv3 Licensed)
"useSso": false, // Not supported
"useKeyConnector": false, // Not supported
"useSso": CONFIG.sso_enabled(),
"useKeyConnector": CONFIG.sso_key_connector(),
"usePasswordManager": true,
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"selfHost": true,
@ -474,7 +474,7 @@ impl Membership {
// https://github.com/bitwarden/server/blob/9ebe16587175b1c0e9208f84397bb75d0d595510/src/Api/AdminConsole/Models/Response/ProfileOrganizationResponseModel.cs
json!({
"id": self.org_uuid,
"identifier": null, // Not supported
"identifier": &org.name,
"name": org.name,
"seats": 20, // hardcoded maxEmailsCount in the web-vault
"maxCollections": null,
@ -492,8 +492,8 @@ impl Membership {
"resetPasswordEnrolled": self.reset_password_key.is_some(),
"useResetPassword": CONFIG.mail_enabled(),
"ssoBound": false, // Not supported
"useSso": false, // Not supported
"useKeyConnector": false,
"useSso": CONFIG.sso_enabled(),
"useKeyConnector": CONFIG.sso_key_connector(),
"useSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"usePasswordManager": true,
"useCustomPermissions": true,
@ -508,8 +508,12 @@ impl Membership {
"familySponsorshipFriendlyName": null,
"familySponsorshipAvailable": false,
"productTierType": 3, // Enterprise tier
"keyConnectorEnabled": false,
"keyConnectorUrl": null,
"keyConnectorEnabled": CONFIG.sso_key_connector(),
"keyConnectorUrl": if CONFIG.sso_key_connector() {
json!(format!("{}/api/key-connector", CONFIG.domain()))
} else {
json!(null)
},
"familySponsorshipLastSyncDate": null,
"familySponsorshipValidUntil": null,
"familySponsorshipToDelete": null,
@ -662,7 +666,7 @@ impl Membership {
"ssoBound": false, // Not supported
"managedByOrganization": false, // This key is obsolete replaced by claimedByOrganization
"claimedByOrganization": false, // Means not managed via the Members UI, like SSO
"usesKeyConnector": false, // Not supported
"usesKeyConnector": crate::crypto::has_kc_key(self.user_uuid.as_ref()),
"accessSecretsManager": false, // Not supported (Not AGPLv3 Licensed)
"object": "organizationUserUserDetails",

2
src/db/models/user.rs

@ -277,7 +277,7 @@ impl User {
"providerOrganizations": [],
"forcePasswordReset": false,
"avatarColor": self.avatar_color,
"usesKeyConnector": false,
"usesKeyConnector": crypto::has_kc_key(self.uuid.as_ref()),
"creationDate": format_date(&self.created_at),
"object": "profile",
})

Loading…
Cancel
Save