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
@@ -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.