Files
Password-Manager/delphi-backend/Source/PM.Database.pas
T
Zaki 01c56edf25 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.
2026-05-23 11:18:34 +01:00

280 lines
10 KiB
ObjectPascal

unit PM.Database;
{
SQLite connection (FireDAC) toward the shared vault.db file.
CreateSchema mirrors api.php (CREATE TABLE IF NOT EXISTS + ALTER migrations).
Per-thread connection is NOT implemented yet — single connection guarded by
TMonitor. Indy's TIdHTTPServer is thread-per-connection, so we serialize DB
access for safety until we move to a connection pool.
}
interface
uses
System.SysUtils, System.Classes, System.IOUtils, System.SyncObjs,
FireDAC.Comp.Client, FireDAC.Stan.Def, FireDAC.Stan.Async,
FireDAC.Phys.SQLite, FireDAC.DApt, FireDAC.Stan.Param,
FireDAC.FMXUI.Wait, FireDAC.Stan.Intf, FireDAC.UI.Intf,
Data.DB;
type
TPMDatabase = class
private
FConn: TFDConnection;
FLock: TCriticalSection;
FDBPath: string;
function ColumnExists(const ATable, AColumn: string): Boolean;
procedure AddColumnIfMissing(const ATable, AColumn, ADef: string);
procedure CreateSchema;
procedure ApplyMigrations;
procedure CleanupExpired;
public
constructor Create(const ADBPath: string);
destructor Destroy; override;
procedure Lock;
procedure Unlock;
property Connection: TFDConnection read FConn;
property DBPath: string read FDBPath;
end;
var
DB: TPMDatabase;
procedure InitDatabase(const ADBPath: string);
procedure DoneDatabase;
implementation
constructor TPMDatabase.Create(const ADBPath: string);
begin
inherited Create;
FDBPath := ADBPath;
FLock := TCriticalSection.Create;
FConn := TFDConnection.Create(nil);
FConn.DriverName := 'SQLite';
FConn.Params.Values['Database'] := FDBPath;
FConn.Params.Values['LockingMode'] := 'Normal';
FConn.Params.Values['Synchronous'] := 'Normal';
FConn.Params.Values['BusyTimeout'] := '5000';
FConn.Params.Values['JournalMode'] := 'WAL';
FConn.Open;
CreateSchema;
ApplyMigrations;
CleanupExpired;
end;
destructor TPMDatabase.Destroy;
begin
FConn.Free;
FLock.Free;
inherited;
end;
procedure TPMDatabase.Lock;
begin
FLock.Enter;
end;
procedure TPMDatabase.Unlock;
begin
FLock.Leave;
end;
procedure TPMDatabase.CreateSchema;
begin
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS users (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' username TEXT UNIQUE NOT NULL,' +
' password_hash TEXT NOT NULL,' +
' salt TEXT NOT NULL,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' hash_algo TEXT DEFAULT ''pbkdf2''' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS folders (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' name TEXT NOT NULL,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' +
' UNIQUE(user_id, name)' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS vault_entries (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' site TEXT NOT NULL,' +
' username TEXT NOT NULL,' +
' encrypted_password TEXT NOT NULL,' +
' iv TEXT NOT NULL,' +
' encryption_method TEXT DEFAULT ''server'',' +
' folder TEXT DEFAULT ''All'',' +
' deleted INTEGER DEFAULT 0,' +
' deleted_at DATETIME,' +
' favorite INTEGER DEFAULT 0,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' updated_at DATETIME DEFAULT CURRENT_TIMESTAMP' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS sessions (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' token_hash TEXT UNIQUE NOT NULL,' +
' csrf_token TEXT,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' expires_at DATETIME NOT NULL,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS login_attempts (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' ip TEXT NOT NULL,' +
' attempted_at DATETIME DEFAULT CURRENT_TIMESTAMP' +
')');
// Per-username lockout state, complementing the per-IP login_attempts
// counter. On a loopback-only deployment the per-IP counter is mostly
// useless (everyone hits 127.0.0.1), so the per-username counter is the
// real defense against brute-force.
// - failed_count: total failures since the last successful auth
// - locked_until: timestamp the account becomes available again (NULL = not locked)
// - last_attempt_at / _ip: forensic info for the audit log
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS account_lockouts (' +
' username TEXT PRIMARY KEY,' +
' failed_count INTEGER NOT NULL DEFAULT 0,' +
' locked_until DATETIME,' +
' 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,' +
' user_id INTEGER,' +
' action TEXT NOT NULL,' +
' ip TEXT,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS passkey_challenges (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER,' +
' challenge BLOB NOT NULL,' +
' type TEXT NOT NULL,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP' +
')');
FConn.ExecSQL(
'CREATE TABLE IF NOT EXISTS passkey_credentials (' +
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
' user_id INTEGER NOT NULL,' +
' credential_id BLOB NOT NULL UNIQUE,' +
' public_key BLOB NOT NULL,' +
' counter INTEGER DEFAULT 0,' +
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
')');
end;
function TPMDatabase.ColumnExists(const ATable, AColumn: string): Boolean;
var
LQ: TFDQuery;
begin
Result := False;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := FConn;
// PRAGMA table_info returns one row per column with name in column 'name'
LQ.SQL.Text := 'PRAGMA table_info(' + ATable + ')';
LQ.Open;
while not LQ.Eof do
begin
if SameText(LQ.FieldByName('name').AsString, AColumn) then
Exit(True);
LQ.Next;
end;
finally
LQ.Free;
end;
end;
procedure TPMDatabase.AddColumnIfMissing(const ATable, AColumn, ADef: string);
begin
if not ColumnExists(ATable, AColumn) then
FConn.ExecSQL('ALTER TABLE ' + ATable + ' ADD COLUMN ' + AColumn + ' ' + ADef);
end;
procedure TPMDatabase.ApplyMigrations;
begin
// Idempotent: only ALTER when the column is actually missing — no exception
// bubbling up to the debugger like api.php's try/catch did.
AddColumnIfMissing('vault_entries', 'encryption_method', 'TEXT DEFAULT ''server''');
AddColumnIfMissing('vault_entries', 'folder', 'TEXT DEFAULT ''All''');
AddColumnIfMissing('vault_entries', 'deleted', 'INTEGER DEFAULT 0');
AddColumnIfMissing('vault_entries', 'deleted_at', 'DATETIME');
AddColumnIfMissing('vault_entries', 'favorite', 'INTEGER DEFAULT 0');
// UI V2: tags stored as comma-separated TEXT (e.g. "work,important,2fa").
// Simple format, search via LIKE %tag%. Frontend handles parsing/joining.
AddColumnIfMissing('vault_entries', 'tags', 'TEXT DEFAULT ''''');
// TOTP (2FA) — RFC 6238. Secret + IV are AES-GCM ciphertext / IV pair
// encrypted client-side with the user's master-derived key, exactly like
// encrypted_password. The server treats them as opaque blobs and never
// sees the plaintext secret. NULL = no TOTP configured for this entry.
AddColumnIfMissing('vault_entries', 'totp_secret', 'TEXT');
AddColumnIfMissing('vault_entries', 'totp_iv', 'TEXT');
AddColumnIfMissing('users', 'hash_algo', 'TEXT DEFAULT ''pbkdf2''');
// PBKDF2 iteration count per user. Legacy rows (predating this column)
// default to 100000 — the value used by api.php / the early Delphi build.
// New accounts created here use the current PBKDF2_ITERATIONS_TARGET
// (600 000 as of 2026). Login flow transparently re-hashes legacy users
// and re-encrypts their entries on the client side.
AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000');
AddColumnIfMissing('sessions', 'csrf_token', 'TEXT');
end;
procedure TPMDatabase.CleanupExpired;
begin
FConn.ExecSQL('DELETE FROM sessions WHERE expires_at < datetime(''now'')');
FConn.ExecSQL('DELETE FROM login_attempts WHERE attempted_at < datetime(''now'', ''-15 minutes'')');
FConn.ExecSQL('DELETE FROM audit_log WHERE created_at < datetime(''now'', ''-30 days'')');
// Account lockout entries: prune rows that are no longer locked AND haven't
// been touched in 30 days (the user clearly isn't being attacked anymore).
// Active lockouts and recent attempts are preserved.
FConn.ExecSQL(
'DELETE FROM account_lockouts ' +
'WHERE (locked_until IS NULL OR locked_until < datetime(''now'')) ' +
'AND last_attempt_at < datetime(''now'', ''-30 days'')');
FConn.ExecSQL('DELETE FROM passkey_challenges WHERE created_at < datetime(''now'', ''-10 minutes'')');
end;
procedure InitDatabase(const ADBPath: string);
begin
if DB = nil then
DB := TPMDatabase.Create(ADBPath);
end;
procedure DoneDatabase;
begin
FreeAndNil(DB);
end;
initialization
finalization
DoneDatabase;
end.