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:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user