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:
+32
-25
@@ -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: '<p>Save this code somewhere safe (password manager, ' +
|
||||
'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 ' +
|
||||
'your master password — so set a new one right after ' +
|
||||
'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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user