From 9b7d04ee46b236f1d9682170aff5571edb7d0273 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:52:05 +0100 Subject: [PATCH] fix(reauth): wrong master pw re-prompts inline instead of closing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- js/app.import.js | 37 ++++--------------------------------- js/app.js | 39 +++++++++++++++++++++++++++++++++++++++ js/app.unlock.js | 31 ++++++------------------------- 3 files changed, 49 insertions(+), 58 deletions(-) diff --git a/js/app.import.js b/js/app.import.js index 5d1a8fd..780f64b 100644 --- a/js/app.import.js +++ b/js/app.import.js @@ -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, diff --git a/js/app.js b/js/app.js index 23cbe5c..ec3ef25 100644 --- a/js/app.js +++ b/js/app.js @@ -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 diff --git a/js/app.unlock.js b/js/app.unlock.js index e6477bb..e648e97 100644 --- a/js/app.unlock.js +++ b/js/app.unlock.js @@ -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;