diff --git a/.env.template b/.env.template index 62231776..430c0540 100644 --- a/.env.template +++ b/.env.template @@ -558,6 +558,9 @@ ## 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 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..1f6bda26 --- /dev/null +++ b/migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,13 @@ +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/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..da16528c --- /dev/null +++ b/migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql @@ -0,0 +1,4 @@ +ALTER TABLE devices + 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/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/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/src/api/core/accounts.rs b/src/api/core/accounts.rs index 8cc5e55b..f89e2981 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -66,10 +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, get_tasks, post_auth_request, get_auth_request, @@ -1514,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 { @@ -1589,6 +1603,40 @@ async fn post_clear_device_token(device_id: DeviceId, ip: ClientIp, conn: DbConn put_clear_device_token(device_id, ip, conn).await } +#[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, +} + +// 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, + headers: Headers, + conn: DbConn, +) -> JsonResult { + if headers.device.uuid != device_id { + err!("No device found"); + } + + let mut device = headers.device; + let data = data.into_inner(); + + 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?; + + Ok(Json(device.to_json())) +} + #[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..ee924383 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, sso_login, conn).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, false, conn).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..c3e6273c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -4,6 +4,7 @@ mod icons; mod identity; mod notifications; mod push; +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..8e7f67eb --- /dev/null +++ b/src/api/user_decryption.rs @@ -0,0 +1,83 @@ +//! `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}; + +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 +} + +// 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, +) -> 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, + "userKeyId": user.key_id, + "object": "userDecryptionOptions" + }); + + 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; + } + + out +} + +// 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()) +} + +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 9f0ae2e1..7d13b22d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -843,6 +843,8 @@ 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 diff --git a/src/db/models/device.rs b/src/db/models/device.rs index 5e5f1f97..12548158 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,11 @@ 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.is_some() && self.encrypted_public_key.is_some() && self.encrypted_private_key.is_some() + } + pub fn to_json(&self) -> Value { json!({ "id": self.uuid, @@ -68,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.as_ref()), + "encryptedPublicKey": Self::enc_string_json(self.encrypted_public_key.as_ref()), "object":"device" }) } + fn enc_string_json(v: Option<&String>) -> 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}; @@ -101,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() } } @@ -123,9 +183,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.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/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, } }