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:
+4
-33
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user