From 7fe2505785576f7a8db72d13971f126f0371de9d Mon Sep 17 00:00:00 2001 From: rwjack Date: Mon, 30 Mar 2026 00:52:07 +0200 Subject: [PATCH 1/2] 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 | 49 ++++ src/api/core/ciphers.rs | 25 +- src/api/core/sends.rs | 3 + src/api/identity.rs | 57 +---- src/api/mod.rs | 1 + src/api/user_decryption.rs | 218 ++++++++++++++++++ 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(+), 76 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 03990820..28b8f814 100644 --- a/.env.template +++ b/.env.template @@ -531,6 +531,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 f4402326..576146bb 100644 --- a/playwright/docker-compose.yml +++ b/playwright/docker-compose.yml @@ -35,6 +35,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 e0869c63..9ffb9caa 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -65,6 +65,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, @@ -1452,6 +1456,51 @@ async fn post_clear_device_token(device_id: DeviceId, conn: DbConn) -> EmptyResu put_clear_device_token(device_id, 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 f7bf5cd3..e2c0a15b 100644 --- a/src/api/core/ciphers.rs +++ b/src/api/core/ciphers.rs @@ -162,26 +162,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 fcd8c388..b88198a9 100644 --- a/src/api/identity.rs +++ b/src/api/identity.rs @@ -304,7 +304,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( @@ -426,7 +426,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( @@ -436,6 +436,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(); @@ -463,24 +464,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!({ @@ -510,11 +495,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() { @@ -614,24 +595,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!({ @@ -663,11 +628,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 ecdf9408..6af0fa71 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..6a4400e8 --- /dev/null +++ b/src/api/user_decryption.rs @@ -0,0 +1,218 @@ +//! `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::{json, Value}; + +use crate::db::models::{Device, Membership, SsoUser, User, UserId}; +use crate::db::DbConn; +use crate::CONFIG; + +/// 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, + }); + + 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 6ff09467..d4b958fe 100644 --- a/src/config.rs +++ b/src/config.rs @@ -832,6 +832,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 1026574c..1b39cc0d 100644 --- a/src/db/models/device.rs +++ b/src/db/models/device.rs @@ -31,6 +31,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 @@ -51,6 +58,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, } } @@ -59,6 +70,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, @@ -66,11 +84,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}; @@ -121,9 +148,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 ebc72101..10506a6e 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -561,4 +561,13 @@ impl SsoUser { .map_res("Error deleting sso user") }} } + + 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 914b4fe9..118a44fb 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 8eef2e8c..1da85201 100644 --- a/src/main.rs +++ b/src/main.rs @@ -542,6 +542,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 d336689d..0318df5c 100644 --- a/src/util.rs +++ b/src/util.rs @@ -100,7 +100,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 ed034ea55e91e6cfc64a958f51fcdc7cc014a36d Mon Sep 17 00:00:00 2001 From: rwjack Date: Mon, 30 Mar 2026 13:46:58 +0200 Subject: [PATCH 2/2] revert cors --- src/util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/util.rs b/src/util.rs index 0318df5c..d336689d 100644 --- a/src/util.rs +++ b/src/util.rs @@ -100,7 +100,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; \