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
+8
View File
@@ -116,6 +116,14 @@ svg { width: 16px; height: 16px; flex-shrink: 0; }
.btn-block { width: 100%; }
.btn-sm { padding: 6px 10px; font-size: 12px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-link {
background: transparent;
color: var(--text-dim);
border-color: transparent;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link:hover { color: var(--accent); background: transparent; }
.icon-btn {
display: inline-flex; align-items: center; justify-content: center;
@@ -0,0 +1,388 @@
unit PM.Handler.Recovery;
(*
Recovery key endpoints — one-time-use code that wraps the user's AES vault
key for emergency access when the master password is lost.
Threat model:
The server stores only SHA-256(code), never the plaintext. The wrapped_key
is AES-GCM ciphertext of the user's vault key under a KEK derived from
PBKDF2(code, kdf_salt, 600k). Without the plaintext code, the server
cannot unwrap the key on its own. The user is the only party that ever
has access to the plaintext, and only once (right after generation).
Single use:
Redeeming the recovery key deletes the row. The user is expected to set
a fresh master password and generate a new recovery key immediately
after, which the client does automatically via change-master-password
+ setup.
GET /recovery-key/status -> { configured: bool, created_at? }
POST /recovery-key/setup body { masterPassword, codeHash,
kdfSalt, wrappedKey, wrappedIv } -> { message }
DELETE /recovery-key -> { message }
POST /recovery-key/redeem body { username, code } -> session +
{ wrappedKey, wrappedIv, kdfSalt, salt,
kdfIterations, token, csrfToken, userId }
(NO session auth — this IS the auth)
*)
interface
implementation
uses
System.SysUtils, System.JSON,
FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit;
// Verifies the user's master password against their current stored hash.
// Used by /recovery-key/setup so a stolen session token alone can't set up
// a recovery backdoor.
function VerifyMasterPassword(AUserId: Integer; const APwd: string;
out AUsername: string): Boolean;
const
HASH_ALGO_LEGACY = 'pbkdf2';
HASH_ALGO_CURRENT = 'pbkdf2-sha256';
PBKDF2_ITERATIONS = 100000;
var
LQ: TFDQuery;
LStoredHash, LSalt, LAlgo, LComputed: string;
LIters: Integer;
begin
Result := False;
AUsername := '';
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT username, password_hash, salt, hash_algo, kdf_iterations ' +
'FROM users WHERE id = :uid';
LQ.ParamByName('uid').AsInteger := AUserId;
LQ.Open;
if LQ.IsEmpty then Exit;
AUsername := LQ.FieldByName('username').AsString;
LStoredHash := LQ.FieldByName('password_hash').AsString;
LSalt := LQ.FieldByName('salt').AsString;
LAlgo := LQ.FieldByName('hash_algo').AsString;
LIters := LQ.FieldByName('kdf_iterations').AsInteger;
if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY;
if LIters <= 0 then LIters := PBKDF2_ITERATIONS;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
if SameText(LAlgo, HASH_ALGO_LEGACY) then
begin
LComputed := PBKDF2_SHA256_Hex(APwd, LSalt, LIters);
Result := ConstantTimeEquals(LComputed, LStoredHash);
end
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
begin
LComputed := SHA256Hex(PBKDF2_SHA256_Hex(APwd, LSalt, LIters));
Result := ConstantTimeEquals(LComputed, LStoredHash);
end;
end;
// ===== GET /recovery-key/status ==============================================
procedure HandleStatus(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LQ: TFDQuery;
LObj: TJSONObject;
LConfigured: Boolean;
LCreatedAt: string;
begin
try
LUserId := Authenticate(ARequest, AResponse);
except
on ESessionRejected do Exit;
end;
LConfigured := False;
LCreatedAt := '';
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT created_at FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if not LQ.IsEmpty then
begin
LConfigured := True;
LCreatedAt := LQ.FieldByName('created_at').AsString;
end;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LObj := TJSONObject.Create;
LObj.AddPair('configured', TJSONBool.Create(LConfigured));
if LConfigured then LObj.AddPair('created_at', LCreatedAt);
TJSONHelper.SendJSON(AResponse, LObj);
end;
// ===== POST /recovery-key/setup ==============================================
procedure HandleSetup(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LBody: TJSONObject;
LPwd, LCodeHash, LKdfSalt, LWrappedKey, LWrappedIv, LIP, LUser: string;
LQ: TFDQuery;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
LIP := GetClientIP(ARequest);
LBody := TJSONHelper.ReadBody(ARequest);
try
LPwd := LBody.GetValue<string>('masterPassword', '');
LCodeHash := LBody.GetValue<string>('codeHash', '');
LKdfSalt := LBody.GetValue<string>('kdfSalt', '');
LWrappedKey := LBody.GetValue<string>('wrappedKey', '');
LWrappedIv := LBody.GetValue<string>('wrappedIv', '');
finally
LBody.Free;
end;
// Length sanity: SHA-256 hex = 64; kdf salt hex = 64; wrapped pieces are
// base64 — minimal length check to weed out obvious garbage.
if (Length(LCodeHash) <> 64) or (Length(LKdfSalt) <> 64) or
(LWrappedKey = '') or (LWrappedIv = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid recovery payload');
Exit;
end;
if not VerifyMasterPassword(LUserId, LPwd, LUser) then
begin
RecordFailedAccountAttempt(LUser, LIP);
LogAudit(LUserId, 'failed_recovery_setup', LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid master password');
Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// INSERT-or-replace via DELETE+INSERT (portable, avoids the UPSERT
// syntax we saw FireDAC choke on for the lockout table earlier).
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
LQ.SQL.Text :=
'INSERT INTO recovery_keys ' +
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ch').AsString := LCodeHash;
LQ.ParamByName('ks').AsString := LKdfSalt;
LQ.ParamByName('wk').AsString := LWrappedKey;
LQ.ParamByName('wi').AsString := LWrappedIv;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'recovery_setup', LIP);
TJSONHelper.SendOK(AResponse, 'Recovery key configured');
end;
// ===== DELETE /recovery-key ==================================================
procedure HandleDelete(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LUserId: Integer;
LQ: TFDQuery;
begin
try
LUserId := Authenticate(ARequest, AResponse);
RequireCSRF(ARequest, AResponse, LUserId);
except
on ESessionRejected do Exit;
end;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
LogAudit(LUserId, 'recovery_delete', GetClientIP(ARequest));
TJSONHelper.SendOK(AResponse, 'Recovery key removed');
end;
// ===== POST /recovery-key/redeem =============================================
// No session auth required — this is the entry point when the user CAN'T log
// in. Per-IP rate limit + per-account lockout still apply: an attacker can't
// brute-force the (high-entropy) recovery code by trying every possible
// value without hitting the lockout.
procedure HandleRedeem(ARequest: TIdHTTPRequestInfo;
AResponse: TIdHTTPResponseInfo; const AParams: TArray<string>);
var
LBody, LObj: TJSONObject;
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
LSalt, LToken, LCSRF: string;
LUserId, LKdfIters: Integer;
LQ: TFDQuery;
begin
LIP := GetClientIP(ARequest);
if CheckRateLimit(LIP) >= 10 then
begin
TJSONHelper.SendError(AResponse, 429, 'Too many attempts. Try again later.');
Exit;
end;
LBody := TJSONHelper.ReadBody(ARequest);
try
LUser := Trim(LBody.GetValue<string>('username', ''));
LCode := Trim(LBody.GetValue<string>('code', ''));
finally
LBody.Free;
end;
if (LUser = '') or (LCode = '') then
begin
TJSONHelper.SendError(AResponse, 400, 'Username and code required');
Exit;
end;
if RejectIfAccountLocked(AResponse, LUser) then Exit;
LCodeHash := SHA256Hex(LCode);
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Join to users to look up by username + verify the code in one shot.
LQ.SQL.Text :=
'SELECT u.id, u.salt, u.kdf_iterations, ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv ' +
'FROM users u ' +
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
'WHERE u.username = :u';
LQ.ParamByName('u').AsString := LUser;
LQ.Open;
if LQ.IsEmpty then
begin
// User doesn't exist OR has no recovery key configured. Same error
// either way to avoid leaking which.
RecordAttempt(LIP);
RecordFailedAccountAttempt(LUser, LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
Exit;
end;
LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
finally
LQ.Free;
end;
if (LStoredHash = '') or (LKdfSalt = '') or (LWrappedKey = '') then
begin
// User exists but no recovery row.
DB.Unlock;
try
RecordAttempt(LIP);
RecordFailedAccountAttempt(LUser, LIP);
finally
DB.Lock;
end;
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
Exit;
end;
if not ConstantTimeEquals(LCodeHash, LStoredHash) then
begin
DB.Unlock;
try
RecordAttempt(LIP);
RecordFailedAccountAttempt(LUser, LIP);
finally
DB.Lock;
end;
LogAudit(LUserId, 'failed_recovery_redeem', LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
Exit;
end;
// Code matches. Consume (delete the row) inside the same lock so the
// single-use guarantee holds even under concurrent requests.
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ExecSQL;
finally
LQ.Free;
end;
finally
DB.Unlock;
end;
ClearAttempts(LIP);
ClearAccountLockout(LUser);
CreateSession(LUserId, LToken, LCSRF);
LogAudit(LUserId, 'recovery_redeem', LIP);
LObj := TJSONObject.Create;
LObj.AddPair('message', 'OK');
LObj.AddPair('userId', TJSONNumber.Create(LUserId));
LObj.AddPair('token', LToken);
LObj.AddPair('csrfToken', LCSRF);
LObj.AddPair('salt', LSalt);
LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters));
LObj.AddPair('wrappedKey', LWrappedKey);
LObj.AddPair('wrappedIv', LWrappedIv);
LObj.AddPair('kdfSalt', LKdfSalt);
TJSONHelper.SendJSON(AResponse, LObj);
end;
initialization
Router.Register('GET', '/recovery-key/status', HandleStatus);
Router.Register('POST', '/recovery-key/setup', HandleSetup);
Router.Register('DELETE', '/recovery-key', HandleDelete);
Router.Register('POST', '/recovery-key/redeem', HandleRedeem);
end.
+2 -1
View File
@@ -19,7 +19,8 @@ uses
PM.Handler.Auth in 'Handlers\PM.Handler.Auth.pas',
PM.Handler.Folders in 'Handlers\PM.Handler.Folders.pas',
PM.Handler.Entries in 'Handlers\PM.Handler.Entries.pas',
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas';
PM.Handler.Passkey in 'Handlers\PM.Handler.Passkey.pas',
PM.Handler.Recovery in 'Handlers\PM.Handler.Recovery.pas';
{$R *.res}
{$R assets\assets.res}
+1
View File
@@ -219,6 +219,7 @@ $(PreBuildEvent)]]></PreBuildEvent>
<DCCReference Include="Handlers\PM.Handler.Folders.pas"/>
<DCCReference Include="Handlers\PM.Handler.Entries.pas"/>
<DCCReference Include="Handlers\PM.Handler.Passkey.pas"/>
<DCCReference Include="Handlers\PM.Handler.Recovery.pas"/>
<BuildConfiguration Include="Base">
<Key>Base</Key>
</BuildConfiguration>
+16
View File
@@ -147,6 +147,22 @@ begin
' last_attempt_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' last_attempt_ip TEXT' +
')');
// Recovery key — per-user single-use code that wraps the current AES
// vault key, used to recover access if the master password is forgotten.
// The plaintext code is shown to the user exactly once at setup; the
// server only ever sees SHA-256(code) for lookup. wrapped_key is the
// user''s AES key encrypted (AES-GCM) under a KEK derived from
// PBKDF2(code, kdf_salt, 600k). Single-use: redeem deletes the row.
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS recovery_keys (' +
' user_id INTEGER PRIMARY KEY,' +
' code_hash TEXT NOT NULL,' +
' kdf_salt TEXT NOT NULL,' +
' wrapped_key TEXT NOT NULL,' +
' wrapped_iv TEXT NOT NULL,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS audit_log (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
+23
View File
@@ -109,6 +109,9 @@
<svg><use href="#i-lock"/></svg>
Use a passkey
</button>
<button id="recoveryBtn" type="button" class="btn btn-link btn-block" style="font-size:12px;margin-top:4px">
Forgot master password? Use a recovery code
</button>
</form>
<form id="registerForm" class="auth-form is-hidden" autocomplete="off">
@@ -374,6 +377,26 @@
<svg><use href="#i-lock"/></svg> Change master password
</button>
</div>
<div class="slideover-field">
<div class="slideover-field-label">Recovery key</div>
<p id="recoveryStatus" style="font-size:12px;color:var(--text-dim);margin:0 0 8px;line-height:1.5">
No recovery key set.
</p>
<p style="font-size:11px;color:var(--text-faint);margin:0 0 8px;line-height:1.4">
A single-use code that lets you recover access if you
forget your master password. Generate it once, save the
code somewhere offline — the server never sees it again.
</p>
<div style="display:flex;gap:6px;flex-wrap:wrap">
<button class="btn btn-ghost btn-sm" id="recoverySetupBtn">
Generate recovery code
</button>
<button class="btn btn-ghost btn-sm is-danger" id="recoveryRemoveBtn" style="display:none">
Remove
</button>
</div>
</div>
</div>
</aside>
+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)));