committed by
GitHub
21 changed files with 416 additions and 99 deletions
@ -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,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; |
|||
@ -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 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,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; |
|||
@ -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(); |
|||
}); |
|||
|
|||
@ -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()) |
|||
} |
|||
Loading…
Reference in new issue