From 90b2cf08a04064d4ea34c4668f53fa77e42f3d91 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:59:41 +0000 Subject: [PATCH 1/3] Support new emergency access password payload --- src/api/core/accounts.rs | 9 +++++ src/api/core/emergency_access.rs | 61 ++++++++++++++++++++++++++++---- 2 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 69be1334..2b14a269 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -91,6 +91,15 @@ pub struct KDFData { kdf_parallelism: Option, } +impl KDFData { + pub(super) fn matches_user(&self, user: &User) -> bool { + self.kdf == user.client_kdf_type + && self.kdf_iterations == user.client_kdf_iter + && self.kdf_memory == user.client_kdf_memory + && self.kdf_parallelism == user.client_kdf_parallelism + } +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterData { diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index 2eb95502..beb340ba 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -6,7 +6,7 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, - core::{CipherSyncData, CipherSyncType}, + core::{CipherSyncData, CipherSyncType, accounts::KDFData}, }, auth::{Headers, decode_emergency_access_invite}, db::{ @@ -615,17 +615,39 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, + "salt": master_password_salt(&grantor_user), "object": "emergencyAccessTakeover", }); Ok(Json(result)) } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EmergencyAccessAuthenticationData { + salt: String, + kdf: KDFData, + master_password_authentication_hash: String, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct EmergencyAccessUnlockData { + salt: String, + kdf: KDFData, + master_key_wrapped_user_key: String, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { - new_master_password_hash: String, - key: String, + // Legacy payload + new_master_password_hash: Option, + key: Option, + + // Current payload + authentication_data: Option, + unlock_data: Option, } #[post("/emergency-access//password", data = "")] @@ -638,8 +660,6 @@ async fn password_emergency_access( check_emergency_access_enabled()?; let data: EmergencyAccessPasswordData = data.into_inner(); - let new_master_password_hash = &data.new_master_password_hash; - //let key = &data.Key; let requesting_user = headers.user; let Some(emergency_access) = @@ -656,8 +676,33 @@ async fn password_emergency_access( err!("Grantor user not found.") }; + let (new_master_password_hash, new_key) = + if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) { + if authentication_data.kdf != unlock_data.kdf { + err!("KDF settings must be equal for authentication and unlock") + } + + if authentication_data.salt != unlock_data.salt { + err!("Invalid master password salt") + } + + if !authentication_data.kdf.matches_user(&grantor_user) { + err!("KDF settings do not match the grantor account") + } + + if authentication_data.salt != master_password_salt(&grantor_user) { + err!("Invalid master password salt") + } + + (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) + } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { + (new_master_password_hash, new_key) + } else { + err!("Invalid request!") + }; + // change grantor_user password - grantor_user.set_password(new_master_password_hash, Some(data.key), true, None, &conn).await?; + grantor_user.set_password(&new_master_password_hash, Some(new_key), true, None, &conn).await?; grantor_user.save(&conn).await?; // Disable TwoFactor providers since they will otherwise block logins @@ -701,6 +746,10 @@ async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, }))) } +fn master_password_salt(user: &User) -> String { + user.email.trim().to_lowercase() +} + fn is_valid_request( emergency_access: &EmergencyAccess, requesting_user_id: &UserId, From 6525041dc7739b7840c8d2483d9cb13322ec35d9 Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Thu, 17 Sep 2026 19:44:41 +0200 Subject: [PATCH 2/3] Apply PR 7747 review changes to emergency access --- src/api/core/accounts.rs | 32 +++++++++++++++--------- src/api/core/emergency_access.rs | 43 ++++++-------------------------- src/db/models/user.rs | 4 +++ 3 files changed, 32 insertions(+), 47 deletions(-) diff --git a/src/api/core/accounts.rs b/src/api/core/accounts.rs index 2b14a269..556ba1f4 100644 --- a/src/api/core/accounts.rs +++ b/src/api/core/accounts.rs @@ -708,18 +708,32 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult { #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct AuthenticationData { +pub(super) struct AuthenticationData { salt: String, - kdf: KDFData, - master_password_authentication_hash: String, + pub(super) kdf: KDFData, + pub(super) master_password_authentication_hash: String, +} + +impl AuthenticationData { + pub(super) fn check(&self, user: &User, unlock: &UnlockData) -> EmptyResult { + if self.kdf != unlock.kdf { + err!("KDF settings must be equal for authentication and unlock") + } + + if self.salt != user.master_password_salt() || self.salt != unlock.salt { + err!("Invalid master password salt") + } + + Ok(()) + } } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] -struct UnlockData { +pub(super) struct UnlockData { salt: String, kdf: KDFData, - master_key_wrapped_user_key: String, + pub(super) master_key_wrapped_user_key: String, } #[derive(Deserialize)] @@ -738,13 +752,7 @@ async fn post_kdf(data: Json, headers: Headers, conn: DbConn, nt: err!("Invalid password") } - if data.authentication_data.kdf != data.unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - if headers.user.email != data.authentication_data.salt || headers.user.email != data.unlock_data.salt { - err!("Invalid master password salt") - } + data.authentication_data.check(&headers.user, &data.unlock_data)?; let mut user = headers.user; diff --git a/src/api/core/emergency_access.rs b/src/api/core/emergency_access.rs index beb340ba..293ff2e0 100644 --- a/src/api/core/emergency_access.rs +++ b/src/api/core/emergency_access.rs @@ -6,7 +6,10 @@ use crate::{ CONFIG, api::{ EmptyResult, JsonResult, - core::{CipherSyncData, CipherSyncType, accounts::KDFData}, + core::{ + CipherSyncData, CipherSyncType, + accounts::{AuthenticationData, UnlockData}, + }, }, auth::{Headers, decode_emergency_access_invite}, db::{ @@ -615,29 +618,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers, "kdfMemory": grantor_user.client_kdf_memory, "kdfParallelism": grantor_user.client_kdf_parallelism, "keyEncrypted": &emergency_access.key_encrypted, - "salt": master_password_salt(&grantor_user), + "salt": grantor_user.master_password_salt(), "object": "emergencyAccessTakeover", }); Ok(Json(result)) } -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EmergencyAccessAuthenticationData { - salt: String, - kdf: KDFData, - master_password_authentication_hash: String, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -struct EmergencyAccessUnlockData { - salt: String, - kdf: KDFData, - master_key_wrapped_user_key: String, -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct EmergencyAccessPasswordData { @@ -646,8 +633,8 @@ struct EmergencyAccessPasswordData { key: Option, // Current payload - authentication_data: Option, - unlock_data: Option, + authentication_data: Option, + unlock_data: Option, } #[post("/emergency-access//password", data = "")] @@ -678,22 +665,12 @@ async fn password_emergency_access( let (new_master_password_hash, new_key) = if let (Some(authentication_data), Some(unlock_data)) = (data.authentication_data, data.unlock_data) { - if authentication_data.kdf != unlock_data.kdf { - err!("KDF settings must be equal for authentication and unlock") - } - - if authentication_data.salt != unlock_data.salt { - err!("Invalid master password salt") - } + authentication_data.check(&grantor_user, &unlock_data)?; if !authentication_data.kdf.matches_user(&grantor_user) { err!("KDF settings do not match the grantor account") } - if authentication_data.salt != master_password_salt(&grantor_user) { - err!("Invalid master password salt") - } - (authentication_data.master_password_authentication_hash, unlock_data.master_key_wrapped_user_key) } else if let (Some(new_master_password_hash), Some(new_key)) = (data.new_master_password_hash, data.key) { (new_master_password_hash, new_key) @@ -746,10 +723,6 @@ async fn policies_emergency_access(emer_id: EmergencyAccessId, headers: Headers, }))) } -fn master_password_salt(user: &User) -> String { - user.email.trim().to_lowercase() -} - fn is_valid_request( emergency_access: &EmergencyAccess, requesting_user_id: &UserId, diff --git a/src/db/models/user.rs b/src/db/models/user.rs index 81cb8d84..17bfb425 100644 --- a/src/db/models/user.rs +++ b/src/db/models/user.rs @@ -166,6 +166,10 @@ impl User { ) } + pub fn master_password_salt(&self) -> String { + self.email.trim().to_lowercase() + } + pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool { if let Some(ref totp_recover) = self.totp_recover { crypto::ct_eq(recovery_code, totp_recover.to_lowercase()) From 3aa44e7194ee4509c01c8fd0d37221d81c4a3ecd Mon Sep 17 00:00:00 2001 From: tom27052006 <83423411+tom27052006@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:48:56 +0200 Subject: [PATCH 3/3] Add Playwright test for emergency access takeover --- playwright/tests/emergency.spec.ts | 131 ++++++++++++++++++ .../send_emergency_access_invite.html.hbs | 2 +- 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100644 playwright/tests/emergency.spec.ts diff --git a/playwright/tests/emergency.spec.ts b/playwright/tests/emergency.spec.ts new file mode 100644 index 00000000..4c836986 --- /dev/null +++ b/playwright/tests/emergency.spec.ts @@ -0,0 +1,131 @@ +import { test, expect, type Page, type TestInfo, Test } from '@playwright/test'; +import { MailDev } from 'maildev'; + +import * as utils from "../global-utils"; +import { createAccount, logUser } from './setups/user'; +import { activateTOTP } from './setups/2fa'; + +let users = utils.loadEnv(); +let mailserver; + +test.beforeAll('Setup', async ({ browser }, testInfo: TestInfo) => { + mailserver = new MailDev({ + port: process.env.MAILDEV_SMTP_PORT, + web: { port: process.env.MAILDEV_HTTP_PORT }, + }) + + await mailserver.listen(); + + await utils.startVault(browser, testInfo, { + SMTP_HOST: process.env.MAILDEV_HOST, + SMTP_FROM: process.env.PW_SMTP_FROM, + }); +}); + +test.afterAll('Teardown', async ({}) => { + utils.stopVault(); + if( mailserver ){ + await mailserver.close(); + } +}); + +async function emergencyAccess(test: Test, page: Page, user: { name: string }) { + await test.step('Navigate', async () => { + await page.getByRole('button', { name: user.name }).click(); + await page.getByRole('menuitem', { name: 'Account settings' }).click(); + await page.getByRole('link', { name: 'Emergency access' }).click(); + await expect(page.locator('#main-content').getByText('Emergency access', { exact: true })).toBeVisible(); + }); +} + +test('Emergency access', async ({ browser, page }) => { + const context2 = await browser.newContext(); + const page2 = await context2.newPage(); + + const mailBuffer = mailserver.buffer(users.user1.email); + const mailBuffer2 = mailserver.buffer(users.user2.email); + + await createAccount(test, page, users.user1); + await createAccount(test, page2, users.user2); + + await test.step('Add test2', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Add emergency contact' }).click(); + await page.getByRole('textbox', { name: 'Email * (required)' }).fill(users.user2.email); + await page.getByRole('radio', { name: 'Takeover Can reset your' }).check(); + await page.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page, 'User(s) invited'); + }); + + await test.step('Accept', async () => { + const email = await mailBuffer2.expect((m) => m.subject === "Emergency access for " + users.user1.name); + const pageE = await context2.newPage(); + await pageE.setContent(email.html); + const link = await pageE.getByTestId("emergency").getAttribute("href"); + await pageE.close(); + + await page2.goto(link); + await utils.checkNotification(page2, 'Invitation accepted'); + }); + + await test.step('Confirm', async () => { + await emergencyAccess(test, page, users.user1); + await expect(page.locator('#main-content').getByText('Needs confirmation')).toBeVisible(); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Confirm' }).click(); + await page.getByRole('button', { name: 'Confirm' }).click(); + await utils.checkNotification(page, users.user2.name + ' confirmed'); + await mailBuffer2.expect((m) => m.subject === "Emergency access contact for " + users.user1.name + " confirmed"); + }); + + await test.step('Request', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Request Access' }).click(); + await page2.getByRole('button', { name: 'Request Access' }).click(); + await utils.checkNotification(page2, 'Emergency access requested'); + await mailBuffer.expect((m) => m.subject === "Emergency access request by " + users.user2.name + " initiated"); + }); + + await test.step('Approved', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Approve' }).click(); + await page.getByRole('button', { name: 'Approve' }).click(); + await utils.checkNotification(page, 'Emergency access approved'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request for " + users.user1.name + " approved"); + }); + await activateTOTP(test, page, users.user1); + + let newPassword = "TotoNewPassword"; + await test.step('Access', async () => { + await emergencyAccess(test, page2, users.user2); + await page2.getByRole('button', { name: 'Options' }).click(); + await page2.getByRole('menuitem', { name: 'Takeover' }).click(); + await page2.getByRole('textbox', { name: 'New master password * (required)', exact: true }).fill(newPassword); + await page2.getByRole('textbox', { name: 'Confirm new master password' }).fill(newPassword); + await page2.getByRole('button', { name: 'Save' }).click(); + await utils.checkNotification(page2, 'Password reset for ' + users.user1.name); + }); + + await test.step('Changed no 2fa', async () => { + users.user1.password = newPassword; + await logUser(test, page, users.user1); + }); + + await test.step('Reject', async () => { + await emergencyAccess(test, page, users.user1); + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Reject' }).click(); + await utils.checkNotification(page, 'Emergency access rejected'); + await mailBuffer2.expect((m) => m.subject === "Emergency access request to " + users.user1.name + " rejected"); + }); + + await test.step('Remove', async () => { + await page.getByRole('button', { name: 'Options' }).click(); + await page.getByRole('menuitem', { name: 'Remove' }).click(); + await page.getByRole('button', { name: 'Yes' }).click(); + await utils.checkNotification(page, 'Removed user ' + users.user2.name); + await expect(page.getByText('You have not added any emergency contacts')).toBeVisible(); + }); +}); diff --git a/src/static/templates/email/send_emergency_access_invite.html.hbs b/src/static/templates/email/send_emergency_access_invite.html.hbs index 5318378c..b5ab3a07 100644 --- a/src/static/templates/email/send_emergency_access_invite.html.hbs +++ b/src/static/templates/email/send_emergency_access_invite.html.hbs @@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}} - Become emergency contact