From 5692d19a68a691e8cd254e28ed171581e9432196 Mon Sep 17 00:00:00 2001 From: rwjack Date: Mon, 30 Mar 2026 00:52:07 +0200 Subject: [PATCH 1/3] add trusted device verification: https://github.com/dani-garcia/vaultwarden/discussions/6655 --- .env.template | 2 + .../down.sql | 4 + .../up.sql | 4 + .../down.sql | 4 + .../up.sql | 4 + .../down.sql | 0 .../up.sql | 3 + playwright/docker-compose.yml | 1 + playwright/tests/sso_trusted_device.spec.ts | 26 +++ src/api/core/accounts.rs | 71 +++++++ src/api/core/ciphers.rs | 26 +-- src/api/core/sends.rs | 3 + src/api/identity.rs | 57 +---- src/api/mod.rs | 1 + src/api/user_decryption.rs | 196 ++++++++++++++++++ src/config.rs | 2 + src/db/models/device.rs | 35 +++- src/db/models/user.rs | 9 + src/db/schema.rs | 3 + src/main.rs | 10 + src/util.rs | 2 +- 21 files changed, 386 insertions(+), 77 deletions(-) create mode 100644 migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/down.sql create mode 100644 migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql create mode 100644 migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/down.sql create mode 100644 migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql create mode 100644 migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/down.sql create mode 100644 migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/up.sql create mode 100644 playwright/tests/sso_trusted_device.spec.ts create mode 100644 src/api/user_decryption.rs diff --git a/.env.template b/.env.template index 62231776..4e5ede31 100644 --- a/.env.template +++ b/.env.template @@ -563,6 +563,8 @@ ## Log all the tokens, LOG_LEVEL=debug is required # SSO_DEBUG_TOKENS=false +## Trusted Device Encryption (TDE) for SSO — adds TrustedDeviceOption to SSO login responses (Bitwarden-compatible). +# SSO_TRUSTED_DEVICE_ENCRYPTION=false ######################## ### MFA/2FA settings ### diff --git a/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/down.sql b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..b20db475 --- /dev/null +++ b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE devices + DROP COLUMN encrypted_private_key, + DROP COLUMN encrypted_public_key, + DROP COLUMN encrypted_user_key; diff --git a/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..5b3dff5d --- /dev/null +++ b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE devices + ADD COLUMN encrypted_private_key TEXT NULL, + ADD COLUMN encrypted_public_key TEXT NULL, + ADD COLUMN encrypted_user_key TEXT NULL; diff --git a/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/down.sql b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..27d32774 --- /dev/null +++ b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/down.sql @@ -0,0 +1,4 @@ +ALTER TABLE devices + DROP COLUMN IF EXISTS encrypted_private_key, + DROP COLUMN IF EXISTS encrypted_public_key, + DROP COLUMN IF EXISTS encrypted_user_key; diff --git a/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..5b3dff5d --- /dev/null +++ b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE devices + ADD COLUMN encrypted_private_key TEXT NULL, + ADD COLUMN encrypted_public_key TEXT NULL, + ADD COLUMN encrypted_user_key TEXT NULL; diff --git a/migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/down.sql b/migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/down.sql new file mode 100644 index 00000000..e69de29b diff --git a/migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/up.sql b/migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/up.sql new file mode 100644 index 00000000..36de034e --- /dev/null +++ b/migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,3 @@ +ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT; +ALTER TABLE devices ADD COLUMN encrypted_public_key TEXT; +ALTER TABLE devices ADD COLUMN encrypted_user_key TEXT; diff --git a/playwright/docker-compose.yml b/playwright/docker-compose.yml index 5bfc47a5..dec52c5a 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -38,6 +38,7 @@ services: - SSO_FRONTEND - SSO_ONLY - SSO_SCOPES + - SSO_TRUSTED_DEVICE_ENCRYPTION restart: "no" depends_on: - VaultwardenPrebuild diff --git a/playwright/tests/sso_trusted_device.spec.ts b/playwright/tests/sso_trusted_device.spec.ts new file mode 100644 index 00000000..5ebfc83d --- /dev/null +++ b/playwright/tests/sso_trusted_device.spec.ts @@ -0,0 +1,26 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +import * as utils from '../global-utils'; + +/** + * Web-first checks for SSO + trusted-device (TDE) support: + * - `sso-connector.html` must be served for browser OIDC redirect. + */ +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, { + SSO_ENABLED: 'true', + SSO_ONLY: 'false', + SSO_TRUSTED_DEVICE_ENCRYPTION: 'true', + }); +}); + +test.afterAll('Teardown', async () => { + utils.stopVault(); +}); + +test('Web vault serves sso-connector.html for browser SSO', async ({ request }) => { + const res = await request.get('/sso-connector.html'); + expect(res.ok(), await res.text()).toBeTruthy(); + const ct = res.headers()['content-type'] || ''; + expect(ct).toMatch(/text\/html/i); +}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..447e4dec 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -70,6 +70,10 @@ pub fn routes() -> Vec { put_device_token, put_clear_device_token, post_clear_device_token, + put_device_keys, + post_device_keys, + put_device_keys_by_uuid, + post_device_keys_by_uuid, get_tasks, post_auth_request, get_auth_request, @@ -1589,6 +1593,73 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn put_clear_device_token(device_id, ip, conn).await } +// https://github.com/bitwarden/server/blob/v2026.3.1/src/Api/Controllers/DevicesController.cs +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct DeviceKeysData { + encrypted_user_key: String, + encrypted_public_key: String, + encrypted_private_key: String, +} + +#[put("/devices/identifier//keys", data = "")] +async fn put_device_keys( + device_id: DeviceId, + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + if headers.device.uuid != device_id { + err!("No device found"); + } + let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else { + err!("No device found"); + }; + let data = data.into_inner(); + if data.encrypted_user_key.is_empty() + || data.encrypted_public_key.is_empty() + || data.encrypted_private_key.is_empty() + { + err!("Invalid device keys"); + } + device.encrypted_user_key = Some(data.encrypted_user_key); + device.encrypted_public_key = Some(data.encrypted_public_key); + device.encrypted_private_key = Some(data.encrypted_private_key); + device.save(true, &conn).await?; + Ok(Json(device.to_json())) +} + +#[post("/devices/identifier//keys", data = "")] +async fn post_device_keys( + device_id: DeviceId, + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + put_device_keys(device_id, data, headers, conn).await +} + +// Bitwarden server: `PUT|POST devices/{identifier}/keys` (not `devices/identifier/.../keys`). +#[put("/devices//keys", data = "")] +async fn put_device_keys_by_uuid( + device_id: DeviceId, + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + put_device_keys(device_id, data, headers, conn).await +} + +#[post("/devices//keys", data = "")] +async fn post_device_keys_by_uuid( + device_id: DeviceId, + data: Json, + headers: Headers, + conn: DbConn, +) -> JsonResult { + put_device_keys(device_id, data, headers, conn).await +} + #[get("/tasks")] fn get_tasks(_client_headers: ClientHeaders) -> JsonResult { Ok(Json(json!({ diff --git a/src/api/core/ciphers.rs b/src/api/core/ciphers.rs index a8c6aea0..434c14c8 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -174,26 +174,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option = LazyLock::new(|| { push_token: None, refresh_token: String::new(), twofactor_remember: None, + encrypted_private_key: None, + encrypted_public_key: None, + encrypted_user_key: None, } }); diff --git a/src/api/identity.rs b/src/api/identity.rs index 6808ddde..95f6415d 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -382,7 +382,7 @@ async fn sso_login( // We passed 2FA get auth tokens let auth_tokens = sso::redeem(&device, &user, data.client_id, sso_user, sso_auth, user_infos, conn).await?; - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip, true).await } async fn password_login( @@ -504,7 +504,7 @@ async fn password_login( let auth_tokens = auth::AuthTokens::new(&device, &user, AuthMethod::Password, data.client_id); - authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip).await + authenticated_response(&user, &mut device, auth_tokens, twofactor_token, conn, ip, false).await } async fn authenticated_response( @@ -514,6 +514,7 @@ async fn authenticated_response( twofactor_token: Option, conn: &DbConn, ip: &ClientIp, + sso_login: bool, ) -> JsonResult { if CONFIG.mail_enabled() && device.is_new() { let now = Utc::now().naive_utc(); @@ -541,24 +542,8 @@ async fn authenticated_response( let master_password_policy = master_password_policy(user, conn).await; - let has_master_password = !user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "Kdf": { - "KdfType": user.client_kdf_type, - "Iterations": user.client_kdf_iter, - "Memory": user.client_kdf_memory, - "Parallelism": user.client_kdf_parallelism - }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 - "MasterKeyEncryptedUserKey": user.akey, - "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email - }) - } else { - Value::Null - }; + let user_decryption_options = + super::user_decryption::build_token_user_decryption_options(user, device, conn, sso_login).await; let account_keys = if user.private_key.is_some() { json!({ @@ -588,11 +573,7 @@ async fn authenticated_response( "MasterPasswordPolicy": master_password_policy, "scope": auth_tokens.scope(), "AccountKeys": account_keys, - "UserDecryptionOptions": { - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, - "Object": "userDecryptionOptions" - }, + "UserDecryptionOptions": user_decryption_options, }); if !user.akey.is_empty() { @@ -692,24 +673,8 @@ async fn user_api_key_login( info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); - let has_master_password = !user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "Kdf": { - "KdfType": user.client_kdf_type, - "Iterations": user.client_kdf_iter, - "Memory": user.client_kdf_memory, - "Parallelism": user.client_kdf_parallelism - }, - // This field is named inconsistently and will be removed and replaced by the "wrapped" variant in the apps. - // https://github.com/bitwarden/android/blob/release/2025.12-rc41/network/src/main/kotlin/com/bitwarden/network/model/MasterPasswordUnlockDataJson.kt#L22-L26 - "MasterKeyEncryptedUserKey": user.akey, - "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email - }) - } else { - Value::Null - }; + let user_decryption_options = + super::user_decryption::build_token_user_decryption_options(&user, &device, conn, false).await; let account_keys = if user.private_key.is_some() { json!({ @@ -741,11 +706,7 @@ async fn user_api_key_login( "ForcePasswordReset": false, "scope": AuthMethod::UserApiKey.scope(), "AccountKeys": account_keys, - "UserDecryptionOptions": { - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, - "Object": "userDecryptionOptions" - }, + "UserDecryptionOptions": user_decryption_options, }); Ok(Json(result)) diff --git a/src/api/mod.rs b/src/api/mod.rs index 9a79ce95..449e84a8 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ mod icons; mod identity; mod notifications; mod push; +pub(crate) mod user_decryption; mod web; use rocket::serde::json::Json; diff --git a/src/api/user_decryption.rs b/src/api/user_decryption.rs new file mode 100644 index 00000000..037f2b80 --- /dev/null +++ b/src/api/user_decryption.rs @@ -0,0 +1,196 @@ +//! `UserDecryptionOptions` (login) and `userDecryption` (sync) payloads for Bitwarden-compatible clients. +//! +//! References: Bitwarden `UserDecryptionOptionsBuilder`, `TrustedDeviceUserDecryptionOption`, and +//! `libs/common/.../user-decryption-options.response.ts` in bitwarden/clients. + +use serde_json::{Value, json}; + +use crate::CONFIG; +use crate::db::DbConn; +use crate::db::models::{Device, Membership, SsoUser, User, UserId}; + +/// Device types that may approve “login with device” / trusted-device flows (see Bitwarden `LoginApprovingClientTypes`). +pub fn device_type_can_approve_trusted_login(atype: i32) -> bool { + !matches!(atype, 21..=25) // SDK, Server, CLIs +} + +async fn has_login_approving_device(user_uuid: &UserId, current: &Device, conn: &DbConn) -> bool { + let devices = Device::find_by_user(user_uuid, conn).await; + devices.iter().any(|d| d.uuid != current.uuid && device_type_can_approve_trusted_login(d.atype)) +} + +fn has_valid_reset_password_key(m: &Membership) -> bool { + m.reset_password_key.as_ref().is_some_and(|s| !s.trim().is_empty()) +} + +/// Owner or Admin (Vaultwarden does not persist custom-role JSON for `manageResetPassword` on members). +fn membership_has_manage_reset_password(m: &Membership) -> bool { + matches!(m.atype, 0 | 1) +} + +async fn aggregate_trusted_device_flags(user: &User, device: &Device, conn: &DbConn) -> (bool, bool, bool) { + let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; + let has_admin_approval = members.iter().any(has_valid_reset_password_key); + let has_manage_reset = members.iter().any(membership_has_manage_reset_password); + let has_login_approving = has_login_approving_device(&user.uuid, device, conn).await; + (has_admin_approval, has_manage_reset, has_login_approving) +} + +/// Sync may be called long after SSO login; include TDE hints for users linked to SSO. +async fn user_in_sso_context(user_uuid: &UserId, conn: &DbConn) -> bool { + if !CONFIG.sso_enabled() { + return false; + } + SsoUser::find_by_user(user_uuid, conn).await.is_some() +} + +fn trusted_device_option_token( + has_admin_approval: bool, + has_login_approving_device: bool, + has_manage_reset_password_permission: bool, + is_tde_offboarding: bool, + device: &Device, +) -> Value { + let (enc_priv, enc_user) = if device.is_trusted() { + ( + device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), + device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), + ) + } else { + (Value::Null, Value::Null) + }; + + json!({ + "HasAdminApproval": has_admin_approval, + "HasLoginApprovingDevice": has_login_approving_device, + "HasManageResetPasswordPermission": has_manage_reset_password_permission, + "IsTdeOffboarding": is_tde_offboarding, + "EncryptedPrivateKey": enc_priv, + "EncryptedUserKey": enc_user, + }) +} + +fn trusted_device_option_sync( + has_admin_approval: bool, + has_login_approving_device: bool, + has_manage_reset_password_permission: bool, + is_tde_offboarding: bool, + device: &Device, +) -> Value { + let (enc_priv, enc_user) = if device.is_trusted() { + ( + device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), + device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), + ) + } else { + (Value::Null, Value::Null) + }; + + json!({ + "hasAdminApproval": has_admin_approval, + "hasLoginApprovingDevice": has_login_approving_device, + "hasManageResetPasswordPermission": has_manage_reset_password_permission, + "isTdeOffboarding": is_tde_offboarding, + "encryptedPrivateKey": enc_priv, + "encryptedUserKey": enc_user, + }) +} + +/// `UserDecryptionOptions` for `POST /identity/connect/token` (PascalCase, Bitwarden Identity). +pub async fn build_token_user_decryption_options( + user: &User, + device: &Device, + conn: &DbConn, + sso_login: bool, +) -> Value { + let has_master_password = !user.password_hash.is_empty(); + let master_password_unlock = if has_master_password { + json!({ + "Kdf": { + "KdfType": user.client_kdf_type, + "Iterations": user.client_kdf_iter, + "Memory": user.client_kdf_memory, + "Parallelism": user.client_kdf_parallelism + }, + "MasterKeyEncryptedUserKey": user.akey, + "MasterKeyWrappedUserKey": user.akey, + "Salt": user.email + }) + } else { + Value::Null + }; + + let mut out = json!({ + "HasMasterPassword": has_master_password, + "MasterPasswordUnlock": master_password_unlock, + "Object": "userDecryptionOptions" + }); + + // Bitwarden only builds trusted-device options when SSO Identity context exists (authorization_code grant). + if !sso_login { + return out; + } + + let is_tde_active = CONFIG.sso_trusted_device_encryption(); + let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active; + + if !is_tde_active && !is_tde_offboarding { + return out; + } + + let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await; + out["TrustedDeviceOption"] = trusted_device_option_token(ha, hl, hm, is_tde_offboarding, device); + out +} + +/// `userDecryption` object on full sync (camelCase nested keys; see `GET /sync`). +pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbConn) -> Value { + let has_master_password = !user.password_hash.is_empty(); + let master_password_unlock = if has_master_password { + json!({ + "kdf": { + "kdfType": user.client_kdf_type, + "iterations": user.client_kdf_iter, + "memory": user.client_kdf_memory, + "parallelism": user.client_kdf_parallelism + }, + "masterKeyEncryptedUserKey": user.akey, + "masterKeyWrappedUserKey": user.akey, + "salt": user.email + }) + } else { + Value::Null + }; + + let mut out = json!({ + "masterPasswordUnlock": master_password_unlock, + "userKeyId": user.key_id, + }); + + if !user_in_sso_context(&user.uuid, conn).await { + return out; + } + + let is_tde_active = CONFIG.sso_trusted_device_encryption(); + let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active; + + if !is_tde_active && !is_tde_offboarding { + return out; + } + + let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await; + out["trustedDeviceOption"] = trusted_device_option_sync(ha, hl, hm, is_tde_offboarding, device); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn device_type_approver_excludes_cli_and_server() { + assert!(device_type_can_approve_trusted_login(14)); + assert!(!device_type_can_approve_trusted_login(22)); + assert!(!device_type_can_approve_trusted_login(23)); + } +} diff --git a/src/config.rs b/src/config.rs index 9f0ae2e1..cd61eb27 100644 --- a/src/config.rs +++ b/src/config.rs @@ -847,6 +847,8 @@ 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; + /// Trusted Device Encryption (TDE) for SSO |> When enabled, SSO token responses include `TrustedDeviceOption` per Bitwarden Identity (`UserDecryptionOptions`). Requires clients that support TDE. See: https://bitwarden.com/help/sso-decryption-options/ + sso_trusted_device_encryption: bool, true, def, false; }, /// Yubikey settings diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 5e5f1f97..23f18477 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -33,6 +33,13 @@ pub struct Device { pub refresh_token: String, pub twofactor_remember: Option, + + /// Device private key encrypted with the device key (trusted-device / TDE). + pub encrypted_private_key: Option, + /// Device public key encrypted with the user key. + pub encrypted_public_key: Option, + /// User symmetric key encrypted with the device public key. + pub encrypted_user_key: Option, } /// Local methods @@ -53,6 +60,10 @@ impl Device { push_token: None, refresh_token: Device::generate_refresh_token(), twofactor_remember: None, + + encrypted_private_key: None, + encrypted_public_key: None, + encrypted_user_key: None, } } @@ -61,6 +72,13 @@ impl Device { crypto::encode_random_bytes::<64>(&BASE64URL) } + /// Matches upstream `DeviceExtensions.IsTrusted` / device list responses. + pub fn is_trusted(&self) -> bool { + self.encrypted_user_key.as_ref().is_some_and(|s| !s.is_empty()) + && self.encrypted_public_key.as_ref().is_some_and(|s| !s.is_empty()) + && self.encrypted_private_key.as_ref().is_some_and(|s| !s.is_empty()) + } + pub fn to_json(&self) -> Value { json!({ "id": self.uuid, @@ -68,11 +86,20 @@ impl Device { "type": self.atype, "identifier": self.uuid, "creationDate": format_date(&self.created_at), - "isTrusted": false, + "isTrusted": self.is_trusted(), + "encryptedUserKey": Self::enc_string_json(&self.encrypted_user_key), + "encryptedPublicKey": Self::enc_string_json(&self.encrypted_public_key), "object":"device" }) } + fn enc_string_json(v: &Option) -> Value { + match v { + Some(s) if !s.is_empty() => Value::String(s.clone()), + _ => Value::Null, + } + } + pub fn refresh_twofactor_remember(&mut self) -> String { use crate::auth::{encode_jwt, generate_2fa_remember_claims}; @@ -123,9 +150,9 @@ impl DeviceWithAuthRequest { "identifier": self.device.uuid, "creationDate": format_date(&self.device.created_at), "devicePendingAuthRequest": auth_request, - "isTrusted": false, - "encryptedPublicKey": null, - "encryptedUserKey": null, + "isTrusted": self.device.is_trusted(), + "encryptedPublicKey": Device::enc_string_json(&self.device.encrypted_public_key), + "encryptedUserKey": Device::enc_string_json(&self.device.encrypted_user_key), "object": "device", }) } diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 3412b142..9b514d1a 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -609,4 +609,13 @@ impl SsoUser { }) .await } + + pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option { + db_run! { conn: { + sso_users::table + .filter(sso_users::user_uuid.eq(user_uuid)) + .first::(conn) + .ok() + }} + } } diff --git a/src/db/schema.rs b/src/db/schema.rs index 98b1eda6..613593f6 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -55,6 +55,9 @@ table! { push_token -> Nullable, refresh_token -> Text, twofactor_remember -> Nullable, + encrypted_private_key -> Nullable, + encrypted_public_key -> Nullable, + encrypted_user_key -> Nullable, } } diff --git a/src/main.rs b/src/main.rs index 437354af..ac869b9b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -548,6 +548,16 @@ fn check_web_vault() { error!("You can also set the environment variable 'WEB_VAULT_ENABLED=false' to disable it"); exit(1); } + + if CONFIG.sso_enabled() { + let sso_connector = Path::new(&CONFIG.web_vault_folder()).join("sso-connector.html"); + if !sso_connector.is_file() { + warn!( + "Web vault is missing 'sso-connector.html' at '{}'. Browser OIDC SSO redirects to this file; install a current web vault or disable SSO.", + sso_connector.display() + ); + } + } } async fn create_db_pool() -> db::DbPool { diff --git a/src/util.rs b/src/util.rs index 6de2d803..ca5dbad2 100644 --- a/src/util.rs +++ b/src/util.rs @@ -109,7 +109,7 @@ impl Fairing for AppHeaders { form-action 'self'; \ media-src 'self'; \ object-src 'self' blob:; \ - script-src 'self' 'wasm-unsafe-eval'; \ + script-src 'self' 'wasm-unsafe-eval' 'sha256-ZswfTY7H35rbv8WC7NXBoiC7WNu86vSzCDChNWwZZDM='; \ style-src 'self' 'unsafe-inline'; \ child-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ frame-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ From d05066f85a3a16d4758c051f46adefcee842347f Mon Sep 17 00:00:00 2001 From: rwjack Date: Mon, 30 Mar 2026 13:46:58 +0200 Subject: [PATCH 2/3] revert cors --- src/util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util.rs b/src/util.rs index ca5dbad2..6de2d803 100644 --- a/src/util.rs +++ b/src/util.rs @@ -109,7 +109,7 @@ impl Fairing for AppHeaders { form-action 'self'; \ media-src 'self'; \ object-src 'self' blob:; \ - script-src 'self' 'wasm-unsafe-eval' 'sha256-ZswfTY7H35rbv8WC7NXBoiC7WNu86vSzCDChNWwZZDM='; \ + script-src 'self' 'wasm-unsafe-eval'; \ style-src 'self' 'unsafe-inline'; \ child-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ frame-src 'self' https://*.duosecurity.com https://*.duofederal.com; \ From db3726b623277cdc25dad763b66afcbac0b588fc Mon Sep 17 00:00:00 2001 From: Timshel Date: Thu, 24 Sep 2026 12:38:59 +0200 Subject: [PATCH 3/3] Improvements and tests --- .env.template | 5 +- .../up.sql | 17 +- .../up.sql | 6 +- playwright/tests/setups/sso.ts | 55 +++--- playwright/tests/sso_trusted.spec.ts | 114 +++++++++++ playwright/tests/sso_trusted_device.spec.ts | 26 --- src/api/core/accounts.rs | 73 +++---- src/api/identity.rs | 4 +- src/api/mod.rs | 2 +- src/api/user_decryption.rs | 187 ++++-------------- src/config.rs | 4 +- src/db/models/device.rs | 51 ++++- src/db/models/organization.rs | 12 ++ src/main.rs | 10 - 14 files changed, 287 insertions(+), 279 deletions(-) create mode 100644 playwright/tests/sso_trusted.spec.ts delete mode 100644 playwright/tests/sso_trusted_device.spec.ts diff --git a/.env.template b/.env.template index 4e5ede31..430c0540 100644 --- a/.env.template +++ b/.env.template @@ -558,13 +558,14 @@ ## Use sso only for authentication not the session lifecycle # SSO_AUTH_ONLY_NOT_SESSION=false +## Trusted Device Encryption (TDE) for SSO — adds TrustedDeviceOption to SSO login responses. +# SSO_TRUSTED_DEVICE_ENCRYPTION=false + ## Client cache for discovery endpoint. Duration in seconds (0 to disable). # SSO_CLIENT_CACHE_EXPIRATION=0 ## Log all the tokens, LOG_LEVEL=debug is required # SSO_DEBUG_TOKENS=false -## Trusted Device Encryption (TDE) for SSO — adds TrustedDeviceOption to SSO login responses (Bitwarden-compatible). -# SSO_TRUSTED_DEVICE_ENCRYPTION=false ######################## ### MFA/2FA settings ### diff --git a/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql index 5b3dff5d..1f6bda26 100644 --- a/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql +++ b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -1,4 +1,13 @@ -ALTER TABLE devices - ADD COLUMN encrypted_private_key TEXT NULL, - ADD COLUMN encrypted_public_key TEXT NULL, - ADD COLUMN encrypted_user_key TEXT NULL; +SELECT if ( + NOT EXISTS( + SELECT DISTINCT index_name FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = 'devices' + AND column_name = 'encrypted_private_key' + ) + ,'ALTER TABLE devices ADD COLUMN encrypted_private_key TEXT NULL, ADD COLUMN encrypted_public_key TEXT NULL, ADD COLUMN encrypted_user_key TEXT NULL' + ,'SELECT "info: column exist."' +) INTO @add_col_stmt; +PREPARE add_col_stmt FROM @add_col_stmt; +EXECUTE add_col_stmt; +DEALLOCATE PREPARE add_col_stmt; diff --git a/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql index 5b3dff5d..da16528c 100644 --- a/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql +++ b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -1,4 +1,4 @@ ALTER TABLE devices - ADD COLUMN encrypted_private_key TEXT NULL, - ADD COLUMN encrypted_public_key TEXT NULL, - ADD COLUMN encrypted_user_key TEXT NULL; + ADD COLUMN IF NOT EXISTS encrypted_private_key TEXT NULL, + ADD COLUMN IF NOT EXISTS encrypted_public_key TEXT NULL, + ADD COLUMN IF NOT EXISTS encrypted_user_key TEXT NULL; diff --git a/playwright/tests/setups/sso.ts b/playwright/tests/setups/sso.ts index 0ad0cffb..c330b84a 100644 --- a/playwright/tests/setups/sso.ts +++ b/playwright/tests/setups/sso.ts @@ -5,6 +5,35 @@ import * as OTPAuth from "otpauth"; import * as utils from '../../global-utils'; import { retrieveEmailCode } from './2fa'; +export async function landing( + test: Test, + page: Page, + user: { email: string, name: string, password: string }, + options: { noReset?: bool } = {} +){ + await test.step('Landing page', async () => { + if( !options.noReset ) { + await utils.cleanLanding(page); + } + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); + await page.locator("input[type=email].vw-email-sso").fill(user.email); + await page.getByRole('button', { name: /Use single sign-on/ }).click(); + }); +} + +export async function keycloak( + test: Test, + page: Page, + user: { name: string, password: string } +){ + await test.step('Keycloak login', async () => { + 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(); + }); +} + /** * If a MailBuffer is passed it will be used and consume the expected emails */ @@ -15,18 +44,9 @@ export async function logNewUser( options: { mailBuffer?: MailBuffer } = {} ) { await test.step(`Create user ${user.name}`, async () => { - await test.step('Landing page', async () => { - 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 landing(test, page, user); - await test.step('Keycloak login', async () => { - 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(); - }); + await keycloak(test, page, user); await test.step('Create Vault account', async () => { await expect(page.getByRole('heading', { name: 'Join organisation' })).toBeVisible(); @@ -70,18 +90,9 @@ export async function logUser( let mailBuffer = options.mailBuffer; await test.step(`Log user ${user.email}`, async () => { - await test.step('Landing page', async () => { - 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 landing(test, page, user); - await test.step('Keycloak login', async () => { - 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(); - }); + await keycloak(test, page, user); if( options.totp || options.mail2fa ){ let code; diff --git a/playwright/tests/sso_trusted.spec.ts b/playwright/tests/sso_trusted.spec.ts new file mode 100644 index 00000000..866d910c --- /dev/null +++ b/playwright/tests/sso_trusted.spec.ts @@ -0,0 +1,114 @@ +import { test, expect, type TestInfo } from '@playwright/test'; + +import { keycloak, landing, logNewUser, logUser } from './setups/sso'; +import { activateTOTP, disableTOTP } from './setups/2fa'; +import * as utils from "../global-utils"; + +let users = utils.loadEnv(); + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + await utils.startVault(browser, testInfo, { + SSO_ENABLED: true, + SSO_TRUSTED_DEVICE_ENCRYPTION: true, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); +}); + +export async function startTrusted(test: Test, page: Page) { + await landing(test, page, users.user1); + + await keycloak(test, page, users.user1); + + await test.step('Approval required', async () => { + await expect(page.getByRole('heading', { name: 'Device approval required' })).toBeVisible(); + }) +} + +export async function trustedUnlock(test: Test, page: Page) { + await test.step('Unlock', async () => { + await page.getByRole('button', { name: users.user1.name, exact: true }).click(); + await page.getByRole('menuitem', { name: 'Log out' }).click(); + + await landing(test, page, users.user1, { noReset: true }); + await expect(page).toHaveTitle(/Vaults/); + }); +} + +test('Trusted', async ({ browser, page }) => { + // No change to onboarding + await logNewUser(test, page, users.user1); + + await test.step('Password', async () => { + await startTrusted(test, page); + + await test.step('Only password', async () => { + await expect(page.getByRole('button', { name: 'Approve from your other device' })).toHaveCount(0); + await expect(page.getByRole('button', { name: 'Request admin approval' })).toHaveCount(0); + }); + + await test.step('Activate', async () => { + await page.getByRole('button', { name: 'Use master password' }).click(); + await expect(page.getByRole('heading', { name: 'Your vault is locked' })).toBeVisible(); + await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); + await page.getByRole('button', { name: 'Unlock' }).click(); + }); + + await test.step('Activated', async () => { + await expect(page).toHaveTitle(/Vaults/); + await utils.checkNotification(page, 'Device Trusted'); + }); + + await trustedUnlock(test, page); + }); + + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + await test.step('Approval', async () => { + await startTrusted(test, page2); + + await test.step('Request', async () => { + await page2.getByRole('button', { name: 'Approve from your other device' }).click(); + await expect(page2.getByRole('heading', { name: 'Request sent' })).toBeVisible(); + }); + + await test.step('Validate', async () => { + await page.getByText('You have a pending login').click(); + await page.getByRole('link', { name: 'Review login request' }).click(); + await expect(page.getByRole('heading', { name: 'Devices' })).toBeVisible(); + await page.getByRole('row').filter({hasText: "Request pending"}).getByRole('link').click(); + await page.getByRole('button', { name: 'Confirm access' }).click(); + await utils.checkNotification(page, 'Login request approved'); + }); + + await test.step('Validated', async () => { + await expect(page2).toHaveTitle(/Vaults/); + await utils.checkNotification(page2, 'Login Approved'); + await utils.checkNotification(page2, 'Device Trusted'); + }); + + await trustedUnlock(test, page2); + }); + + await test.step('Invalidate', async () => { + await page.getByRole('link', { name: 'Settings' }).click(); + await page.getByRole('button', { name: 'Deauthorise sessions' }).click();; + await expect(page.getByRole('heading', { name: 'Deauthorise sessions' })).toBeVisible(); + await page.getByRole('textbox', { name: 'Master password * (required)', exact: true }).fill(users.user1.password); + await page.getByRole('button', { name: 'Deauthorise sessions' }).click(); + }); + + await test.step('Invalidated', async () => { + await landing(test, page, users.user1, { noReset: true }); + await page.getByRole('heading', { name: 'Device approval required' }).click(); + + await landing(test, page2, users.user1, { noReset: true }); + await page2.getByRole('heading', { name: 'Device approval required' }).click(); + }); + + await context2.close(); +}); + diff --git a/playwright/tests/sso_trusted_device.spec.ts b/playwright/tests/sso_trusted_device.spec.ts deleted file mode 100644 index 5ebfc83d..00000000 --- a/playwright/tests/sso_trusted_device.spec.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { test, expect, type TestInfo } from '@playwright/test'; - -import * as utils from '../global-utils'; - -/** - * Web-first checks for SSO + trusted-device (TDE) support: - * - `sso-connector.html` must be served for browser OIDC redirect. - */ -test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { - await utils.startVault(browser, testInfo, { - SSO_ENABLED: 'true', - SSO_ONLY: 'false', - SSO_TRUSTED_DEVICE_ENCRYPTION: 'true', - }); -}); - -test.afterAll('Teardown', async () => { - utils.stopVault(); -}); - -test('Web vault serves sso-connector.html for browser SSO', async ({ request }) => { - const res = await request.get('/sso-connector.html'); - expect(res.ok(), await res.text()).toBeTruthy(); - const ct = res.headers()['content-type'] || ''; - expect(ct).toMatch(/text\/html/i); -}); diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 447e4dec..f89e2981 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -66,14 +66,12 @@ pub fn routes() -> Vec { get_known_device, get_all_devices, get_device, + post_device_lost_trust, post_device_token, put_device_token, put_clear_device_token, post_clear_device_token, put_device_keys, - post_device_keys, - put_device_keys_by_uuid, - post_device_keys_by_uuid, get_tasks, post_auth_request, get_auth_request, @@ -1518,6 +1516,18 @@ async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult { }))) } +#[post("/devices/lost-trust")] +async fn post_device_lost_trust(headers: Headers, conn: DbConn) -> JsonResult { + let mut device = headers.device; + + device.encrypted_user_key = None; + device.encrypted_public_key = None; + device.encrypted_private_key = None; + device.save(true, &conn).await?; + + Ok(Json(json!({}))) +} + #[get("/devices/identifier/")] async fn get_device(device_id: DeviceId, headers: Headers, conn: DbConn) -> JsonResult { let Some(device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else { @@ -1593,16 +1603,19 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn put_clear_device_token(device_id, ip, conn).await } -// https://github.com/bitwarden/server/blob/v2026.3.1/src/Api/Controllers/DevicesController.cs #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct DeviceKeysData { + #[serde(alias = "EncryptedUserKey")] encrypted_user_key: String, + #[serde(alias = "EncryptedPublicKey")] encrypted_public_key: String, + #[serde(alias = "EncryptedPrivateKey")] encrypted_private_key: String, } -#[put("/devices/identifier//keys", data = "")] +// https://github.com/bitwarden/server/blob/v2026.3.1/src/Api/Controllers/DevicesController.cs +#[put("/devices//keys", data = "")] async fn put_device_keys( device_id: DeviceId, data: Json, @@ -1612,52 +1625,16 @@ async fn put_device_keys( if headers.device.uuid != device_id { err!("No device found"); } - let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await else { - err!("No device found"); - }; - let data = data.into_inner(); - if data.encrypted_user_key.is_empty() - || data.encrypted_public_key.is_empty() - || data.encrypted_private_key.is_empty() - { - err!("Invalid device keys"); - } - device.encrypted_user_key = Some(data.encrypted_user_key); - device.encrypted_public_key = Some(data.encrypted_public_key); - device.encrypted_private_key = Some(data.encrypted_private_key); - device.save(true, &conn).await?; - Ok(Json(device.to_json())) -} -#[post("/devices/identifier//keys", data = "")] -async fn post_device_keys( - device_id: DeviceId, - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - put_device_keys(device_id, data, headers, conn).await -} + let mut device = headers.device; + let data = data.into_inner(); -// Bitwarden server: `PUT|POST devices/{identifier}/keys` (not `devices/identifier/.../keys`). -#[put("/devices//keys", data = "")] -async fn put_device_keys_by_uuid( - device_id: DeviceId, - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - put_device_keys(device_id, data, headers, conn).await -} + device.encrypted_user_key = Some(data.encrypted_user_key).filter(|k| !k.is_empty()); + device.encrypted_public_key = Some(data.encrypted_public_key).filter(|k| !k.is_empty()); + device.encrypted_private_key = Some(data.encrypted_private_key).filter(|k| !k.is_empty()); + device.save(true, &conn).await?; -#[post("/devices//keys", data = "")] -async fn post_device_keys_by_uuid( - device_id: DeviceId, - data: Json, - headers: Headers, - conn: DbConn, -) -> JsonResult { - put_device_keys(device_id, data, headers, conn).await + Ok(Json(device.to_json())) } #[get("/tasks")] diff --git a/src/api/identity.rs b/src/api/identity.rs index 95f6415d..ee924383 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -543,7 +543,7 @@ async fn authenticated_response( let master_password_policy = master_password_policy(user, conn).await; let user_decryption_options = - super::user_decryption::build_token_user_decryption_options(user, device, conn, sso_login).await; + super::user_decryption::build_token_user_decryption_options(user, device, sso_login, conn).await; let account_keys = if user.private_key.is_some() { json!({ @@ -674,7 +674,7 @@ async fn user_api_key_login( info!("User {} logged in successfully via API key. IP: {}", user.email, ip.ip); let user_decryption_options = - super::user_decryption::build_token_user_decryption_options(&user, &device, conn, false).await; + super::user_decryption::build_token_user_decryption_options(&user, &device, false, conn).await; let account_keys = if user.private_key.is_some() { json!({ diff --git a/src/api/mod.rs b/src/api/mod.rs index 449e84a8..c3e6273c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,7 +4,7 @@ mod icons; mod identity; mod notifications; mod push; -pub(crate) mod user_decryption; +mod user_decryption; mod web; use rocket::serde::json::Json; diff --git a/src/api/user_decryption.rs b/src/api/user_decryption.rs index 037f2b80..8e7f67eb 100644 --- a/src/api/user_decryption.rs +++ b/src/api/user_decryption.rs @@ -7,144 +7,22 @@ use serde_json::{Value, json}; use crate::CONFIG; use crate::db::DbConn; -use crate::db::models::{Device, Membership, SsoUser, User, UserId}; +use crate::db::models::{Device, Membership, SsoUser, User}; -/// Device types that may approve “login with device” / trusted-device flows (see Bitwarden `LoginApprovingClientTypes`). -pub fn device_type_can_approve_trusted_login(atype: i32) -> bool { - !matches!(atype, 21..=25) // SDK, Server, CLIs -} - -async fn has_login_approving_device(user_uuid: &UserId, current: &Device, conn: &DbConn) -> bool { - let devices = Device::find_by_user(user_uuid, conn).await; - devices.iter().any(|d| d.uuid != current.uuid && device_type_can_approve_trusted_login(d.atype)) -} - -fn has_valid_reset_password_key(m: &Membership) -> bool { - m.reset_password_key.as_ref().is_some_and(|s| !s.trim().is_empty()) -} - -/// Owner or Admin (Vaultwarden does not persist custom-role JSON for `manageResetPassword` on members). -fn membership_has_manage_reset_password(m: &Membership) -> bool { - matches!(m.atype, 0 | 1) -} - -async fn aggregate_trusted_device_flags(user: &User, device: &Device, conn: &DbConn) -> (bool, bool, bool) { - let members = Membership::find_confirmed_by_user(&user.uuid, conn).await; - let has_admin_approval = members.iter().any(has_valid_reset_password_key); - let has_manage_reset = members.iter().any(membership_has_manage_reset_password); - let has_login_approving = has_login_approving_device(&user.uuid, device, conn).await; - (has_admin_approval, has_manage_reset, has_login_approving) -} - -/// Sync may be called long after SSO login; include TDE hints for users linked to SSO. -async fn user_in_sso_context(user_uuid: &UserId, conn: &DbConn) -> bool { - if !CONFIG.sso_enabled() { - return false; - } - SsoUser::find_by_user(user_uuid, conn).await.is_some() -} - -fn trusted_device_option_token( - has_admin_approval: bool, - has_login_approving_device: bool, - has_manage_reset_password_permission: bool, - is_tde_offboarding: bool, - device: &Device, -) -> Value { - let (enc_priv, enc_user) = if device.is_trusted() { - ( - device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), - device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), - ) - } else { - (Value::Null, Value::Null) - }; - - json!({ - "HasAdminApproval": has_admin_approval, - "HasLoginApprovingDevice": has_login_approving_device, - "HasManageResetPasswordPermission": has_manage_reset_password_permission, - "IsTdeOffboarding": is_tde_offboarding, - "EncryptedPrivateKey": enc_priv, - "EncryptedUserKey": enc_user, - }) -} - -fn trusted_device_option_sync( - has_admin_approval: bool, - has_login_approving_device: bool, - has_manage_reset_password_permission: bool, - is_tde_offboarding: bool, - device: &Device, -) -> Value { - let (enc_priv, enc_user) = if device.is_trusted() { - ( - device.encrypted_private_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), - device.encrypted_user_key.as_ref().filter(|s| !s.is_empty()).map(|s| json!(s)).unwrap_or(Value::Null), - ) - } else { - (Value::Null, Value::Null) - }; - - json!({ - "hasAdminApproval": has_admin_approval, - "hasLoginApprovingDevice": has_login_approving_device, - "hasManageResetPasswordPermission": has_manage_reset_password_permission, - "isTdeOffboarding": is_tde_offboarding, - "encryptedPrivateKey": enc_priv, - "encryptedUserKey": enc_user, - }) +pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbConn) -> Value { + let with_trusted = + CONFIG.sso_enabled() && (CONFIG.sso_only() || SsoUser::find_by_user(&user.uuid, conn).await.is_some()); + build_token_user_decryption_options(user, device, with_trusted, conn).await } -/// `UserDecryptionOptions` for `POST /identity/connect/token` (PascalCase, Bitwarden Identity). +// Bitwarden only builds trusted-device options when SSO Identity context exists (authorization_code grant). +// Do not return the Trusted information if there is no master password (otherwise onboarding does not allow setting one) pub async fn build_token_user_decryption_options( user: &User, device: &Device, + with_trusted: bool, conn: &DbConn, - sso_login: bool, ) -> Value { - let has_master_password = !user.password_hash.is_empty(); - let master_password_unlock = if has_master_password { - json!({ - "Kdf": { - "KdfType": user.client_kdf_type, - "Iterations": user.client_kdf_iter, - "Memory": user.client_kdf_memory, - "Parallelism": user.client_kdf_parallelism - }, - "MasterKeyEncryptedUserKey": user.akey, - "MasterKeyWrappedUserKey": user.akey, - "Salt": user.email - }) - } else { - Value::Null - }; - - let mut out = json!({ - "HasMasterPassword": has_master_password, - "MasterPasswordUnlock": master_password_unlock, - "Object": "userDecryptionOptions" - }); - - // Bitwarden only builds trusted-device options when SSO Identity context exists (authorization_code grant). - if !sso_login { - return out; - } - - let is_tde_active = CONFIG.sso_trusted_device_encryption(); - let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active; - - if !is_tde_active && !is_tde_offboarding { - return out; - } - - let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await; - out["TrustedDeviceOption"] = trusted_device_option_token(ha, hl, hm, is_tde_offboarding, device); - out -} - -/// `userDecryption` object on full sync (camelCase nested keys; see `GET /sync`). -pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbConn) -> Value { let has_master_password = !user.password_hash.is_empty(); let master_password_unlock = if has_master_password { json!({ @@ -163,34 +41,43 @@ pub async fn build_sync_user_decryption(user: &User, device: &Device, conn: &DbC }; let mut out = json!({ + "hasMasterPassword": has_master_password, "masterPasswordUnlock": master_password_unlock, "userKeyId": user.key_id, + "object": "userDecryptionOptions" }); - if !user_in_sso_context(&user.uuid, conn).await { - return out; - } - - let is_tde_active = CONFIG.sso_trusted_device_encryption(); - let is_tde_offboarding = !has_master_password && device.is_trusted() && !is_tde_active; - - if !is_tde_active && !is_tde_offboarding { - return out; + if with_trusted && CONFIG.sso_trusted_device_encryption() && has_master_password { + let mut trusted = json!({ + "hasAdminApproval": false, + "hasLoginApprovingDevice": has_login_approving_device(user, device, conn).await, + "hasManageResetPasswordPermission": is_owner_admin(user, conn).await, + "isTdeOffboarding": false, + }); + + if let Some(key) = device.encrypted_user_key.as_ref() { + trusted["encryptedUserKey"] = json!(key); + trusted["EncryptedUserKey"] = json!(key); + } + + if let Some(key) = device.encrypted_private_key.as_ref() { + trusted["encryptedPrivateKey"] = json!(key); + trusted["EncryptedPrivateKey"] = json!(key); + } + + out["trustedDeviceOption"] = trusted.clone(); + out["TrustedDeviceOption"] = trusted; } - let (ha, hm, hl) = aggregate_trusted_device_flags(user, device, conn).await; - out["trustedDeviceOption"] = trusted_device_option_sync(ha, hl, hm, is_tde_offboarding, device); out } -#[cfg(test)] -mod tests { - use super::*; +// Details on trusted settings: +// https://github.com/bitwarden/clients/blob/web-v2026.4.2/libs/auth/src/common/models/domain/user-decryption-options.ts#L114 +async fn is_owner_admin(user: &User, conn: &DbConn) -> bool { + Membership::find_confirmed_by_user(&user.uuid, conn).await.iter().any(|m| m.is_owner() || m.is_admin()) +} - #[test] - fn device_type_approver_excludes_cli_and_server() { - assert!(device_type_can_approve_trusted_login(14)); - assert!(!device_type_can_approve_trusted_login(22)); - assert!(!device_type_can_approve_trusted_login(23)); - } +async fn has_login_approving_device(user: &User, device: &Device, conn: &DbConn) -> bool { + Device::find_by_user(&user.uuid, conn).await.iter().any(|d| d.uuid != device.uuid && d.can_approve_trusted_login()) } diff --git a/src/config.rs b/src/config.rs index cd61eb27..7d13b22d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -843,12 +843,12 @@ make_config! { sso_master_password_policy: String, true, option; /// Use SSO only for auth not the session lifecycle |> Use default Vaultwarden session lifecycle (Idle refresh token valid for 30days) sso_auth_only_not_session: bool, true, def, false; + /// Trusted Device Encryption (TDE) for SSO |> When enabled, SSO token responses include `TrustedDeviceOption` per Bitwarden Identity (`UserDecryptionOptions`). Requires clients that support TDE. See: https://bitwarden.com/help/sso-decryption-options/ + sso_trusted_device_encryption: bool, true, def, false; /// Client cache for discovery endpoint. |> Duration in seconds (0 or less to disable). More details: https://github.com/dani-garcia/vaultwarden/wiki/Enabling-SSO-support-using-OpenId-Connect#client-cache 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; - /// Trusted Device Encryption (TDE) for SSO |> When enabled, SSO token responses include `TrustedDeviceOption` per Bitwarden Identity (`UserDecryptionOptions`). Requires clients that support TDE. See: https://bitwarden.com/help/sso-decryption-options/ - sso_trusted_device_encryption: bool, true, def, false; }, /// Yubikey settings diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 23f18477..12548158 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -74,9 +74,7 @@ impl Device { /// Matches upstream `DeviceExtensions.IsTrusted` / device list responses. pub fn is_trusted(&self) -> bool { - self.encrypted_user_key.as_ref().is_some_and(|s| !s.is_empty()) - && self.encrypted_public_key.as_ref().is_some_and(|s| !s.is_empty()) - && self.encrypted_private_key.as_ref().is_some_and(|s| !s.is_empty()) + self.encrypted_user_key.is_some() && self.encrypted_public_key.is_some() && self.encrypted_private_key.is_some() } pub fn to_json(&self) -> Value { @@ -87,13 +85,13 @@ impl Device { "identifier": self.uuid, "creationDate": format_date(&self.created_at), "isTrusted": self.is_trusted(), - "encryptedUserKey": Self::enc_string_json(&self.encrypted_user_key), - "encryptedPublicKey": Self::enc_string_json(&self.encrypted_public_key), + "encryptedUserKey": Self::enc_string_json(self.encrypted_user_key.as_ref()), + "encryptedPublicKey": Self::enc_string_json(self.encrypted_public_key.as_ref()), "object":"device" }) } - fn enc_string_json(v: &Option) -> Value { + fn enc_string_json(v: Option<&String>) -> Value { match v { Some(s) if !s.is_empty() => Value::String(s.clone()), _ => Value::Null, @@ -128,7 +126,42 @@ impl Device { } pub fn is_mobile(&self) -> bool { - matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios) + matches!(DeviceType::from_i32(self.atype), DeviceType::Android | DeviceType::Ios | DeviceType::AndroidAmazon) + } + + pub fn is_browser(&self) -> bool { + matches!( + DeviceType::from_i32(self.atype), + DeviceType::ChromeBrowser + | DeviceType::FirefoxBrowser + | DeviceType::OperaBrowser + | DeviceType::EdgeBrowser + | DeviceType::IEBrowser + | DeviceType::UnknownBrowser + | DeviceType::DuckDuckGoBrowser + ) + } + + pub fn is_desktop(&self) -> bool { + matches!( + DeviceType::from_i32(self.atype), + DeviceType::WindowsDesktop | DeviceType::MacOsDesktop | DeviceType::LinuxDesktop + ) + } + + pub fn is_extension(&self) -> bool { + matches!( + DeviceType::from_i32(self.atype), + DeviceType::ChromeExtension + | DeviceType::FirefoxExtension + | DeviceType::OperaExtension + | DeviceType::EdgeExtension + ) + } + + // https://github.com/bitwarden/server/blob/v2026.4.2/src/Identity/Utilities/LoginApprovingClientTypes.cs + pub fn can_approve_trusted_login(&self) -> bool { + self.is_browser() || self.is_extension() || self.is_desktop() || self.is_mobile() } } @@ -151,8 +184,8 @@ impl DeviceWithAuthRequest { "creationDate": format_date(&self.device.created_at), "devicePendingAuthRequest": auth_request, "isTrusted": self.device.is_trusted(), - "encryptedPublicKey": Device::enc_string_json(&self.device.encrypted_public_key), - "encryptedUserKey": Device::enc_string_json(&self.device.encrypted_user_key), + "encryptedPublicKey": Device::enc_string_json(self.device.encrypted_public_key.as_ref()), + "encryptedUserKey": Device::enc_string_json(self.device.encrypted_user_key.as_ref()), "object": "device", }) } diff --git a/src/db/models/organization.rs b/src/db/models/organization.rs index 353a406e..674e1641 100644 --- a/src/db/models/organization.rs +++ b/src/db/models/organization.rs @@ -56,6 +56,18 @@ pub struct Membership { pub external_id: Option, } +impl Membership { + #[inline(always)] + pub fn is_admin(&self) -> bool { + self.atype == MembershipType::Admin as i32 + } + + #[inline(always)] + pub fn is_owner(&self) -> bool { + self.atype == MembershipType::Owner as i32 + } +} + #[derive(Identifiable, Queryable, Insertable, AsChangeset)] #[diesel(table_name = organization_api_key)] #[diesel(primary_key(uuid, org_uuid))] diff --git a/src/main.rs b/src/main.rs index ac869b9b..437354af 100644 --- a/src/main.rs +++ b/src/main.rs @@ -548,16 +548,6 @@ fn check_web_vault() { error!("You can also set the environment variable 'WEB_VAULT_ENABLED=false' to disable it"); exit(1); } - - if CONFIG.sso_enabled() { - let sso_connector = Path::new(&CONFIG.web_vault_folder()).join("sso-connector.html"); - if !sso_connector.is_file() { - warn!( - "Web vault is missing 'sso-connector.html' at '{}'. Browser OIDC SSO redirects to this file; install a current web vault or disable SSO.", - sso_connector.display() - ); - } - } } async fn create_db_pool() -> db::DbPool {