Files
Password-Manager/delphi-backend/Handlers/PM.Handler.Recovery.pas
T
Zaki 40b3154a34 feat: MFA tools, single-instance, tray polish, prefs persistence
Session highlights:

- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
  rememberedUsername being lost across reboots due to the random
  ephemeral HTTP port changing the localStorage origin every launch.
  Bridge cmd://prefs/{get,set} round-trips through Delphi.

- feat(tray): icon visible from startup (NIM_ADD at constructor, not
  at first minimize). Tray context menu themed via uxtheme!135
  SetPreferredAppMode so it follows the app's dark/light setting.

- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
  Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
  running bridge restores the window from tray. Mutex lives in Local\
  namespace so distinct Windows users can still each run one.

- feat(mfa): Authenticator sidebar view (live TOTP codes for every
  entry with a secret) + standalone TOTP generator modal (paste
  base32 / otpauth:// URI, or generate a random 20-byte secret).

- feat(sidebar): Folders / Tags / Tools sections collapsible with
  chevron toggle. Badge counts stay visible when collapsed. State
  persisted in settings_json (synced across devices).

- feat(autofill): hotkey when vault is locked now restores the app
  and focuses the master password input instead of no-op'ing
  silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
  vault was locked" path.

- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
  sleep. Rationale: the DPAPI blob already gates access via the
  Windows account, so re-locking on top of the OS lock is redundant.
  Idle auto-lock still fires (separate opt-in).

- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
  source-of-truth at boot, instead of trusting (now-volatile)
  localStorage.

- docs: CLAUDE.md updated with all new modules, bridge commands,
  and the port-ephemeral pitfall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 21:31:39 +01:00

418 lines
14 KiB
ObjectPascal

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,
Data.DB, 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.
// Accepts EITHER plaintext (legacy clients) OR a precomputed verifier
// (ZK clients). Used by /recovery-key/setup so a stolen session token
// alone can't set up a recovery backdoor.
function VerifyMasterPassword(AUserId: Integer;
const APwd, AVerifier: 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 AVerifier <> '' then
begin
// ZK: client computed PBKDF2 hex locally. Server only does the wrap.
if Length(AVerifier) <> 64 then Exit;
if SameText(LAlgo, HASH_ALGO_LEGACY) then
Result := ConstantTimeEquals(AVerifier, LStoredHash)
else if SameText(LAlgo, HASH_ALGO_CURRENT) then
Result := ConstantTimeEquals(SHA256Hex(AVerifier), LStoredHash);
Exit;
end;
// Plaintext fallback (legacy client).
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 := '';
var RemainingUses: Integer := 0;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT created_at, remaining_uses 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;
RemainingUses := LQ.FieldByName('remaining_uses').AsInteger;
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);
if LConfigured then LObj.AddPair('remaining_uses', TJSONNumber.Create(RemainingUses));
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, LVerifier, 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', '');
LVerifier := LBody.GetValue<string>('verifier', '');
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, LVerifier, 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, remaining_uses) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi, 5)';
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, LCurrentUses, LNewUses: 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, rk.remaining_uses ' +
'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
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;
LCurrentUses := LQ.FieldByName('remaining_uses').AsInteger;
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. Decrement remaining_uses ; if it drops to 0, delete
// the row (last use). The row is also deleted when the user
// successfully changes their master password (in PM.Handler.Auth).
LNewUses := LCurrentUses - 1;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
if LNewUses <= 0 then
begin
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
end
else
begin
LQ.SQL.Text := 'UPDATE recovery_keys SET remaining_uses = :u WHERE user_id = :uid';
LQ.ParamByName('u').AsInteger := LNewUses;
LQ.ParamByName('uid').AsInteger := LUserId;
end;
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);
LObj.AddPair('remainingUses', TJSONNumber.Create(LNewUses));
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.