e23a78dda7
- Entry templates: new vault_entries.template column drives a typed
sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
+ table label off the template, badge reads "credit card" instead of
"note". Templates seed kind=note (no site/password required), use
custom_fields with optional dropdown options (brand, month/year,
protocol). Round-tripped across export/import/duplicate/master-pw
rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
(card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
via mousedown origin tracker), Esc closes, X closes. App shell is
pushed left by 420px when the panel is open so the table / pagination
/ sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
(decrypted to base64, re-encrypted under current key on restore),
icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
preserves title/template/custom_fields. CSV parser detects kind/
template columns.
- Bulk-import response returns `ids[]` parallel to input so the
client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
addTag, batchAddTag were all silently wiping TOTP / custom_fields
/ kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
the very next request after rotation no longer ESessionRejects.
Client adopts the new pair. Attachments are re-encrypted client-side
during rotation (GET old → decrypt with old key → encrypt with new
→ PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
.Value assignment so SQLite TEXT no longer truncates to 4000 chars
(deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
surfaced in Settings → Account. Autostart launches suppress the
first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
370 lines
15 KiB
ObjectPascal
370 lines
15 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' +
|
|
')');
|
|
// Password history — keeps the last N versions of each entry's
|
|
// encrypted_password + iv. Populated by HandleUpdateEntry before each
|
|
// PUT overwrites the row; pruned to 20 entries per row after each insert.
|
|
// kind mirrors vault_entries.kind so notes can be restored too.
|
|
FConn.ExecSQL(
|
|
'CREATE TABLE IF NOT EXISTS entries_password_history (' +
|
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
|
' entry_id INTEGER NOT NULL,' +
|
|
' user_id INTEGER NOT NULL,' +
|
|
' encrypted_password TEXT NOT NULL,' +
|
|
' iv TEXT NOT NULL,' +
|
|
' kind TEXT NOT NULL DEFAULT ''login'',' +
|
|
' changed_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
|
' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE,' +
|
|
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE' +
|
|
')');
|
|
FConn.ExecSQL(
|
|
'CREATE INDEX IF NOT EXISTS idx_history_entry ' +
|
|
' ON entries_password_history(entry_id, changed_at DESC)');
|
|
// Per-entry encrypted file attachments (PDFs, images of backup codes,
|
|
// etc.). encrypted_blob is the AES-GCM ciphertext of the raw file bytes,
|
|
// base64-encoded. Filename + mime + size_bytes are stored in cleartext
|
|
// for the listing UI — knowingly leaked metadata in exchange for not
|
|
// having to decrypt every entry on list render.
|
|
FConn.ExecSQL(
|
|
'CREATE TABLE IF NOT EXISTS entry_attachments (' +
|
|
' id INTEGER PRIMARY KEY AUTOINCREMENT,' +
|
|
' user_id INTEGER NOT NULL,' +
|
|
' entry_id INTEGER NOT NULL,' +
|
|
' filename TEXT NOT NULL,' +
|
|
' mime TEXT,' +
|
|
' size_bytes INTEGER NOT NULL,' +
|
|
' encrypted_blob TEXT NOT NULL,' +
|
|
' iv TEXT NOT NULL,' +
|
|
' created_at DATETIME DEFAULT CURRENT_TIMESTAMP,' +
|
|
' FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,' +
|
|
' FOREIGN KEY (entry_id) REFERENCES vault_entries(id) ON DELETE CASCADE' +
|
|
')');
|
|
FConn.ExecSQL(
|
|
'CREATE INDEX IF NOT EXISTS idx_attachments_entry ' +
|
|
' ON entry_attachments(entry_id)');
|
|
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 ''''');
|
|
// Optional human-friendly display name. When empty, the UI falls back
|
|
// to `site`. Lets the user store the raw URL/host (used for autofill
|
|
// domain matching) while showing something nicer on cards/slideovers.
|
|
AddColumnIfMissing('vault_entries', 'title', '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');
|
|
// Entry kind: 'login' (default — site/user/encrypted_password/iv/totp)
|
|
// or 'note' (free-text secure note — body stored in encrypted_password
|
|
// + iv, site/username/totp_* unused). Legacy rows default to 'login'.
|
|
AddColumnIfMissing('vault_entries', 'kind', 'TEXT DEFAULT ''login''');
|
|
// Custom fields: opaque encrypted JSON array of
|
|
// [{label, value, is_secret}, ...]
|
|
// Same crypto pipeline as encrypted_password (AES-GCM with the vault
|
|
// key). NULL = no custom fields configured. The server treats both
|
|
// columns as opaque ciphertext + IV.
|
|
AddColumnIfMissing('vault_entries', 'custom_fields', 'TEXT');
|
|
AddColumnIfMissing('vault_entries', 'custom_fields_iv', 'TEXT');
|
|
// Cached favicon as a base64 data URI (e.g. "data:image/png;base64,...").
|
|
// Fetched on demand by the Delphi favicon proxy when the user opts in.
|
|
// NULL = no icon cached → JS falls back to the first-letter avatar.
|
|
AddColumnIfMissing('vault_entries', 'icon_b64', 'TEXT');
|
|
// Tracked client-side via POST /entries/:id/touch on copy/open. Powers
|
|
// the sidebar "Recent" view. NULL = never accessed since the column
|
|
// landed (legacy rows).
|
|
AddColumnIfMissing('vault_entries', 'accessed_at', 'DATETIME');
|
|
// Bumped to CURRENT_TIMESTAMP only when encrypted_password actually
|
|
// changes (distinct from updated_at which fires on any edit). Powers
|
|
// the "aged password" badge. Legacy rows: NULL → JS falls back to
|
|
// updated_at, then created_at.
|
|
AddColumnIfMissing('vault_entries', 'password_changed_at', 'DATETIME');
|
|
// Pinned entries float to the top of every view, regardless of sort.
|
|
// Independent from favorite (which is a filter, not a sort override).
|
|
AddColumnIfMissing('vault_entries', 'pinned', 'INTEGER DEFAULT 0');
|
|
// Template identifier: empty/NULL = generic login or note; otherwise a
|
|
// string like 'credit-card', 'ssh-key', 'server', 'recovery-codes'.
|
|
// Drives the card/table label so notes-with-fields read as "Credit card"
|
|
// instead of the generic "Encrypted note" placeholder.
|
|
AddColumnIfMissing('vault_entries', 'template', 'TEXT');
|
|
// Per-folder customisation. NULL = no override → JS uses the default
|
|
// accent + i-folder symbol.
|
|
AddColumnIfMissing('folders', 'color', 'TEXT');
|
|
AddColumnIfMissing('folders', 'icon', 'TEXT');
|
|
// Manual sort order from drag-reorder. 0 = legacy/never reordered →
|
|
// falls back to alphabetical secondary sort in GET /folders.
|
|
AddColumnIfMissing('folders', 'sort_order', 'INTEGER DEFAULT 0');
|
|
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('recovery_keys', 'remaining_uses', 'INTEGER DEFAULT 5');
|
|
// Server-side preferences blob (JSON). Synced across devices on login,
|
|
// saved on every change from the JS settings panel. Device-specific
|
|
// toggles (quick-unlock DPAPI, Win32 autofill hotkey) intentionally stay
|
|
// in localStorage and are NOT included here.
|
|
AddColumnIfMissing('users', 'settings_json', 'TEXT DEFAULT ''{}''');
|
|
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.
|