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
|
// Step 1: reauth — verifies the human in front of the screen is the
|
||||||
// vault owner before we hand them every plaintext password. Defense
|
// vault owner before we hand them every plaintext password. Defense
|
||||||
// against a stranger reaching the open laptop and exfiltrating data.
|
// against a stranger reaching the open laptop and exfiltrating data.
|
||||||
// Up to 5 retries with inline error in the modal — beyond that the
|
// reauthLoop re-prompts inline (up to 5 tries) then defers to the
|
||||||
// server-side rate limiter takes over (429 with retry_after).
|
// server rate limiter; null = cancelled or capped (already toasted).
|
||||||
let lastError = null;
|
if (!(await reauthLoop('Enter your master password to start an encrypted export.')))
|
||||||
let attempts = 0;
|
return;
|
||||||
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 + '.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
|
// Step 2: ask for an INDEPENDENT export password. Decoupled from the
|
||||||
// master pw so a master-pw change later doesn't invalidate the backup,
|
// 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),
|
// QUICK UNLOCK + PIN + RECOVERY KEY — extracted to js/app.unlock.js (§3.1),
|
||||||
// loaded AFTER this file (it assigns Bridge.onQuickUnlockResult / onPinResult
|
// 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
|
// Setting/changing a PIN creates a new unlock path → treat as a
|
||||||
// sensitive operation. An unattended unlocked vault must not be
|
// sensitive operation. An unattended unlocked vault must not be
|
||||||
// possible for a passerby to pin-backdoor.
|
// 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.');
|
'Confirm your master password to set or change the PIN.');
|
||||||
if (!masterPwd) return;
|
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 lastError = '';
|
||||||
let attempts = 0;
|
let attempts = 0;
|
||||||
@@ -419,20 +408,9 @@ async function enableQuickUnlock() {
|
|||||||
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
|
if (!Bridge.active) return toast('Quick unlock requires the Delphi app', 'warning');
|
||||||
if (!state.cryptoKey) return toast('Unlock the vault first', '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.');
|
'Confirm your master password to enable Quick unlock on this device.');
|
||||||
if (!masterPwd) return;
|
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 —
|
// Export the raw key + identity. We don't store the session token —
|
||||||
// tryQuickUnlock re-logs in with a verifier derived from the key,
|
// tryQuickUnlock re-logs in with a verifier derived from the key,
|
||||||
@@ -674,7 +652,10 @@ async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCod
|
|||||||
async function doGenerateRecoveryKey() {
|
async function doGenerateRecoveryKey() {
|
||||||
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
|
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.');
|
'Confirm your master password to generate a recovery key.');
|
||||||
if (!masterPwd) return;
|
if (!masterPwd) return;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user