feat(bridge): auto-lock on sleep/hibernate + fix UPSERT syntax

Sleep/hibernate handling
========================
Adds WM_POWERBROADCAST / PBT_APMSUSPEND handling alongside the existing
WTS_SESSION_LOCK detection. Closing a laptop lid often suspends the
system without firing a session lock, leaving the decrypted vault in
memory until resume — this fixes that.

Implementation note: WM_POWERBROADCAST is normally only delivered to
top-level windows, and Windows can silently skip hidden utility windows.
PowerRegisterSuspendResumeNotification (user32, Win 8+) forces delivery
to our specific HWND regardless. Loaded dynamically via GetProcAddress
so older Windows degrades gracefully (WTS lock still works).

The suspend handler reuses OnSystemLock — semantically the same event
from the user's perspective ("I'm leaving the machine"). Calls
lockVault() in JS via ExecuteJavaScript.

RateLimit fix (related: lockout feature from previous commit)
=============================================================
The UPSERT (INSERT ... ON CONFLICT DO UPDATE) in RecordFailedAccountAttempt
errored with "near ON: syntax error" — either the bundled SQLite version
or FireDAC's parameter preprocessor doesn't handle UPSERT correctly.
Replaced with portable UPDATE-then-INSERT (safe under our DB.Lock).

Also:
 - datetime modifier ("+60 seconds") built in Delphi via Format() rather
   than SQL-side concatenation ('+' || :sec || ' seconds'), which FireDAC
   was mangling on some configs.
 - GetAccountLockoutRemaining rewritten with julianday() (the SQLite
   idiom for date arithmetic) instead of strftime('%s'). Cleaner, NULL-safe.
This commit is contained in:
2026-05-23 00:29:21 +01:00
parent 9f6636defc
commit bff9bdf9f2
2 changed files with 98 additions and 20 deletions
+34 -20
View File
@@ -162,13 +162,16 @@ begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// CAST(strftime(...) - strftime(...) AS INTEGER) gives seconds remaining.
// If locked_until is NULL or in the past, the SELECT returns 0.
// julianday() returns a real number of days since the Julian epoch
// — the standard SQLite idiom for date arithmetic. Multiplying by
// 86400 gives seconds. The WHERE clause filters out rows where the
// lockout is NULL or already expired, so an empty result set means
// "not locked" — no separate NULL handling needed.
LQ.SQL.Text :=
'SELECT MAX(0, CAST(' +
' (strftime(''%s'', locked_until) - strftime(''%s'', ''now''))' +
' AS INTEGER)) AS remaining ' +
'FROM account_lockouts WHERE username = :u';
'SELECT CAST((julianday(locked_until) - julianday(''now'')) * 86400 ' +
' AS INTEGER) AS remaining ' +
'FROM account_lockouts ' +
'WHERE username = :u AND locked_until > datetime(''now'')';
LQ.ParamByName('u').AsString := AUsername;
LQ.Open;
if not LQ.IsEmpty then
@@ -191,30 +194,38 @@ begin
DB.Lock;
try
// Step 1: upsert + increment in a single statement. SQLite's
// ON CONFLICT(...) DO UPDATE handles the "row already exists" case
// atomically without a separate SELECT/UPDATE race.
// Try UPDATE first; if no row exists, INSERT a fresh one. This avoids
// SQLite UPSERT (ON CONFLICT DO UPDATE), which requires SQLite 3.24+
// and which FireDAC's parameter preprocessor mangles on some versions.
// Safe under our outer DB.Lock — no race between the UPDATE and INSERT.
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'INSERT INTO account_lockouts ' +
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip) ' +
'ON CONFLICT(username) DO UPDATE SET ' +
'UPDATE account_lockouts SET ' +
' failed_count = failed_count + 1, ' +
' last_attempt_at = CURRENT_TIMESTAMP, ' +
' last_attempt_ip = excluded.last_attempt_ip';
LQ.ParamByName('u').AsString := AUsername;
' last_attempt_ip = :ip ' +
'WHERE username = :u';
LQ.ParamByName('u').AsString := AUsername;
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
if LQ.RowsAffected = 0 then
begin
LQ.SQL.Text :=
'INSERT INTO account_lockouts ' +
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip)';
LQ.ParamByName('u').AsString := AUsername;
LQ.ParamByName('ip').AsString := AIP;
LQ.ExecSQL;
end;
finally
LQ.Free;
end;
// Step 2: read the new failed_count and apply backoff if the threshold
// is crossed. Done in two queries because SQLite's RETURNING clause
// requires 3.35+ and we want to support older versions.
// Read the new failed_count to decide if the backoff threshold is crossed.
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
@@ -235,11 +246,14 @@ begin
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
// Build the datetime modifier in Delphi (e.g. "+60 seconds") and
// pass it as a single string parameter — avoids SQL-side string
// concatenation which FireDAC's preprocessor can choke on.
LQ.SQL.Text :=
'UPDATE account_lockouts SET ' +
' locked_until = datetime(''now'', ''+'' || :sec || '' seconds'') ' +
' locked_until = datetime(''now'', :modifier) ' +
'WHERE username = :u';
LQ.ParamByName('sec').AsInteger := LBackoff;
LQ.ParamByName('modifier').AsString := Format('+%d seconds', [LBackoff]);
LQ.ParamByName('u').AsString := AUsername;
LQ.ExecSQL;
finally