From d101192e22dcb8b6b7e82359d9da6de47cf1cbfd Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:26:32 +0100 Subject: [PATCH] fix(recovery): don't commit the code until the user saves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Generating a recovery code POSTed /recovery-key/setup — which deletes any existing recovery row and inserts the new one — BEFORE showing the code. So cancelling the "save this code" modal still erased the old code and activated a new one the user never saw: a silent path to lockout. Generate + wrap locally, show the code, and only POST setup after "I saved it". Cancel now writes nothing — the account is left exactly as it was, old code still valid. Co-Authored-By: Claude Opus 4.8 --- js/app.unlock.js | 57 +++++++++++++++++++++++++++--------------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/js/app.unlock.js b/js/app.unlock.js index e648e97..476cb32 100644 --- a/js/app.unlock.js +++ b/js/app.unlock.js @@ -672,29 +672,12 @@ async function doGenerateRecoveryKey() { const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( rawKey, code, kdfSalt); - try { - // Send a verifier instead of the master pw — server proves the - // user still knows the master pw without ever seeing the plaintext. - const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); - await api('/recovery-key/setup', { - method: 'POST', - headers: authHeaders({ 'Content-Type': 'application/json' }), - body: JSON.stringify({ - verifier: verifier, - codeHash: codeHash, - kdfSalt: kdfSalt, - wrappedKey: wrappedKey, - wrappedIv: wrappedIv, - }), - }); - } catch (err) { - return toast('Setup failed: ' + (err.message || ''), 'error'); - } - - // Refresh the Settings button label - state.recoveryConfigured = true; - if ($('#recoveryStatus')) updateRecoveryStatusLabel(); + // Everything above is local — NOTHING is written server-side yet. The + // setup POST (which deletes any existing recovery row and inserts the new + // one) is deferred until AFTER the user acknowledges they saved the code. + // Committing before the modal meant backing out still erased the old code + // and activated a new one the user never saw → lockout. Cancel now leaves + // the account exactly as it was. // Show the code ONCE. Use the confirm modal so the user has to // explicitly click "I saved it" before the value vanishes. @@ -727,7 +710,7 @@ async function doGenerateRecoveryKey() { } }, 0); - await confirmDialog({ + const saved = await confirmDialog({ title: 'Your recovery code', message: '

Save this code somewhere safe (password manager, ' + 'safe deposit box, printed copy). It will not be ' + @@ -750,8 +733,32 @@ async function doGenerateRecoveryKey() { 'is permanently erased as soon as you successfully change ' + 'your master password — so set a new one right after ' + 'recovering.

', - okText: 'I saved it', + okText: 'I saved it', + cancelText: 'Cancel', }); + if (!saved) return toast('Cancelled — no recovery code was set'); + + // Acknowledged → commit. reauthLoop already verified the master pw; the + // server re-checks the verifier below. + try { + const verifier = await computeVerifier( + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); + await api('/recovery-key/setup', { + method: 'POST', + headers: authHeaders({ 'Content-Type': 'application/json' }), + body: JSON.stringify({ + verifier: verifier, + codeHash: codeHash, + kdfSalt: kdfSalt, + wrappedKey: wrappedKey, + wrappedIv: wrappedIv, + }), + }); + } catch (err) { + return toast('Setup failed: ' + (err.message || ''), 'error'); + } + state.recoveryConfigured = true; + if ($('#recoveryStatus')) updateRecoveryStatusLabel(); toast('Recovery code generated'); }