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:
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user