feat(recovery): single-use recovery code for forgotten master password

In a zero-knowledge vault, forgetting the master password normally
means losing the data — the AES key is derived from the master pw
and the server can't help. This commit adds the standard escape
hatch: a one-time recovery code that key-wraps the AES key so the
user can get back in.

Threat model
============
The plaintext recovery code is shown to the user exactly once, at
generation time. Server only ever stores SHA-256(code) + an AES-GCM
wrap of the vault key under a KEK = PBKDF2(code, kdf_salt, 600k).
Without the plaintext code the server cannot unwrap. The code is
high-entropy (96 bits from a 32-char ambiguity-free alphabet, in 4
groups of 4) — printed form is misreading-resistant.

Single use: redeeming deletes the row inside the same DB.Lock the
lookup happened in, so concurrent redeem attempts are race-free.
Failed redemptions feed both the per-IP rate limit AND the per-
username lockout, so brute-forcing the code is infeasible.

Schema
======
recovery_keys (
  user_id      INTEGER PRIMARY KEY (1:1 with users, FK cascade),
  code_hash    TEXT NOT NULL          (SHA-256 hex of plaintext code),
  kdf_salt     TEXT NOT NULL          (PBKDF2 salt for KEK derivation),
  wrapped_key  TEXT NOT NULL          (base64 AES-GCM ciphertext of vault key),
  wrapped_iv   TEXT NOT NULL          (base64 12B IV for the wrap),
  created_at   DATETIME DEFAULT CURRENT_TIMESTAMP
)

Backend: new unit PM.Handler.Recovery
=====================================
  GET    /recovery-key/status   (auth)         -> { configured, created_at? }
  POST   /recovery-key/setup    (auth + CSRF)  body {masterPassword, codeHash,
                                                     kdfSalt, wrappedKey, wrappedIv}
  DELETE /recovery-key          (auth + CSRF)  -> remove config
  POST   /recovery-key/redeem   (NO auth)      body {username, code}
                                               -> session + wrappedKey + wrappedIv + kdfSalt
                                                  + user's current salt + kdfIterations

VerifyMasterPassword() helper handles both legacy 'pbkdf2' and
current 'pbkdf2-sha256' schemes consistently with PM.Handler.Auth.

Setup flow
==========
1. Settings → "Generate recovery code" button (asks master pw via reauth).
2. Client generates: 16-char code + fresh kdf_salt + exports the current
   AES key via crypto.subtle.exportKey('raw').
3. Client wraps the raw key under KEK=PBKDF2(code, kdf_salt, 600k)
   with a random 12B IV → base64.
