diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 0ce1f684..6fdbce68 100644 --- a/src/api/core/accounts.rs +++ b/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 { 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 = "")] +async fn post_key_connector_user_keys(data: Json, 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 = "")] +async fn post_set_key_connector_key(data: Json, 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, + org_identifier: Option, +} + +#[get("/accounts/key-connector/confirmation-details/")] +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(()) +} diff --git a/src/api/identity.rs b/src/api/identity.rs index f3fd3d1a..03cd04a5 100644 --- a/src/api/identity.rs +++ b/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" }, }); diff --git a/src/api/web.rs b/src/api/web.rs index 0ae9c7db..5a0dd271 100644 --- a/src/api/web.rs +++ b/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 { 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> { } #[get("/")] -async fn web_index() -> Cached> { - Cached::short(NamedFile::open(Path::new(&CONFIG.web_vault_folder()).join("index.html")).await.ok(), false) +async fn web_index() -> Cached>> { + 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( + "", + "\ + ", + ); + } + 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> { + Cached::short( + Html(r#" + + + + +"#), + 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 `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 { diff --git a/src/crypto.rs b/src/crypto.rs index 1930f380..c575f39f 100644 --- a/src/crypto.rs +++ b/src/crypto.rs @@ -113,3 +113,256 @@ pub fn ct_eq, 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, +} + +/// 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 { + 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 { + 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, 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, 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 { + 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, 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); + } +} diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 0b722ef6..526c1a90 100644 --- a/src/db/models/organization.rs +++ b/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", diff --git a/src/db/models/user.rs b/src/db/models/user.rs index e88c7296..aad58b27 100644 --- a/src/db/models/user.rs +++ b/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", })