Browse Source

Merge db3726b623 into 32098ca7d1

pull/7769/merge
Timshel 2 days ago
committed by GitHub
parent
commit
2e3f8e41f9
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. 114
      playwright/tests/sso_trusted.spec.ts
  11. 48
      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. 70
      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

@ -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

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;

114
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();
});

48
src/api/core/accounts.rs

@ -66,10 +66,12 @@ pub fn routes() -> Vec<rocket::Route> {
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/<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 {
@ -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/<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

@ -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<String>,
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))

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

@ -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

70
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,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",
})
}

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

@ -609,4 +609,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