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:
+31
-24
@@ -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 ' +
|
||||||
@@ -751,7 +734,31 @@ async function doGenerateRecoveryKey() {
|
|||||||
'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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user