Browse Source

Merge e00fa4352d into 58a980f850

pull/7769/merge
Timshel 5 days ago
committed by GitHub
parent
commit
c24c6c0028
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 3
      .env.template
  2. 4
      migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/down.sql
  3. 13
      migrations/mysql/2026-03-29-120000_add_device_trusted_encryption/up.sql
  4. 4
      migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/down.sql
  5. 4
      migrations/postgresql/2026-03-29-120000_add_device_trusted_encryption/up.sql
  6. 0
      migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/down.sql
  7. 3
      migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/up.sql
  8. 1
      playwright/docker-compose.yml
  9. 55
      playwright/tests/setups/sso.ts
  10. 144
      playwright/tests/sso_trusted.spec.ts
  11. 123
      src/api/core/accounts.rs
  12. 26
      src/api/core/ciphers.rs
  13. 3
      src/api/core/sends.rs
  14. 57
      src/api/identity.rs
  15. 1
      src/api/mod.rs
  16. 83
      src/api/user_decryption.rs
  17. 2
      src/config.rs
  18. 78
      src/db/models/device.rs
  19. 12
      src/db/models/organization.rs
  20. 9
      src/db/models/user.rs
  21. 3
      src/db/schema.rs

3
.env.template

@ -552,6 +552,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

4
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;

13
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;

4
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;

4
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;

0
migrations/sqlite/2026-03-29-120000_add_device_trusted_encryption/down.sql

3
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;

1
playwright/docker-compose.yml

@ -38,6 +38,7 @@ services:
- SSO_FRONTEND
- SSO_ONLY
- SSO_SCOPES
- SSO_TRUSTED_DEVICE_ENCRYPTION
restart: "no"
depends_on:
- VaultwardenPrebuild

55
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;

144
playwright/tests/sso_trusted.spec.ts

@ -0,0 +1,144 @@
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 landing(test, page, users.user1, { noReset: true });
await expect(page).toHaveTitle(/Vaults/);
});
}
export async function logOutUnlockTrusted(test: Test, page: Page) {
await test.step('Log out', async () => {
await page.getByRole('button', { name: users.user1.name, exact: true }).click();
await page.getByRole('menuitem', { name: 'Log out' }).click();
});
await trustedUnlock(test, page);
}
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 logOutUnlockTrusted(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 logOutUnlockTrusted(test, page2);
});
let newPassword = "TotoNewPassword";
await test.step('Rotate key', async () => {
await page.getByRole('button', { name: users.user1.name }).click();
await page.getByRole('menuitem', { name: 'Account settings' }).click();
await page.getByRole('link', { name: 'Security' }).click();
await page.getByRole('link', { name: 'Master password' }).click();
await expect(page.getByRole('heading', { name: 'Change master password' })).toBeVisible();
await page.getByRole('textbox', { name: 'Current master password * (required)' }).fill(users.user1.password);
await page.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('textbox', { name: 'Confirm new master password * (required)', exact: true }).fill(newPassword);
await page.getByRole('checkbox', { name: 'Check known data breaches for this password' }).uncheck();
await page.getByRole('checkbox', { name: "Also rotate my account's encryption key" }).check();
await page.getByRole('button', { name: 'Yes' }).click();
await page.getByRole('button', { name: 'Change master password' }).click();
await expect(page.getByRole('heading', { name: 'Log in', exact: true })).toBeVisible();
await expect(page2.getByRole('heading', { name: 'Log in', exact: true })).toBeVisible();
});
await test.step('Still trusted', async () => {
await trustedUnlock(test, page);
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(newPassword);
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();
});

123
src/api/core/accounts.rs

