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