feat: MFA tools, single-instance, tray polish, prefs persistence

Session highlights:

- feat(prefs): DPAPI-backed key/value store (PM.UserPrefs) — fixes
  rememberedUsername being lost across reboots due to the random
  ephemeral HTTP port changing the localStorage origin every launch.
  Bridge cmd://prefs/{get,set} round-trips through Delphi.

- feat(tray): icon visible from startup (NIM_ADD at constructor, not
  at first minimize). Tray context menu themed via uxtheme!135
  SetPreferredAppMode so it follows the app's dark/light setting.

- feat(single-instance): named mutex + RegisterWindowMessage broadcast.
  Second launch posts WM_PMSHOW to HWND_BROADCAST and exits; the
  running bridge restores the window from tray. Mutex lives in Local\
  namespace so distinct Windows users can still each run one.

- feat(mfa): Authenticator sidebar view (live TOTP codes for every
  entry with a secret) + standalone TOTP generator modal (paste
  base32 / otpauth:// URI, or generate a random 20-byte secret).

- feat(sidebar): Folders / Tags / Tools sections collapsible with
  chevron toggle. Badge counts stay visible when collapsed. State
  persisted in settings_json (synced across devices).

- feat(autofill): hotkey when vault is locked now restores the app
  and focuses the master password input instead of no-op'ing
  silently. Cleaner UX for the common "I hit Ctrl+Shift+L but the
  vault was locked" path.

- feat(quick-unlock): when enabled, skip lockVault on Windows lock /
  sleep. Rationale: the DPAPI blob already gates access via the
  Windows account, so re-locking on top of the OS lock is redundant.
  Idle auto-lock still fires (separate opt-in).

- fix(quick-unlock): re-sync state.quickUnlockEnabled from DPAPI
  source-of-truth at boot, instead of trusting (now-volatile)
  localStorage.

- docs: CLAUDE.md updated with all new modules, bridge commands,
  and the port-ephemeral pitfall.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-08 21:31:39 +01:00
parent 664db65437
commit 40b3154a34
38 changed files with 8165 additions and 548 deletions
+33 -19
View File
@@ -33,7 +33,7 @@ implementation
uses
System.SysUtils, System.JSON,
FireDAC.Comp.Client, FireDAC.Stan.Param,
Data.DB, FireDAC.Comp.Client, FireDAC.Stan.Param,
IdCustomHTTPServer,
PM.Router, PM.JSON, PM.Database, PM.Crypto, PM.Session, PM.Audit, PM.RateLimit;
@@ -122,19 +122,21 @@ begin
LConfigured := False;
LCreatedAt := '';
var RemainingUses: Integer := 0;
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT created_at FROM recovery_keys WHERE user_id = :uid';
'SELECT created_at, remaining_uses FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.Open;
if not LQ.IsEmpty then
begin
LConfigured := True;
LCreatedAt := LQ.FieldByName('created_at').AsString;
RemainingUses := LQ.FieldByName('remaining_uses').AsInteger;
end;
finally
LQ.Free;
@@ -146,6 +148,7 @@ begin
LObj := TJSONObject.Create;
LObj.AddPair('configured', TJSONBool.Create(LConfigured));
if LConfigured then LObj.AddPair('created_at', LCreatedAt);
if LConfigured then LObj.AddPair('remaining_uses', TJSONNumber.Create(RemainingUses));
TJSONHelper.SendJSON(AResponse, LObj);
end;
@@ -208,8 +211,8 @@ begin
LQ.SQL.Text :=
'INSERT INTO recovery_keys ' +
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi)';
' (user_id, code_hash, kdf_salt, wrapped_key, wrapped_iv, remaining_uses) ' +
'VALUES (:uid, :ch, :ks, :wk, :wi, 5)';
LQ.ParamByName('uid').AsInteger := LUserId;
LQ.ParamByName('ch').AsString := LCodeHash;
LQ.ParamByName('ks').AsString := LKdfSalt;
@@ -271,7 +274,7 @@ var
LBody, LObj: TJSONObject;
LUser, LCode, LCodeHash, LIP, LStoredHash, LKdfSalt, LWrappedKey, LWrappedIv,
LSalt, LToken, LCSRF: string;
LUserId, LKdfIters: Integer;
LUserId, LKdfIters, LCurrentUses, LNewUses: Integer;
LQ: TFDQuery;
begin
LIP := GetClientIP(ARequest);
@@ -307,7 +310,7 @@ begin
// Join to users to look up by username + verify the code in one shot.
LQ.SQL.Text :=
'SELECT u.id, u.salt, u.kdf_iterations, ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv ' +
' rk.code_hash, rk.kdf_salt, rk.wrapped_key, rk.wrapped_iv, rk.remaining_uses ' +
'FROM users u ' +
'LEFT JOIN recovery_keys rk ON rk.user_id = u.id ' +
'WHERE u.username = :u';
@@ -315,20 +318,19 @@ begin
LQ.Open;
if LQ.IsEmpty then
begin
// User doesn't exist OR has no recovery key configured. Same error
// either way to avoid leaking which.
RecordAttempt(LIP);
RecordFailedAccountAttempt(LUser, LIP);
TJSONHelper.SendError(AResponse, 401, 'Invalid username or recovery code');
Exit;
end;
LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
LUserId := LQ.FieldByName('id').AsInteger;
LSalt := LQ.FieldByName('salt').AsString;
LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger;
LStoredHash := LQ.FieldByName('code_hash').AsString;
LKdfSalt := LQ.FieldByName('kdf_salt').AsString;
LWrappedKey := LQ.FieldByName('wrapped_key').AsString;
LWrappedIv := LQ.FieldByName('wrapped_iv').AsString;
LCurrentUses := LQ.FieldByName('remaining_uses').AsInteger;
finally
LQ.Free;
end;
@@ -361,13 +363,24 @@ begin
Exit;
end;
// Code matches. Consume (delete the row) inside the same lock so the
// single-use guarantee holds even under concurrent requests.
// Code matches. Decrement remaining_uses ; if it drops to 0, delete
// the row (last use). The row is also deleted when the user
// successfully changes their master password (in PM.Handler.Auth).
LNewUses := LCurrentUses - 1;
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
if LNewUses <= 0 then
begin
LQ.SQL.Text := 'DELETE FROM recovery_keys WHERE user_id = :uid';
LQ.ParamByName('uid').AsInteger := LUserId;
end
else
begin
LQ.SQL.Text := 'UPDATE recovery_keys SET remaining_uses = :u WHERE user_id = :uid';
LQ.ParamByName('u').AsInteger := LNewUses;
LQ.ParamByName('uid').AsInteger := LUserId;
end;
LQ.ExecSQL;
finally
LQ.Free;
@@ -391,6 +404,7 @@ begin
LObj.AddPair('wrappedKey', LWrappedKey);
LObj.AddPair('wrappedIv', LWrappedIv);
LObj.AddPair('kdfSalt', LKdfSalt);
LObj.AddPair('remainingUses', TJSONNumber.Create(LNewUses));
TJSONHelper.SendJSON(AResponse, LObj);
end;