@ -1,4 +1,4 @@
use std::collections::HashSet;
use std::collections::{HashMap, HashSet};
use chrono::Utc;
use rocket::{
@ -66,10 +66,13 @@ pub fn routes() -> Vec<rocket::Route> {
get_known_device,
get_all_devices,
get_device,
post_device_lost_trust,
post_device_token,
post_devices_untrust,
put_device_token,
put_clear_device_token,
post_clear_device_token,
put_device_keys,
get_tasks,
post_auth_request,
get_auth_request,
@ -832,6 +835,9 @@ struct RotateAccountUnlockData {
emergency_access_unlock_data: Vec<UpdateEmergencyAccessData>,
master_password_unlock_data: MasterPasswordUnlockData,
organization_account_recovery_unlock_data: Vec<UpdateResetPasswordData>,
#[serde(default)]
device_key_unlock_data: Vec<UpdateDeviceKeysData>,
}
#[derive(Deserialize)]
@ -846,6 +852,14 @@ struct MasterPasswordUnlockData {
master_key_encrypted_user_key: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct UpdateDeviceKeysData {
device_id: DeviceId,
encrypted_user_key: String,
encrypted_public_key: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct RotateAccountKeys {
@ -861,9 +875,11 @@ struct RotateAccountData {
sends: Vec<SendData>,
}
#[allow(clippy::too_many_arguments)]
fn validate_keydata(
data: &KeyData,
existing_ciphers: &[Cipher],
existing_devices: &[Device],
existing_folders: &[Folder],
existing_emergency_access: &[EmergencyAccess],
existing_memberships: &[Membership],
@ -929,6 +945,15 @@ fn validate_keydata(
err!("All existing reset password keys must be included in the rotation")
}
// Check that we're correctly rotating all the user's trusted device
let existing_trusted_device_ids =
existing_devices.iter().filter(|d| d.is_trusted()).map(|c| &c.uuid).collect::<HashSet<&DeviceId>>();
let provided_device_ids =
data.account_unlock_data.device_key_unlock_data.iter().map(|rp| &rp.device_id).collect::<HashSet<&DeviceId>>();
if !provided_device_ids.is_superset(&existing_trusted_device_ids) {
err!("All existing trusted device keys must be included in the rotation")
}
// Check that we're correctly rotating all the user's sends
let existing_send_ids = existing_sends.iter().map(|s| &s.uuid).collect::<HashSet<&SendId>>();
let provided_send_ids = data.account_data.sends.iter().filter_map(|s| s.id.as_ref()).collect::<HashSet<&SendId>>();
@ -959,6 +984,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
// TODO: Ideally we'd do everything after this point in a single transaction.
let mut existing_ciphers = Cipher::find_owned_by_user(user_id, &conn).await;
let existing_devices = Device::find_by_user(user_id, &conn).await;
let mut existing_folders = Folder::find_by_user(user_id, &conn).await;
let mut existing_emergency_access = EmergencyAccess::find_all_confirmed_by_grantor_uuid(user_id, &conn).await;
let mut existing_memberships = Membership::find_by_user(user_id, &conn).await;
@ -969,6 +995,7 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
validate_keydata(
&data,
&existing_ciphers,
&existing_devices,
&existing_folders,
&existing_emergency_access,
&existing_memberships,
@ -1014,6 +1041,20 @@ async fn post_rotatekey(data: Json<KeyData>, headers: Headers, conn: DbConn, nt:
membership.save(&conn).await?;
}
// Update device
let mut device_updates: HashMap<DeviceId, UpdateDeviceKeysData> =
data.account_unlock_data.device_key_unlock_data.into_iter().map(|d| (d.device_id.clone(), d)).collect();
for mut device in existing_devices {
if let Some(update) = device_updates.remove(&device.uuid) {
device.encrypted_public_key = Some(update.encrypted_public_key);
device.encrypted_user_key = Some(update.encrypted_user_key);
device.save(true, &conn).await?;
} else if device.is_trusted() {
device.lost_trust();
device.save(true, &conn).await?;
}
}
// Update send data
for send_data in data.account_data.sends {
let Some(send) = send_data.id.as_ref().and_then(|id| existing_sends.iter_mut().find(|s| &s.uuid == id)) else {
@ -1565,6 +1606,52 @@ async fn get_all_devices(headers: Headers, conn: DbConn) -> JsonResult {
})))
}
/// Reported by a client that still holds a device key but did not get any keys back from us.
#[post("/devices/lost-trust")]
async fn post_device_lost_trust(headers: Headers, conn: DbConn) -> EmptyResult {
let mut device = headers.device;
warn!(
"Device {} ({}) of user {} still holds a device key, but has no trusted device keys on the server",
device.uuid,
DeviceType::from_i32(device.atype),
headers.user.uuid
);
// Still ensure that it's the case.
if device.lost_trust() {
device.save(true, &conn).await?;
}
Ok(())
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct DevicesUntrusted {
devices: Vec<DeviceId>,
}
#[post("/devices/untrust", data = "<data>")]
async fn post_devices_untrust(headers: Headers, data: Json<DevicesUntrusted>, conn: DbConn) -> EmptyResult {
let untrusted = data.into_inner();
if !untrusted.devices.is_empty() {
for device_id in untrusted.devices {
warn!("Cannot find user {} device {} to untrust", device_id, headers.user.email);
if let Some(mut device) = Device::find_by_uuid_and_user(&device_id, &headers.user.uuid, &conn).await {
if device.lost_trust() {
device.save(true, &conn).await?;
}
} else {
warn!("Cannot find user {} device {} to untrust", device_id, headers.user.email);
}
}
}
Ok(())
}
#[get("/devices/identifier/<device_id>")]
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 {
@ -1640,6 +1727,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/<device_id>/keys", data = "<data>")]
async fn put_device_keys(
device_id: DeviceId,
data: Json<DeviceKeysData>,
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!({

26
src/api/core/ciphers.rs

@ -174,26 +174,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
api::core::get_eq_domains(&headers, true).into_inner()
};
// This is very similar to the userDecryptionOptions sent in connect/token,
// but as of 2025-12-19 they're both using different casing conventions.
let has_master_password = !headers.user.password_hash.is_empty();
let master_password_unlock = if has_master_password {
json!({
"kdf": {
"kdfType": headers.user.client_kdf_type,
"iterations": headers.user.client_kdf_iter,
"memory": headers.user.client_kdf_memory,
"parallelism": headers.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": headers.user.akey,
"masterKeyWrappedUserKey": headers.user.akey,
"salt": headers.user.email
})
} else {
Value::Null
};
let user_decryption = api::user_decryption::build_sync_user_decryption(&headers.user, &headers.device, &conn).await;
Ok(Json(json!({
"profile": user_json,
@ -204,10 +185,7 @@ async fn sync(data: SyncData, headers: Headers, client_version: Option<ClientVer
"ciphers": ciphers_json,
"domains": domains_json,
"sends": sends_json,
"userDecryption": {
"masterPasswordUnlock": master_password_unlock,
"userKeyId": headers.user.key_id,
},
"userDecryption": user_decryption,
"object": "sync"
})))
}

3
src/api/core/sends.rs

@ -35,6 +35,9 @@ static ANON_PUSH_DEVICE: LazyLock<Device> = LazyLock::new(|| {
push_token: None,
refresh_token: String::new(),
twofactor_remember: None,
encrypted_private_key: None,
encrypted_public_key: None,
encrypted_user_key: None,
}
});

57
src/api/identity.rs

@ -364,7 +364,7 @@ async fn sso_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbCon
// 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(data: ConnectData, user_id: &mut Option<UserId>, conn: &DbConn, ip: &ClientIp) -> JsonResult {
@ -495,7 +495,7 @@ async fn password_login(data: ConnectData, user_id: &mut Option<UserId>, conn: &
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(
@ -505,6 +505,7 @@ async fn authenticated_response(
twofactor_token: Option<String>,
conn: &DbConn,
ip: &ClientIp,
sso_login: bool,
) -> JsonResult {
if CONFIG.mail_enabled() && device.is_new() {
let now = Utc::now().naive_utc();
@ -532,24 +533,8 @@ async fn authenticated_response(
let master_password_policy = identity_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!({
@ -578,11 +563,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() {
@ -689,24 +670,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!({
@ -737,11 +702,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))

1
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;

83
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())
}

2
src/config.rs

@ -844,6 +844,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

78
src/db/models/device.rs

@ -33,6 +33,13 @@ pub struct Device {
pub refresh_token: String,
pub twofactor_remember: Option<String>,
/// Device private key encrypted with the device key (trusted-device / TDE).
pub encrypted_private_key: Option<String>,
/// Device public key encrypted with the user key.
pub encrypted_public_key: Option<String>,
/// User symmetric key encrypted with the device public key.
pub encrypted_user_key: Option<String>,
}
/// 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,19 @@ 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 lost_trust(&mut self) -> bool {
let lost = self.is_trusted();
self.encrypted_private_key = None;
self.encrypted_public_key = None;
self.encrypted_user_key = None;
lost
}
pub fn to_json(&self) -> Value {
json!({
"id": self.uuid,
@ -68,11 +92,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 +134,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 +191,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",
})
}

12
src/db/models/organization.rs

@ -56,6 +56,18 @@ pub struct Membership {
pub external_id: Option<String>,
}
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))]

9
src/db/models/user.rs

@ -624,4 +624,13 @@ impl SsoUser {
})
.await
}
pub async fn find_by_user(user_uuid: &UserId, conn: &DbConn) -> Option<Self> {
db_run! { conn: {
sso_users::table
.filter(sso_users::user_uuid.eq(user_uuid))
.first::<Self>(conn)
.ok()
}}
}
}

3
src/db/schema.rs

@ -55,6 +55,9 @@ table! {
push_token -> Nullable<Text>,
refresh_token -> Text,
twofactor_remember -> Nullable<Text>,
encrypted_private_key -> Nullable<Text>,
encrypted_public_key -> Nullable<Text>,
encrypted_user_key -> Nullable<Text>,
}
}

Loading…
Cancel
Save