4. POSTs to /recovery-key/setup. Server verifies master pw, INSERT-or-
   replaces the row (DELETE+INSERT, no UPSERT — same pattern as the
   lockout table since FireDAC's UPSERT support is patchy).
5. Confirm modal shows the plaintext code in a monospace, user-select-all
   panel. The modal is forcing: "I saved it" button is the only way out.
   Modal is the only place the code ever appears — server never sees it.

Redeem flow (forgot master pw)
==============================
1. Auth screen → "Forgot master password? Use a recovery code" link.
2. promptDialog: username, then code (masked input).
3. POST /recovery-key/redeem. Server hashes the typed code, joins with
   users by username, ConstantTimeEquals against stored hash. On match:
     - deletes the recovery_keys row (single-use)
     - issues a fresh session token + CSRF
     - returns: { token, csrfToken, salt, kdfIterations, kdfSalt,
                  wrappedKey, wrappedIv, userId }
4. Client unwraps the AES key with PBKDF2(code, kdfSalt, 600k) → raw bytes
   → importKey('raw') back into a CryptoKey.
5. State is reconstituted from the new session, persistCryptoKey, enterApp.
6. Client immediately opens the Change-master-password modal — the
   recovery code is consumed and the account needs a fresh master pw
   AND a fresh recovery code (the user generates a new one from Settings).

Backward compat
===============
Recovery is opt-in. Existing users see "No recovery key set" in Settings
until they generate one. No migration needed — the table is created via
CREATE TABLE IF NOT EXISTS at server startup, FK cascade on user delete.

Minor UI additions
==================
 - .btn-link CSS class for the auth-screen "Forgot master password?" link.
 - Recovery-status label in Settings refreshed on every openSettings()
   via GET /recovery-key/status.
This commit is contained in:
2026-05-23 11:18:34 +01:00
parent cca8184b81
commit 01c56edf25
7 changed files with 695 additions and 1 deletions
+257
View File
@@ -2409,6 +2409,256 @@ function closeReauth(ok) {
}
}
// ============================================================
// RECOVERY KEY — one-shot emergency access
// ============================================================
//
// Generated at the user's request from Settings. The plaintext code is
// shown exactly once; the server stores only SHA-256(code) for lookup
// + an AES-GCM wrap of the current vault key under a KEK derived from
// PBKDF2(code, kdfSalt, 600k).
//
// Recovery flow (master pw forgotten):
// 1. Auth screen → "Use recovery code" → enter username + code
// 2. Server hashes code, looks up user, verifies match, DELETES the
// recovery row (single-use), returns wrapped key + KEK salt +
// a fresh session.
// 3. Client derives the KEK, unwraps the AES key.
// 4. Client immediately forces a master-password change so the
// account isn't left with the recovery code's KEK as the only
// escape hatch.
// Random recovery code: 16 chars in 4 groups of 4. ~96 bits entropy
// from a 36-char alphabet (no ambiguous chars: no 0/O/I/l/1) so the
// printed form is misreading-resistant.
function generateRecoveryCode() {
const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // 32 chars
const bytes = crypto.getRandomValues(new Uint8Array(16));
let s = '';
for (let i = 0; i < 16; i++) {
if (i > 0 && i % 4 === 0) s += '-';
s += A[bytes[i] % A.length];
}
return s;
}
async function sha256HexLocal(input) {
const buf = new TextEncoder().encode(input);
const hashBuf = await crypto.subtle.digest('SHA-256', buf);
const bytes = new Uint8Array(hashBuf);
let hex = '';
for (const b of bytes) hex += b.toString(16).padStart(2, '0');
return hex;
}
// Derive a KEK from the recovery code + per-row salt, then wrap the
// supplied AES key bytes under it. Returns base64 ciphertext + IV.
async function wrapAesKeyForRecovery(aesKeyBytes, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex); // match deriveKey's quirk
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, kek, aesKeyBytes);
return { wrappedKey: bytesToBase64(ct), wrappedIv: bytesToBase64(iv) };
}
async function unwrapAesKeyFromRecovery(wrappedKeyB64, wrappedIvB64, recoveryCode, kdfSaltHex) {
const saltBytes = new TextEncoder().encode(kdfSaltHex);
const km = await crypto.subtle.importKey(
'raw', new TextEncoder().encode(recoveryCode),
'PBKDF2', false, ['deriveKey']);
const kek = await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: saltBytes, iterations: 600000, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
false, ['encrypt', 'decrypt']);
const iv = base64ToBytes(wrappedIvB64);
const ct = base64ToBytes(wrappedKeyB64);
return await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, kek, ct); // raw bytes
}
// Generate a new recovery key for the logged-in user. Shows the plaintext
// code in a modal that the user must explicitly acknowledge before closing.
async function doGenerateRecoveryKey() {
if (!state.cryptoKey) { return toast('Vault locked', 'warning'); }
const masterPwd = await askReauth(
'Confirm your master password to generate a recovery key.');
if (!masterPwd) return;
// Generate the code + a fresh per-row salt for the KEK PBKDF2. Salt is
// per-recovery so regenerating doesn't reuse the same KDF parameters.
const code = generateRecoveryCode();
const codeHash = await sha256HexLocal(code);
const kdfSalt = randomHexSalt();
// Export the current AES key as raw bytes so we can wrap it under
// the recovery KEK. The export only works because deriveKey was
// called with `extractable=true` — already the case in our code.
const rawKey = await crypto.subtle.exportKey('raw', state.cryptoKey);
const { wrappedKey, wrappedIv } = await wrapAesKeyForRecovery(
rawKey, code, kdfSalt);
try {
await api('/recovery-key/setup', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
masterPassword: masterPwd,
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
// explicitly click "I saved it" before the value vanishes.
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 ' +
'shown again.</p>' +
'<p style="font-family:JetBrains Mono,monospace;font-size:20px;' +
'letter-spacing:2px;text-align:center;padding:14px;' +
'background:var(--bg-elev);border-radius:6px;user-select:all">' +
code + '</p>' +
'<p style="color:var(--text-dim);font-size:12px">' +
'Using it later will let you recover access if you forget ' +
'your master password. The code is single-use.</p>',
okText: 'I saved it',
});
toast('Recovery code generated');
}
async function doRemoveRecoveryKey() {
const ok = await confirmDialog({
title: 'Remove recovery key',
message: 'You will lose your ability to recover this account if you ' +
'forget the master password. Continue?',
okText: 'Remove',
danger: true,
});
if (!ok) return;
try {
await api('/recovery-key', {
method: 'DELETE',
headers: authHeaders(),
});
state.recoveryConfigured = false;
if ($('#recoveryStatus')) updateRecoveryStatusLabel();
toast('Recovery key removed');
} catch (err) { toast(err.message, 'error'); }
}
function updateRecoveryStatusLabel() {
const lbl = $('#recoveryStatus');
const setupBtn = $('#recoverySetupBtn');
const removeBtn = $('#recoveryRemoveBtn');
if (!lbl) return;
if (state.recoveryConfigured) {
lbl.textContent = 'Recovery key is configured.';
if (setupBtn) setupBtn.textContent = 'Regenerate code';
if (removeBtn) removeBtn.style.display = '';
} else {
lbl.textContent = 'No recovery key set.';
if (setupBtn) setupBtn.textContent = 'Generate recovery code';
if (removeBtn) removeBtn.style.display = 'none';
}
}
async function refreshRecoveryStatus() {
try {
const r = await api('/recovery-key/status', { headers: authHeaders() });
state.recoveryConfigured = !!r.configured;
updateRecoveryStatusLabel();
} catch (e) { /* ignore */ }
}
// Recovery redeem flow — called from the auth screen when the user clicks
// "Use a recovery code". Prompts for username + code, redeems, unwraps the
// vault key, immediately forces a master password change.
async function doRecoveryRedeem() {
const u = await promptDialog({
title: 'Recover access',
message: 'Enter your username — we\'ll ask for the recovery code next.',
placeholder: 'Username',
okText: 'Continue',
});
if (!u) return;
const code = await promptDialog({
title: 'Enter recovery code',
message: 'Recovery codes look like XXXX-XXXX-XXXX-XXXX. ' +
'They\'re single-use — using one will remove it from your account.',
placeholder: 'XXXX-XXXX-XXXX-XXXX',
okText: 'Recover',
password: true,
});
if (!code) return;
let r;
try {
r = await api('/recovery-key/redeem', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: u.trim(), code: code.trim() }),
});
} catch (err) {
if (err.status === 429 && err.body && err.body.retry_after) {
return showLockoutCountdown(err.body.retry_after);
}
return toast('Recovery failed: ' + (err.message || 'invalid code'), 'error');
}
// Unwrap the vault key with the code the user just typed.
let rawKey;
try {
rawKey = await unwrapAesKeyFromRecovery(
r.wrappedKey, r.wrappedIv, code.trim(), r.kdfSalt);
} catch (e) {
return toast('Could not decrypt vault — wrong code?', 'error');
}
// Reconstitute state from the new session.
state.token = r.token;
state.csrf = r.csrfToken;
state.salt = r.salt;
state.username = u.trim();
sessionStorage.setItem('authToken', state.token);
sessionStorage.setItem('csrfToken', state.csrf);
sessionStorage.setItem('salt', state.salt);
sessionStorage.setItem('username', state.username);
// Import the raw key bytes as a fresh AES-GCM CryptoKey (extractable
// so master-pw change can later re-export and re-wrap as needed).
state.cryptoKey = await crypto.subtle.importKey(
'raw', rawKey, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
await persistCryptoKey();
toast('Access recovered — please set a new master password');
await enterApp();
// Force a master pw change immediately. The recovery code is consumed
// (server deleted the row); the account is currently orphaned from
// a "we know who you are" perspective. Setting a new master pw both
// restores normal login AND lets the user generate a fresh recovery
// code afterwards.
setTimeout(openChangeMasterModal, 300);
}
// ============================================================
// CHANGE MASTER PASSWORD
// ============================================================
@@ -3044,6 +3294,8 @@ function openSettings() {
$('#settingMaskUser').checked = state.maskUsernames;
$('#settingHIBP').checked = state.hibpEnabled;
$('#settingUser').textContent = state.username;
// Async: query server for recovery key state and update the label
refreshRecoveryStatus();
$('#settingsPanel').classList.add('is-open');
}
function closeSettings() {
@@ -3337,6 +3589,11 @@ async function init() {
$$('#changeMasterModal [data-close]').forEach(b =>
b.addEventListener('click', closeChangeMasterModal));
// Recovery key
$('#recoverySetupBtn').addEventListener('click', doGenerateRecoveryKey);
$('#recoveryRemoveBtn').addEventListener('click', doRemoveRecoveryKey);
$('#recoveryBtn').addEventListener('click', doRecoveryRedeem);
// Re-auth modal
$('#reauthForm').addEventListener('submit', e => { e.preventDefault(); closeReauth(true); });
$$('#reauthModal [data-close]').forEach(b => b.addEventListener('click', () => closeReauth(false)));