mirror of
https://github.com/dani-garcia/vaultwarden.git
synced 2026-04-08 18:31:36 -06:00
Merge ed034ea55e into a6b43651ca
This commit is contained in:
commit
18020d5faa
@ -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 ###
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
ALTER TABLE devices
|
||||
DROP COLUMN encrypted_private_key,
|
||||
DROP COLUMN encrypted_public_key,
|
||||
DROP COLUMN encrypted_user_key;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -35,6 +35,7 @@ services:
|
||||
- SSO_FRONTEND
|
||||
- SSO_ONLY
|
||||
- SSO_SCOPES
|
||||
- SSO_TRUSTED_DEVICE_ENCRYPTION
|
||||
restart: "no"
|
||||
depends_on:
|
||||
- VaultwardenPrebuild
|
||||
|
||||
26
playwright/tests/sso_trusted_device.spec.ts
Normal file
26
playwright/tests/sso_trusted_device.spec.ts
Normal file
@ -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);
|
||||
});
|
||||
@ -65,6 +65,10 @@ pub fn routes() -> Vec<rocket::Route> {
|
||||
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,
|
||||
@ -1450,6 +1454,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/<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 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/<device_id>/keys", data = "<data>")]
|
||||
async fn post_device_keys(device_id: DeviceId, data: Json<DeviceKeysData>, 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/<device_id>/keys", data = "<data>")]
|
||||
async fn put_device_keys_by_uuid(device_id: DeviceId, data: Json<DeviceKeysData>, headers: Headers, conn: DbConn) -> JsonResult {
|
||||
put_device_keys(device_id, data, headers, conn).await
|
||||
}
|
||||
|
||||
#[post("/devices/<device_id>/keys", data = "<data>")]
|
||||
async fn post_device_keys_by_uuid(device_id: DeviceId, data: Json<DeviceKeysData>, 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!({
|
||||
|
||||
@ -163,26 +163,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 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,
|
||||
@ -192,9 +173,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,
|
||||
},
|
||||
"userDecryption": user_decryption,
|
||||
"object": "sync"
|
||||
})))
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -307,7 +307,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(
|
||||
@ -429,7 +429,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(
|
||||
@ -439,6 +439,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();
|
||||
@ -466,24 +467,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!({
|
||||
@ -513,11 +498,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() {
|
||||
@ -617,24 +598,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!({
|
||||
@ -666,11 +631,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))
|
||||
|
||||
@ -4,6 +4,7 @@ mod icons;
|
||||
mod identity;
|
||||
mod notifications;
|
||||
mod push;
|
||||
pub(crate) mod user_decryption;
|
||||
mod web;
|
||||
|
||||
use rocket::serde::json::Json;
|
||||
|
||||
218
src/api/user_decryption.rs
Normal file
218
src/api/user_decryption.rs
Normal file
@ -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));
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -31,6 +31,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
|
||||
@ -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<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};
|
||||
|
||||
@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@ -561,4 +561,13 @@ impl SsoUser {
|
||||
.map_res("Error deleting sso user")
|
||||
}}
|
||||
}
|
||||
|
||||
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()
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
10
src/main.rs
10
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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user