From bff9bdf9f26f7624e035aaa932fb3cceba43e7df Mon Sep 17 00:00:00 2001 From: Zaki <18zaki18@gmail.com> Date: Sat, 23 May 2026 00:29:21 +0100 Subject: [PATCH] feat(bridge): auto-lock on sleep/hibernate + fix UPSERT syntax MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- delphi-backend/Source/PM.Bridge.pas | 64 ++++++++++++++++++++++++++ delphi-backend/Source/PM.RateLimit.pas | 54 ++++++++++++++-------- 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/delphi-backend/Source/PM.Bridge.pas b/delphi-backend/Source/PM.Bridge.pas index f4529d3..7faedbd 100644 --- a/delphi-backend/Source/PM.Bridge.pas +++ b/delphi-backend/Source/PM.Bridge.pas @@ -61,6 +61,7 @@ type FIconOwned: Boolean; // true = we must call DestroyIcon on FIconHandle FIconHandle: HICON; FNid: TNotifyIconData; + FPowerNotify: THandle; // registration handle from PowerRegisterSuspendResumeNotification FSecureClipboard: TSecureClipboard; FBalloonShown: Boolean; FOnSystemLock: TProc; @@ -116,6 +117,19 @@ const WTS_SESSION_LOCK = 7; 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 // Windows SKUs (e.g. minimal Server Core without Session Services), so // we load it at runtime and tolerate absence gracefully. @@ -135,6 +149,31 @@ begin _WTSUnregister := GetProcAddress(_WtsLib, 'WTSUnRegisterSessionNotification'); 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 // ============================================================================= @@ -238,10 +277,22 @@ begin LoadWtsApi; if Assigned(_WTSRegister) then _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; destructor TPMBridge.Destroy; begin + if (FPowerNotify <> 0) and Assigned(_PowerUnregister) then + _PowerUnregister(FPowerNotify); + if Assigned(_WTSUnregister) then _WTSUnregister(FMsgWindow); @@ -479,6 +530,19 @@ begin begin if AMsg.WParam = WTS_SESSION_LOCK then 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; AMsg.Result := DefWindowProc(FMsgWindow, AMsg.Msg, AMsg.WParam, AMsg.LParam); diff --git a/delphi-backend/Source/PM.RateLimit.pas b/delphi-backend/Source/PM.RateLimit.pas index 0994fd8..351f036 100644 --- a/delphi-backend/Source/PM.RateLimit.pas +++ b/delphi-backend/Source/PM.RateLimit.pas @@ -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