fix(reauth): wrong master pw re-prompts inline instead of closing

Setting a PIN, enabling Quick Unlock, and generating a recovery key all
closed the reauth modal and showed a toast on the first wrong master
password — no retry. Export already looped inline; the others didn't.

Extracted that loop into reauthLoop(message): re-prompts with an inline
error + attempt count (5 tries), honours the 429 lockout, returns the
verified password or null. All four sensitive-action flows now share it, so
a mistyped master password keeps the modal up to try again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-24 23:52:05 +01:00
parent b542106c76
commit 9b7d04ee46
3 changed files with 49 additions and 58 deletions
+4 -33
View File
@@ -791,39 +791,10 @@ async function doExport() {
// Step 1: reauth — verifies the human in front of the screen is the
// vault owner before we hand them every plaintext password. Defense
// against a stranger reaching the open laptop and exfiltrating data.
// Up to 5 retries with inline error in the modal — beyond that the
// server-side rate limiter takes over (429 with retry_after).
let lastError = null;
let attempts = 0;
const MAX_ATTEMPTS = 5;
let reauthed = false;
while (!reauthed) {
const masterPwd = await askReauth(
'Enter your master password to start an encrypted export.',
{ error: lastError });
if (!masterPwd) return; // user cancelled
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
reauthed = true;
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
return toast('Account locked. Try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
}
attempts++;
if (attempts >= MAX_ATTEMPTS) {
return toast('Too many wrong attempts — try again later', 'error');
}
lastError = 'Wrong master password. Attempt ' +
attempts + ' / ' + MAX_ATTEMPTS + '.';
}
}
// reauthLoop re-prompts inline (up to 5 tries) then defers to the
// server rate limiter; null = cancelled or capped (already toasted).
if (!(await reauthLoop('Enter your master password to start an encrypted export.')))
return;
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
// master pw so a master-pw change later doesn't invalidate the backup,
+39
View File
@@ -6022,6 +6022,45 @@ function closeReauth(ok) {
}
}
// Prompt for the master password and verify it via /reauth, RE-PROMPTING
// inline on a wrong entry (the modal stays up with an error + attempt count)
// instead of closing on the first miss. The single choke point for every
// sensitive-action reauth (export, PIN set, Quick Unlock, recovery) so they
// all behave the same. Returns the verified master password, or null when the
// user cancels or the attempt cap / server lockout is hit (already toasted).
async function reauthLoop(message) {
const MAX = 5;
let attempts = 0;
let lastError = '';
for (;;) {
const pwd = await askReauth(message, { error: lastError });
if (!pwd) return null; // cancelled
try {
const verifier = await computeVerifier(
pwd, state.salt, state.kdfIterations || 100000,
state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier }),
});
return pwd;
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
toast('Account locked. Try again in ' +
Math.ceil(err.body.retry_after / 60) + ' min', 'warning');
return null;
}
attempts++;
if (attempts >= MAX) {
toast('Too many wrong attempts — try again later', 'error');
return null;
}
lastError = 'Wrong master password. Attempt ' + attempts + ' / ' + MAX + '.';
}
}
}
// ============================================================
// QUICK UNLOCK + PIN + RECOVERY KEY — extracted to js/app.unlock.js (§3.1),
// loaded AFTER this file (it assigns Bridge.onQuickUnlockResult / onPinResult
+6 -25
View File
@@ -277,20 +277,9 @@ async function pinSetupFlow() {
// Setting/changing a PIN creates a new unlock path → treat as a
// sensitive operation. An unattended unlocked vault must not be
// possible for a passerby to pin-backdoor.
const masterPwd = await askReauth(
const masterPwd = await reauthLoop(
'Confirm your master password to set or change the PIN.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
let lastError = '';
let attempts = 0;
@@ -419,20 +408,9 @@ async function enableQuickUnlock() {
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
if (!state.cryptoKey) return toast('Unlock the vault first', 'warning');
const masterPwd = await askReauth(
const masterPwd = await reauthLoop(
'Confirm your master password to enable Quick unlock on this device.');
if (!masterPwd) return;
try {
const verifier = await computeVerifier(
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params);
await api('/reauth', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({ verifier: verifier }),
});
} catch (err) {
return toast('Wrong master password', 'error');
}
// Export the raw key + identity. We don't store the session token —
// tryQuickUnlock re-logs in with a verifier derived from the key,
@@ -674,7 +652,10 @@ async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCod
async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth(
// Pre-verify via reauthLoop (inline retry) so a wrong master pw re-prompts
// instead of failing the setup POST and closing the modal. The setup call
// below still carries the verifier — the server re-checks it.
const masterPwd = await reauthLoop(
'Confirm your master password to generate a recovery key.');
if (!masterPwd) return;