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:
@@ -61,6 +61,7 @@ type
|
|||||||
FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle
|
FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle
|
||||||
FIconHandle: HICON;
|
FIconHandle: HICON;
|
||||||
FNid: TNotifyIconData;
|
FNid: TNotifyIconData;
|
||||||
|
FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification
|
||||||
FSecureClipboard: TSecureClipboard;
|
FSecureClipboard: TSecureClipboard;
|
||||||
FBalloonShown: Boolean;
|
FBalloonShown: Boolean;
|
||||||
FOnSystemLock: TProc;
|
FOnSystemLock: TProc;
|
||||||
@@ -116,6 +117,19 @@ const
|
|||||||
WTS_SESSION_LOCK = 7;
|
WTS_SESSION_LOCK = 7;
|
||||||
NOTIFY_FOR_THIS_SESSION = 0;
|
NOTIFY_FOR_THIS_SESSION = 0;
|
||||||
|
|
||||||
|
// Power management broadcast — sent to all top-level windows when the
|
||||||
|
// system is about to sleep / hibernate or has just resumed. No explicit
|
||||||
|
// registration needed (unlike WTS).
|
||||||
|
// PBT_APMSUSPEND ($04) : "system is suspending operation" — fires once,
|
||||||
|
// right before sleep/hibernate. This is our lock trigger.
|
||||||
|
// PBT_APMRESUMEAUTOMATIC ($12) : system resumed (we don't need to act).
|
||||||
|
// PBT_APMRESUMESUSPEND ($07) : system resumed with user interaction.
|
||||||
|
const
|
||||||
|
WM_POWERBROADCAST = $0218;
|
||||||
|
PBT_APMSUSPEND = $0004;
|
||||||
|
PBT_APMRESUMEAUTOMATIC = $0012;
|
||||||
|
PBT_APMRESUMESUSPEND = $0007;
|
||||||
|
|
||||||
// Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all
|
// Dynamic WTS function pointers — wtsapi32.dll is not guaranteed on all
|
||||||
// Windows SKUs (e.g. minimal Server Core without Session Services), so
|
// Windows SKUs (e.g. minimal Server Core without Session Services), so
|
||||||
// we load it at runtime and tolerate absence gracefully.
|
// we load it at runtime and tolerate absence gracefully.
|
||||||
@@ -135,6 +149,31 @@ begin
|
|||||||
_WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification');
|
_WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification');
|
||||||
end;
|
end;
|
||||||
|
|
||||||
|
// Power notification registration (Windows 8+). Forces delivery of
|
||||||
|
// WM_POWERBROADCAST to a specific HWND, including non-top-level / hidden
|
||||||
|
// utility windows that Windows might otherwise skip. Exported from user32.
|
||||||
|
const
|
||||||
|
DEVICE_NOTIFY_WINDOW_HANDLE = 0;
|
||||||
|
|
||||||
|
var
|
||||||
|
_PowerRegister : function(Flags: DWORD; Recipient: THandle;
|
||||||
|
out RegistrationHandle: THandle): DWORD; stdcall = nil;
|
||||||
|
_PowerUnregister: function(RegistrationHandle: THandle): DWORD; stdcall = nil;
|
||||||
|
_PowerApiLoaded : Boolean = False;
|
||||||
|
|
||||||
|
procedure LoadPowerApi;
|
||||||
|
var
|
||||||
|
LLib: HMODULE;
|
||||||
|
begin
|
||||||
|
if _PowerApiLoaded then Exit;
|
||||||
|
_PowerApiLoaded := True;
|
||||||
|
// Functions live in user32.dll despite the "Power" prefix.
|
||||||
|
LLib := GetModuleHandle('user32.dll');
|
||||||
|
if LLib = 0 then Exit;
|
||||||
|
_PowerRegister := GetProcAddress(LLib, 'PowerRegisterSuspendResumeNotification');
|
||||||
|
_PowerUnregister := GetProcAddress(LLib, 'PowerUnregisterSuspendResumeNotification');
|
||||||
|
end;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TSecureClipboard
|
// TSecureClipboard
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -238,10 +277,22 @@ begin
|
|||||||
LoadWtsApi;
|
LoadWtsApi;
|
||||||
if Assigned(_WTSRegister) then
|
if Assigned(_WTSRegister) then
|
||||||
_WTSRegister(FMsgWindow, NOTIFY_FOR_THIS_SESSION);
|
_WTSRegister(FMsgWindow, NOTIFY_FOR_THIS_SESSION);
|
||||||
|
|
||||||
|
// Sleep/hibernate detection. Forces WM_POWERBROADCAST delivery to our
|
||||||
|
// message-only window even if Windows would otherwise skip it. On
|
||||||
|
// Windows < 8 this fails silently — only modern systems support this
|
||||||
|
// API, but they're also the ones that have aggressive sleep behavior.
|
||||||
|
FPowerNotify := 0;
|
||||||
|
LoadPowerApi;
|
||||||
|
if Assigned(_PowerRegister) then
|
||||||
|
_PowerRegister(DEVICE_NOTIFY_WINDOW_HANDLE, FMsgWindow, FPowerNotify);
|
||||||
end;
|
end;
|
||||||
|
|
||||||
destructor TPMBridge.Destroy;
|
destructor TPMBridge.Destroy;
|
||||||
begin
|
begin
|
||||||
|
if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then
|
||||||
|
_PowerUnregister(FPowerNotify);
|
||||||
|
|
||||||
if Assigned(_WTSUnregister) then
|
if Assigned(_WTSUnregister) then
|
||||||
_WTSUnregister(FMsgWindow);
|
_WTSUnregister(FMsgWindow);
|
||||||
|
|
||||||
@@ -479,6 +530,19 @@ begin
|
|||||||
begin
|
begin
|
||||||
if AMsg.WParam = WTS_SESSION_LOCK then
|
if AMsg.WParam = WTS_SESSION_LOCK then
|
||||||
if Assigned(FOnSystemLock) then FOnSystemLock();
|
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||||
|
end
|
||||||
|
|
||||||
|
else if AMsg.Msg = WM_POWERBROADCAST then
|
||||||
|
begin
|
||||||
|
// Sleep/hibernate fires PBT_APMSUSPEND. Treat it identically to a
|
||||||
|
// session lock: the user is leaving the machine unattended, so the
|
||||||
|
// vault must be locked. Without this, closing a laptop lid (which
|
||||||
|
// doesn't always trigger WTS_SESSION_LOCK if the system goes straight
|
||||||
|
// to sleep) would leave the decrypted state in memory until resume.
|
||||||
|
// PBT_APMSUSPEND is delivered SYNCHRONOUSLY before the system
|
||||||
|
// suspends — fast handler required (no UI prompts, no network).
|
||||||
|
if AMsg.WParam = PBT_APMSUSPEND then
|
||||||
|
if Assigned(FOnSystemLock) then FOnSystemLock();
|
||||||
end;
|
end;
|
||||||
|
|
||||||
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
|
AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam);
|
||||||
|
|||||||
@@ -162,13 +162,16 @@ begin
|
|||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
// CAST(strftime(...) - strftime(...) AS INTEGER) gives seconds remaining.
|
// julianday() returns a real number of days since the Julian epoch
|
||||||
// If locked_until is NULL or in the past, the SELECT returns 0.
|
// — 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 :=
|
LQ.SQL.Text :=
|
||||||
'SELECT MAX(0, CAST(' +
|
'SELECT CAST((julianday(locked_until) - julianday(''now'')) * 86400 ' +
|
||||||
' (strftime(''%s'', locked_until) - strftime(''%s'', ''now''))' +
|
' AS INTEGER) AS remaining ' +
|
||||||
' AS INTEGER)) AS remaining ' +
|
'FROM account_lockouts ' +
|
||||||
'FROM account_lockouts WHERE username = :u';
|
'WHERE username = :u AND locked_until > datetime(''now'')';
|
||||||
LQ.ParamByName('u').AsString := AUsername;
|
LQ.ParamByName('u').AsString := AUsername;
|
||||||
LQ.Open;
|
LQ.Open;
|
||||||
if not LQ.IsEmpty then
|
if not LQ.IsEmpty then
|
||||||
@@ -191,30 +194,38 @@ begin
|
|||||||
|
|
||||||
DB.Lock;
|
DB.Lock;
|
||||||
try
|
try
|
||||||
// Step 1: upsert + increment in a single statement. SQLite's
|
// Try UPDATE first; if no row exists, INSERT a fresh one. This avoids
|
||||||
// ON CONFLICT(...) DO UPDATE handles the "row already exists" case
|
// SQLite UPSERT (ON CONFLICT DO UPDATE), which requires SQLite 3.24+
|
||||||
// atomically without a separate SELECT/UPDATE race.
|
// 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);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
LQ.SQL.Text :=
|
LQ.SQL.Text :=
|
||||||
'INSERT INTO account_lockouts ' +
|
'UPDATE account_lockouts SET ' +
|
||||||
' (username, failed_count, last_attempt_at, last_attempt_ip) ' +
|
|
||||||
'VALUES (:u, 1, CURRENT_TIMESTAMP, :ip) ' +
|
|
||||||
'ON CONFLICT(username) DO UPDATE SET ' +
|
|
||||||
' failed_count = failed_count + 1, ' +
|
' failed_count = failed_count + 1, ' +
|
||||||
' last_attempt_at = CURRENT_TIMESTAMP, ' +
|
' last_attempt_at = CURRENT_TIMESTAMP, ' +
|
||||||
' last_attempt_ip = excluded.last_attempt_ip';
|
' last_attempt_ip = :ip ' +
|
||||||
LQ.ParamByName('u').AsString := AUsername;
|
'WHERE username = :u';
|
||||||
|
LQ.ParamByName('u').AsString := AUsername;
|
||||||
LQ.ParamByName('ip').AsString := AIP;
|
LQ.ParamByName('ip').AsString := AIP;
|
||||||
LQ.ExecSQL;
|
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
|
finally
|
||||||
LQ.Free;
|
LQ.Free;
|
||||||
end;
|
end;
|
||||||
|
|
||||||
// Step 2: read the new failed_count and apply backoff if the threshold
|
// Read the new failed_count to decide if the backoff threshold is crossed.
|
||||||
// is crossed. Done in two queries because SQLite's RETURNING clause
|
|
||||||
// requires 3.35+ and we want to support older versions.
|
|
||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
LQ.Connection := DB.Connection;
|
||||||
@@ -235,11 +246,14 @@ begin
|
|||||||
LQ := TFDQuery.Create(nil);
|
LQ := TFDQuery.Create(nil);
|
||||||
try
|
try
|
||||||
LQ.Connection := DB.Connection;
|
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 :=
|
LQ.SQL.Text :=
|
||||||
'UPDATE account_lockouts SET ' +
|
'UPDATE account_lockouts SET ' +
|
||||||
' locked_until = datetime(''now'', ''+'' || :sec || '' seconds'') ' +
|
' locked_until = datetime(''now'', :modifier) ' +
|
||||||
'WHERE username = :u';
|
'WHERE username = :u';
|
||||||
LQ.ParamByName('sec').AsInteger := LBackoff;
|
LQ.ParamByName('modifier').AsString := Format('+%d seconds', [LBackoff]);
|
||||||
LQ.ParamByName('u').AsString := AUsername;
|
LQ.ParamByName('u').AsString := AUsername;
|
||||||
LQ.ExecSQL;
|
LQ.ExecSQL;
|
||||||
finally
|
finally
|
||||||
|
|||||||
Reference in New Issue
Block a user