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/playwright/docker-compose.yml b/playwright/docker-compose.yml index f4402326..0cd6874c 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -25,6 +25,8 @@ services: - ADMIN_TOKEN - DATABASE_URL - I_REALLY_WANT_VOLATILE_STORAGE + - KEY_CONNECTOR_ENABLED + - KEY_CONNECTOR_URL - LOG_LEVEL - LOGIN_RATELIMIT_MAX_BURST - SMTP_HOST diff --git a/playwright/test.env b/playwright/test.env index df182ebe..c54c5705 100644 --- a/playwright/test.env +++ b/playwright/test.env @@ -67,6 +67,10 @@ SSO_CLIENT_SECRET=warden SSO_AUTHORITY=http://${KC_HTTP_HOST}:${KC_HTTP_PORT}/realms/${TEST_REALM} SSO_DEBUG_TOKENS=true +# Mock service started by sso_keyconnector.spec.ts +KEY_CONNECTOR_PORT=8004 +KEY_CONNECTOR_URL=http://127.0.0.1:${KEY_CONNECTOR_PORT} + # Custom web-vault build # PW_VW_REPO_URL=https://github.com/vaultwarden/vw_web_builds.git # PW_VW_COMMIT_HASH=b5f5b2157b9b64b5813bc334a75a277d0377b5d3 diff --git a/playwright/tests/setups/keyconnector.ts b/playwright/tests/setups/keyconnector.ts new file mode 100644 index 00000000..3de31a8d --- /dev/null +++ b/playwright/tests/setups/keyconnector.ts @@ -0,0 +1,72 @@ +import { createServer } from 'node:http'; + +/** + * Barebone in-memory key connector, just enough for the clients to enroll and unlock. + * Keys are stored per token subject; token validation is deliberately out of scope, + * this only exists to test the Vaultwarden and web client side of the flow. + */ +export function startMockKeyConnector(port: number) { + const keys = new Map(); + + const server = createServer((req, res) => { + // The web client calls us cross-origin, with credentials + res.setHeader('Access-Control-Allow-Origin', req.headers['origin'] ?? '*'); + res.setHeader('Access-Control-Allow-Credentials', 'true'); + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', req.headers['access-control-request-headers'] ?? '*'); + + if (req.method === 'OPTIONS') { + res.writeHead(204).end(); + return; + } + + if (req.url === '/alive') { + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({})); + return; + } + + const sub = tokenSubject(req.headers['authorization']); + if (!sub) { + res.writeHead(401).end(); + return; + } + + if (req.url !== '/user-keys') { + res.writeHead(404).end(); + return; + } + + if (req.method === 'GET') { + if (!keys.has(sub)) { + res.writeHead(404).end(); + return; + } + res.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ key: keys.get(sub) })); + } else if (req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => body += chunk); + req.on('end', () => { + keys.set(sub, JSON.parse(body).key); + res.writeHead(200).end(); + }); + } else { + res.writeHead(405).end(); + } + }); + + server.listen(port); + console.log(`Mock key connector running on port ${port}`); + + return { + keys, + stop: () => server.close(), + }; +} + +function tokenSubject(header?: string): string | null { + try { + return JSON.parse(Buffer.from(header.replace(/^Bearer /, '').split('.')[1], 'base64url').toString()).sub; + } catch { + return null; + } +} diff --git a/playwright/tests/sso_keyconnector.spec.ts b/playwright/tests/sso_keyconnector.spec.ts new file mode 100644 index 00000000..8eba8d60 --- /dev/null +++ b/playwright/tests/sso_keyconnector.spec.ts @@ -0,0 +1,61 @@ +import { test, expect, type Page, type TestInfo } from '@playwright/test'; + +import { startMockKeyConnector } from './setups/keyconnector'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); +let keyConnector; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + keyConnector = startMockKeyConnector(Number(process.env.KEY_CONNECTOR_PORT)); + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_ONLY: false, + KEY_CONNECTOR_ENABLED: true, + KEY_CONNECTOR_URL: process.env.KEY_CONNECTOR_URL, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + keyConnector.stop(); +}); + +async function ssoLogin(page: Page, user: { email: string, name: string, password: string }) { + await page.context().clearCookies(); + await utils.cleanLanding(page); + + await page.locator("input[type=email].vw-email-sso").fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + + await expect(page.getByRole('heading', { name: 'Sign in to your account' })).toBeVisible(); + await page.getByLabel(/Username/).fill(user.name); + await page.getByLabel('Password', { exact: true }).fill(user.password); + await page.getByRole('button', { name: 'Sign In' }).click(); +} + +test('Account creation using SSO enrolls with the key connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // Instead of the master password creation we land on the domain confirmation + await expect(page.getByText(process.env.KEY_CONNECTOR_URL)).toBeVisible(); + // Button label differs between web-vault versions + await page.getByRole('button', { name: /^(Confirm|Continue with log in)$/ }).click(); + + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); + + expect(keyConnector.keys.size).toBe(1); +}); + +test('SSO login unlocks with the key from the connector', async ({ page }) => { + await ssoLogin(page, users.user1); + + // No master password prompt, the vault opens directly + await utils.ignoreExtension(page); + + await expect(page).toHaveTitle(/Vaultwarden Web/); + await expect(page.getByTitle('All vaults', { exact: true })).toBeVisible(); +}); diff --git a/src/api/core/key_connector.rs b/src/api/core/key_connector.rs new file mode 100644 index 00000000..f0c39b5a --- /dev/null +++ b/src/api/core/key_connector.rs @@ -0,0 +1,117 @@ +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; + + if user.private_key.is_some() { + err!("Account already initialized, cannot set Key Connector key"); + } + + 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; + + if user.private_key.is_none() { + err!("Account is not initialized, cannot convert to Key Connector"); + } + + 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..b6ea68ff 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -52,10 +52,27 @@ pub fn routes() -> Vec { prevalidate, authorize, oidcsignin, - oidcsignin_error + oidcsignin_error, + openid_configuration, + jwks ] } +// Issuer and signing key of our own login tokens, for services that verify +// them (e.g. a key connector). The issuer is the `iss` claim, not a URL. +#[get("/.well-known/openid-configuration")] +fn openid_configuration() -> Json { + Json(json!({ + "issuer": *auth::JWT_LOGIN_ISSUER, + "jwks_uri": format!("{}/identity/.well-known/jwks", CONFIG.domain()), + })) +} + +#[get("/.well-known/jwks")] +fn jwks() -> Json { + Json(auth::login_jwks().clone()) +} + #[post("/connect/token", data = "")] async fn login( data: Form, @@ -514,7 +531,12 @@ 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 = user.uses_key_connector; + if uses_key_connector && !CONFIG.key_connector_enabled() { + err!("This user's master key is stored on a key connector, but Key Connector support is disabled") + } + let has_master_password = !user.password_hash.is_empty() && !uses_key_connector; let master_password_unlock = if has_master_password { json!({ "Kdf": { @@ -572,6 +594,13 @@ async fn authenticated_response( result["Key"] = Value::String(user.akey.clone()); } + // Also advertised to users without a master password, that is how the client + // knows to enroll a new SSO user with the connector + if CONFIG.key_connector_enabled() && !has_master_password { + 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/auth.rs b/src/auth.rs index 88a59b4b..7c4cbfbc 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -10,10 +10,12 @@ use std::{ }; use chrono::{DateTime, TimeDelta, Utc}; +use data_encoding::BASE64URL_NOPAD; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, errors::ErrorKind}; use num_traits::FromPrimitive; use openssl::rsa::Rsa; use serde::{de::DeserializeOwned, ser::Serialize}; +use serde_json::json; use rocket::{ outcome::try_outcome, @@ -64,6 +66,7 @@ static JWT_2FA_REMEMBER_ISSUER: LazyLock = LazyLock::new(|| format!("{}| static PRIVATE_RSA_KEY: OnceLock = OnceLock::new(); static PUBLIC_RSA_KEY: OnceLock = OnceLock::new(); +static LOGIN_JWKS: OnceLock = OnceLock::new(); pub async fn initialize_keys() -> Result<(), Error> { use std::io::Error as IoError; @@ -90,6 +93,24 @@ pub async fn initialize_keys() -> Result<(), Error> { }; let pub_key_buffer = priv_key.public_key_to_pem()?; + // Expose the public half as a JWKS on /identity/.well-known/ so token + // consumers (e.g. a key connector) don't need a copy of the PEM. + let n = BASE64URL_NOPAD.encode(&priv_key.n().to_vec()); + let e = BASE64URL_NOPAD.encode(&priv_key.e().to_vec()); + // the kid is the RFC 7638 thumbprint of the key + let thumbprint = json!({"e": e, "kty": "RSA", "n": n}).to_string(); + let kid = BASE64URL_NOPAD.encode(&openssl::sha::sha256(thumbprint.as_bytes())); + let jwks = json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": n, + "e": e, + }] + }); + let enc = EncodingKey::from_rsa_pem(&priv_key_buffer)?; let dec: DecodingKey = DecodingKey::from_rsa_pem(&pub_key_buffer)?; if PRIVATE_RSA_KEY.set(enc).is_err() { @@ -98,9 +119,16 @@ pub async fn initialize_keys() -> Result<(), Error> { if PUBLIC_RSA_KEY.set(dec).is_err() { err!("PUBLIC_RSA_KEY must only be initialized once") } + if LOGIN_JWKS.set(jwks).is_err() { + err!("LOGIN_JWKS must only be initialized once") + } Ok(()) } +pub fn login_jwks() -> &'static serde_json::Value { + LOGIN_JWKS.wait() +} + pub fn encode_jwt(claims: &T) -> String { match jsonwebtoken::encode(&JWT_HEADER, claims, PRIVATE_RSA_KEY.wait()) { Ok(token) => token, diff --git a/src/config.rs b/src/config.rs index 49281b6c..a1e6789b 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,24 @@ 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") + } + let kc_url = cfg.key_connector_url.to_lowercase(); + if !kc_url.starts_with("http://") && !kc_url.starts_with("https://") || Url::parse(&kc_url).is_err() { + err!("`KEY_CONNECTOR_URL` must be a valid URL containing the protocol (http, https)") + } + } + 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(_) => { diff --git a/src/util.rs b/src/util.rs index 91f075d1..6fd68bfd 100644 --- a/src/util.rs +++ b/src/util.rs @@ -128,11 +128,21 @@ impl Fairing for AppHeaders { https://app.addy.io/api/ \ https://api.fastmail.com/ \ https://api.forwardemail.net \ + {key_connector_src} \ {allowed_connect_src};\ ", icon_service_csp = CONFIG._icon_service_csp(), allowed_iframe_ancestors = CONFIG.allowed_iframe_ancestors(), allowed_connect_src = CONFIG.allowed_connect_src(), + // The web vault fetches the master key from the key connector, so it + // has to be allowed to connect to it. Only use the origin here, a + // query or fragment in the URL would break the source expression. + key_connector_src = if CONFIG.key_connector_enabled() { + url::Url::parse(&CONFIG.key_connector_url()) + .map_or_else(|_| String::new(), |u| u.origin().ascii_serialization()) + } else { + String::new() + }, ) };