mirror of
https://github.com/dani-garcia/vaultwarden.wiki.git
synced 2026-10-23 00:38:09 +03:00
[Web 2026.9.0] Support new Emergency Access password payload (#7746)
* Support new emergency access password payload * Apply PR 7747 review changes to emergency access * Add Playwright test for emergency access takeover
This commit is contained in:
5 files changed
+193
-19
No files matched your search
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
+29
-12
@@ -93,6 +93,15 @@ pub struct KDFData {
|
|||||||
kdf_parallelism: Option<i32>,
|
kdf_parallelism: Option<i32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)]
|
#[derive(Debug, Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct RegisterData {
|
pub struct RegisterData {
|
||||||
@@ -717,21 +726,35 @@ fn set_kdf_data(user: &mut User, data: &KDFData) -> EmptyResult {
|
|||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct AuthenticationData {
|
pub(super) struct AuthenticationData {
|
||||||
#[serde(alias = "Salt")]
|
#[serde(alias = "Salt")]
|
||||||
salt: String,
|
salt: String,
|
||||||
#[serde(alias = "Kdf")]
|
#[serde(alias = "Kdf")]
|
||||||
kdf: KDFData,
|
pub(super) kdf: KDFData,
|
||||||
#[serde(alias = "MasterPasswordAuthenticationHash")]
|
#[serde(alias = "MasterPasswordAuthenticationHash")]
|
||||||
master_password_authentication_hash: String,
|
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)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct UnlockData {
|
pub(super) struct UnlockData {
|
||||||
salt: String,
|
salt: String,
|
||||||
kdf: KDFData,
|
kdf: KDFData,
|
||||||
master_key_wrapped_user_key: String,
|
pub(super) master_key_wrapped_user_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -750,13 +773,7 @@ async fn post_kdf(data: Json<ChangeKdfData>, headers: Headers, conn: DbConn, nt:
|
|||||||
err!("Invalid password")
|
err!("Invalid password")
|
||||||
}
|
}
|
||||||
|
|
||||||
if data.authentication_data.kdf != data.unlock_data.kdf {
|
data.authentication_data.check(&headers.user, &data.unlock_data)?;
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut user = headers.user;
|
let mut user = headers.user;
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,10 @@ use crate::{
|
|||||||
CONFIG,
|
CONFIG,
|
||||||
api::{
|
api::{
|
||||||
EmptyResult, JsonResult,
|
EmptyResult, JsonResult,
|
||||||
core::{CipherSyncData, CipherSyncType},
|
core::{
|
||||||
|
CipherSyncData, CipherSyncType,
|
||||||
|
accounts::{AuthenticationData, UnlockData},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
auth::{Headers, decode_emergency_access_invite},
|
auth::{Headers, decode_emergency_access_invite},
|
||||||
db::{
|
db::{
|
||||||
@@ -615,6 +618,7 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
"kdfMemory": grantor_user.client_kdf_memory,
|
"kdfMemory": grantor_user.client_kdf_memory,
|
||||||
"kdfParallelism": grantor_user.client_kdf_parallelism,
|
"kdfParallelism": grantor_user.client_kdf_parallelism,
|
||||||
"keyEncrypted": &emergency_access.key_encrypted,
|
"keyEncrypted": &emergency_access.key_encrypted,
|
||||||
|
"salt": grantor_user.master_password_salt(),
|
||||||
"object": "emergencyAccessTakeover",
|
"object": "emergencyAccessTakeover",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -624,8 +628,13 @@ async fn takeover_emergency_access(emer_id: EmergencyAccessId, headers: Headers,
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct EmergencyAccessPasswordData {
|
struct EmergencyAccessPasswordData {
|
||||||
new_master_password_hash: String,
|
// Legacy payload
|
||||||
key: String,
|
new_master_password_hash: Option<String>,
|
||||||
|
key: Option<String>,
|
||||||
|
|
||||||
|
// Current payload
|
||||||
|
authentication_data: Option<AuthenticationData>,
|
||||||
|
unlock_data: Option<UnlockData>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
#[post("/emergency-access/<emer_id>/password", data = "<data>")]
|
||||||
@@ -638,8 +647,6 @@ async fn password_emergency_access(
|
|||||||
check_emergency_access_enabled()?;
|
check_emergency_access_enabled()?;
|
||||||
|
|
||||||
let data: EmergencyAccessPasswordData = data.into_inner();
|
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 requesting_user = headers.user;
|
||||||
let Some(emergency_access) =
|
let Some(emergency_access) =
|
||||||
@@ -656,8 +663,23 @@ async fn password_emergency_access(
|
|||||||
err!("Grantor user not found.")
|
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) {
|
||||||
|
authentication_data.check(&grantor_user, &unlock_data)?;
|
||||||
|
|
||||||
|
if !authentication_data.kdf.matches_user(&grantor_user) {
|
||||||
|
err!("KDF settings do not match the grantor account")
|
||||||
|
}
|
||||||
|
|
||||||
|
(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
|
// 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?;
|
grantor_user.save(&conn).await?;
|
||||||
|
|
||||||
// Disable TwoFactor providers since they will otherwise block logins
|
// Disable TwoFactor providers since they will otherwise block logins
|
||||||
|
|||||||
@@ -170,6 +170,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 {
|
pub fn check_valid_recovery_code(&self, recovery_code: &str) -> bool {
|
||||||
if let Some(ref totp_recover) = self.totp_recover {
|
if let Some(ref totp_recover) = self.totp_recover {
|
||||||
crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
|
crypto::ct_eq(recovery_code, totp_recover.to_lowercase())
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ Emergency access for {{{grantor_name}}}
|
|||||||
</tr>
|
</tr>
|
||||||
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
<tr style="margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
<td class="content-block" style="font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; color: #333; line-height: 25px; margin: 0; -webkit-font-smoothing: antialiased; padding: 0 0 10px; -webkit-text-size-adjust: none; text-align: center;" valign="top" align="center">
|
||||||
<a href="{{{url}}}"
|
<a data-testid="emergency" href="{{{url}}}"
|
||||||
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
clicktracking=off target="_blank" style="color: #ffffff; text-decoration: none; text-align: center; cursor: pointer; display: inline-block; border-radius: 5px; background-color: #3c8dbc; border-color: #3c8dbc; border-style: solid; border-width: 10px 20px; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; box-sizing: border-box; font-size: 16px; line-height: 25px; -webkit-font-smoothing: antialiased; -webkit-text-size-adjust: none;">
|
||||||
Become emergency contact
|
Become emergency contact
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
Reference in new issue
Block a user