01c56edf25
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.
389 lines
12 KiB
ObjectPascal
389 lines
12 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,
|
|
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.
|