fix(recovery): don't commit the code until the user saves it

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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-25 00:26:32 +01:00
parent e7dca84f40
commit d101192e22
+32 -25
View File
@@ -672,29 +672,12 @@ async function doGenerateRecoveryKey() {
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery( const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt); rawKey, code, kdfSalt);
try { // Everything above is local — NOTHING is written server-side yet. The
// Send a verifier instead of the master pw — server proves the // setup POST (which deletes any existing recovery row and inserts the new
// user still knows the master pw without ever seeing the plaintext. // one) is deferred until AFTER the user acknowledges they saved the code.
const verifier = await computeVerifier( // Committing before the modal meant backing out still erased the old code
masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); // and activated a new one the user never saw → lockout. Cancel now leaves
await api('/recovery-key/setup', { // the account exactly as it was.
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();
// Show the code ONCE. Use the confirm modal so the user has to // Show the code ONCE. Use the confirm modal so the user has to
// explicitly click "I saved it" before the value vanishes. // explicitly click "I saved it" before the value vanishes.
@@ -727,7 +710,7 @@ async function doGenerateRecoveryKey() {
} }
}, 0); }, 0);
await confirmDialog({ const saved = await confirmDialog({
title: 'Your recovery code', title: 'Your recovery code',
message: '<p>Save this code somewhere safe (password manager, ' + message: '<p>Save this code somewhere safe (password manager, ' +
'safe deposit box, printed copy). It will <b>not</b> be ' + 'safe deposit box, printed copy). It will <b>not</b> be ' +
@@ -750,8 +733,32 @@ async function doGenerateRecoveryKey() {
'is permanently erased as soon as you successfully change ' + 'is permanently erased as soon as you successfully change ' +
'your master password — so set a new one right after ' + 'your master password — so set a new one right after ' +
'recovering.</p>', 'recovering.</p>',
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'); toast('Recovery code generated');
} }