Files
Password-Manager/delphi-backend/UMainForm.pas
T
Zaki 749dc87058 feat(unlock): Quick unlock via DPAPI (remember on this device)
User-controlled opt-in to skip the master-password prompt on subsequent
app starts. The vault state (raw AES key + salt + username + session
token) is bundled and handed to the Delphi side, which DPAPI-encrypts
it with CRYPTPROTECT_CURRENT_USER and stashes the blob at
%LOCALAPPDATA%\PMServer\quickunlock.bin.

Honest threat model
===================
This is NOT biometric authentication. The DPAPI scope is the Windows
USER ACCOUNT — any process running as the same user can decrypt the
blob via the same DPAPI call. The security perimeter is the Windows
account itself. The Settings UI label is "Quick unlock" with an
explainer:
  "convenient on a personal machine, not safe on a shared one"

If the user has Windows Hello / fingerprint / PIN configured at the
OS level, then Windows login is biometric-gated and that gating
transitively applies to DPAPI access — but the cryptographic strength
of the encryption isn't tied to the biometric, it's tied to the
Windows account secret. Honest framing matters here, so the feature
isn't sold as "biometric".

Backend
=======
New unit Source/PM.QuickUnlock.pas:
 - StoreQuickUnlock(bytes) → DPAPI-encrypt and persist to
   %LOCALAPPDATA%\PMServer\quickunlock.bin
 - LoadQuickUnlock(out bytes) → read file, DPAPI-decrypt
 - ClearQuickUnlock → forget-me
 - HasQuickUnlock → file existence probe
DPAPI declarations are local (CryptProtectData / CryptUnprotectData
from crypt32.dll) — Winapi.WinCrypt's signatures drift across Delphi
versions and we don't want to fight that.

Bridge commands (UMainForm.HandleBridgeCommand):
 cmd://quickunlock/store?data=<base64>   payload opaque to Delphi
 cmd://quickunlock/get                   → ExecuteJavaScript callback
                                           Bridge.onQuickUnlockResult(b64|null)
 cmd://quickunlock/clear                 forget-me
 cmd://quickunlock/status                → Bridge.onQuickUnlockStatus(bool)

The get / status results are returned via ExecuteJavaScript rather than
HTTP (the bridge is request-only) — JS resolves a Promise that the
caller awaited.

Client
======
state.quickUnlockEnabled mirrors localStorage flag, lazy-cleared if the
backing DPAPI blob has gone missing (e.g., user reset Windows profile).

enableQuickUnlock():
  1. askReauth + /reauth to verify it's actually the user.
  2. exportKey('raw', state.cryptoKey) — extractable already.
  3. JSON-bundle { v, username, salt, token, csrf, key } → base64.
  4. cmd://quickunlock/store sends the blob to Delphi.

tryQuickUnlock() (called from init):
  1. Probe localStorage flag.
  2. cmd://quickunlock/get, await Bridge.onQuickUnlockResult.
  3. Decode JSON, importKey, restore state.* + sessionStorage.
  4. Return true on success, false to fall through to master-pw login.

Two restore scenarios both covered:
 A. Same app session (sessionStorage still populated, only cryptoKey
    was wiped by lock). tryQuickUnlock just restores the key.
 B. Cold start (sessionStorage empty). tryQuickUnlock restores
    EVERYTHING from the DPAPI blob, including the session token.

UI
==
Settings panel → new "Quick unlock" section above Recovery key.
Single toggle button: "Enable on this device" / "Disable" with status
line above. Opens settings → bridgeQuickUnlockStatus() reconciles the
JS-side flag with the actual file (drift detection).

Stale-blob protection
=====================
The stored blob holds the AES key BYTES, which would become useless
if the vault were re-encrypted under a different key. Three paths
that re-encrypt the vault now also wipe the DPAPI blob:
 - Explicit doLogout (user said "I'm done")
 - Master password change (new key, old blob can't decrypt anything)
 - (Recovery redeem already forces master pw change → covered.)

The blob persists across the passive lockVault() flow on purpose —
that's the whole point: lock without losing convenience.

Init wiring
===========
On app start, the existing "restore session from sessionStorage" path
now falls through to tryQuickUnlock if either sessionStorage is empty
OR the cryptoKey is gone. Auth screen shows up only after both
attempts fail.
2026-05-23 11:25:39 +01:00

379 lines
11 KiB
ObjectPascal

unit UMainForm;
interface
uses
System.SysUtils, System.Classes, System.UITypes, System.NetEncoding,
FMX.Forms, FMX.Controls, FMX.Controls.Presentation, FMX.StdCtrls,
FMX.Memo, FMX.Memo.Types, FMX.ScrollBox, FMX.Edit, FMX.Layouts, FMX.Types,
FMX.Dialogs,
FMX.TMSFNCTypes, FMX.TMSFNCUtils, FMX.TMSFNCGraphics, FMX.TMSFNCGraphicsTypes,
FMX.TMSFNCCustomControl, FMX.TMSFNCWebBrowser,
PM.HTTPServer, PM.Bridge, PM.QuickUnlock;
type
TMainForm = class(TForm)
PanelTop: TPanel;
btnStart: TButton;
btnStop: TButton;
lblStatus: TLabel;
edtPort: TEdit;
lblPort: TLabel;
btnToggleLog: TButton;
btnReload: TButton;
PanelLog: TPanel;
Memo: TMemo;
Splitter: TSplitter;
WebBrowser: TTMSFNCWebBrowser;
procedure FormCreate(Sender: TObject);
procedure FormDestroy(Sender: TObject);
procedure FormCloseQuery(Sender: TObject; var CanClose: Boolean);
procedure btnStartClick(Sender: TObject);
procedure btnStopClick(Sender: TObject);
procedure btnToggleLogClick(Sender: TObject);
procedure btnReloadClick(Sender: TObject);
private
FServer: TPMHTTPServer;
FBridge: TPMBridge;
FPendingURL: string;
FNavTimer: TTimer;
FNavAttempts: Integer;
FQuitting: Boolean; // set when user picks "Quit" in tray menu — bypasses
// FormCloseQuery's minimize-to-tray intercept.
procedure LogLine(const AMsg: string);
procedure UpdateButtons;
procedure NavigateToVault;
procedure NavTimerTick(Sender: TObject);
// JS↔Delphi bridge
procedure WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
procedure HandleBridgeCommand(const ACmd, AParams: string);
procedure BridgeSystemLock;
procedure BridgeTrayRestore;
procedure BridgeLockRequest;
procedure BridgeQuit;
end;
var
MainForm: TMainForm;
implementation
{$R *.fmx}
procedure TMainForm.FormCreate(Sender: TObject);
begin
FServer := TPMHTTPServer.Create;
FServer.OnLog := LogLine;
FBridge := TPMBridge.Create(Self);
FBridge.OnSystemLock := BridgeSystemLock;
FBridge.OnTrayRestore := BridgeTrayRestore;
FBridge.OnLockRequest := BridgeLockRequest;
FBridge.OnQuit := BridgeQuit;
// Wire the cmd:// bridge before any navigation happens.
WebBrowser.OnBeforeNavigate := WebBrowserBeforeNavigate;
// Delayed-Navigate timer: TTMSFNCWebBrowser (WebView2 backend) ignores
// Navigate() calls until Edge Chromium finishes its async init (~1-2s).
// We wait 1.5 s after Start, then issue a SINGLE Navigate — no retry loop
// (retrying caused the loaded page to reload every interval, making icons
// flash). If Edge needed longer than 1.5 s, user clicks Reload.
FNavTimer := TTimer.Create(Self);
FNavTimer.Interval := 1500;
FNavTimer.Enabled := False;
FNavTimer.OnTimer := NavTimerTick;
UpdateButtons;
LogLine('Password Manager - Delphi backend ready.');
LogLine('Click Start to launch server + embedded web vault.');
end;
procedure TMainForm.FormDestroy(Sender: TObject);
begin
FBridge.Free;
FServer.Free;
end;
procedure TMainForm.FormCloseQuery(Sender: TObject; var CanClose: Boolean);
begin
// The tray-menu "Quit" handler sets FQuitting before triggering close,
// so we bypass the minimize-to-tray intercept in that case.
if FQuitting then Exit;
// Otherwise: minimize to tray on close instead of quitting, so the vault
// stays available without the dev-panel being visible.
// When the server is stopped, allow normal close — there's no vault to
// keep alive in the background.
if FServer.Active then
begin
CanClose := False;
FBridge.MinimizeToTray;
LogLine('Minimized to tray. Click the tray icon to restore.');
end;
end;
procedure TMainForm.LogLine(const AMsg: string);
begin
// Synchronize handles both cases: if already on main thread, runs inline;
// otherwise marshals. Avoids overload resolution issues with TThread.Queue.
TThread.Synchronize(nil,
procedure
begin
Memo.Lines.Add(FormatDateTime('hh:nn:ss', Now) + ' ' + AMsg);
Memo.GoToTextEnd;
end);
end;
procedure TMainForm.UpdateButtons;
begin
btnStart.Enabled := not FServer.Active;
btnStop.Enabled := FServer.Active;
btnReload.Enabled := FServer.Active;
edtPort.Enabled := not FServer.Active;
if FServer.Active then
lblStatus.Text := 'Running on http://127.0.0.1:' + edtPort.Text
else
lblStatus.Text := 'Stopped';
end;
procedure TMainForm.NavigateToVault;
begin
FPendingURL := 'http://127.0.0.1:' + edtPort.Text + '/index.html';
LogLine('Will navigate embedded browser in ~1.5s to: ' + FPendingURL);
// Schedule a single Navigate after Edge has had time to initialize.
FNavTimer.Enabled := False; // restart timer if already running
FNavTimer.Enabled := True;
end;
procedure TMainForm.NavTimerTick(Sender: TObject);
begin
FNavTimer.Enabled := False; // one-shot
if FPendingURL = '' then Exit;
LogLine('Navigating to: ' + FPendingURL);
WebBrowser.Navigate(FPendingURL);
FPendingURL := '';
end;
procedure TMainForm.btnStartClick(Sender: TObject);
var
LPort: Integer;
begin
LPort := StrToIntDef(edtPort.Text, 8765);
try
FServer.Start(LPort);
UpdateButtons;
NavigateToVault;
except
on E: Exception do
begin
LogLine('ERROR starting server: ' + E.Message);
MessageDlg('Failed to start: ' + E.Message,
TMsgDlgType.mtError, [TMsgDlgBtn.mbOK], 0);
end;
end;
end;
procedure TMainForm.btnStopClick(Sender: TObject);
begin
FServer.Stop;
UpdateButtons;
FPendingURL := '';
FNavTimer.Enabled := False;
WebBrowser.Navigate('about:blank');
end;
procedure TMainForm.btnReloadClick(Sender: TObject);
begin
if FServer.Active then NavigateToVault;
end;
procedure TMainForm.btnToggleLogClick(Sender: TObject);
begin
PanelLog.Visible := not PanelLog.Visible;
Splitter.Visible := PanelLog.Visible;
if PanelLog.Visible then
btnToggleLog.Text := 'Hide log'
else
btnToggleLog.Text := 'Show log';
end;
// ---------------------------------------------------------------------------
// JS↔Delphi bridge
// ---------------------------------------------------------------------------
procedure TMainForm.WebBrowserBeforeNavigate(Sender: TObject;
var Params: TTMSFNCCustomWebBrowserBeforeNavigateParams);
var
URL, Cmd, ParamStr: string;
P: Integer;
begin
URL := Params.URL;
if not URL.StartsWith('cmd://') then Exit;
Params.Cancel := True;
URL := URL.Substring(6); // strip 'cmd://'
P := Pos('?', URL);
if P > 0 then
begin
Cmd := Copy(URL, 1, P - 1);
ParamStr := Copy(URL, P + 1, MaxInt);
end
else
begin
Cmd := URL;
ParamStr := '';
end;
// Defer to avoid WebView2 re-entrance issues.
TThread.ForceQueue(nil,
procedure
begin
HandleBridgeCommand(Cmd, ParamStr);
end);
end;
procedure TMainForm.HandleBridgeCommand(const ACmd, AParams: string);
function GetParam(const AKey: string): string;
var
Parts: TArray<string>;
Part, K, V: string;
EqPos: Integer;
begin
Result := '';
Parts := AParams.Split(['&']);
for Part in Parts do
begin
EqPos := Pos('=', Part);
if EqPos > 0 then
begin
K := Copy(Part, 1, EqPos - 1);
V := Copy(Part, EqPos + 1, MaxInt);
if SameText(K, AKey) then
begin
Result := TNetEncoding.URL.Decode(V);
Exit;
end;
end;
end;
end;
var
LText: string;
LClearMs: Integer;
begin
if ACmd = 'clipboard/copy' then
begin
LText := GetParam('text');
LClearMs := StrToIntDef(GetParam('clear'), 30000);
FBridge.SecureClipboard.SetText(LText, LClearMs);
LogLine(Format('Secure clipboard set (auto-clear in %ds)', [LClearMs div 1000]));
end
else if ACmd = 'clipboard/clear' then
begin
FBridge.SecureClipboard.Clear;
LogLine('Clipboard cleared by JS request');
end
// ---- Quick unlock (DPAPI persistence of the vault key) ----
// store: client provides a base64-encoded blob (UTF-8 JSON, content
// opaque to us). We DPAPI-encrypt and stash on disk.
// get: we DPAPI-decrypt, base64-encode, send back via ExecuteJavaScript.
// clear: forget-me.
else if ACmd = 'quickunlock/store' then
begin
LText := GetParam('data'); // base64 of UTF-8 JSON blob
if LText = '' then Exit;
var LBytes := TNetEncoding.Base64.DecodeStringToBytes(LText);
if PM.QuickUnlock.StoreQuickUnlock(LBytes) then
LogLine(Format('Quick unlock stored (%d bytes)', [Length(LBytes)]))
else
LogLine('Quick unlock store FAILED (DPAPI error)');
end
else if ACmd = 'quickunlock/get' then
begin
var LBytes: TBytes;
if PM.QuickUnlock.LoadQuickUnlock(LBytes) and (Length(LBytes) > 0) then
begin
var LB64 := TNetEncoding.Base64.EncodeBytesToString(LBytes);
// Strip newlines that the Base64 encoder may insert (line-wrapping
// breaks the JS-side decoder) before injecting into a JS string.
LB64 := StringReplace(LB64, #13, '', [rfReplaceAll]);
LB64 := StringReplace(LB64, #10, '', [rfReplaceAll]);
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
'Bridge.onQuickUnlockResult("' + LB64 + '")');
LogLine('Quick unlock served');
end
else
begin
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onQuickUnlockResult)' +
'Bridge.onQuickUnlockResult(null)');
end;
end
else if ACmd = 'quickunlock/clear' then
begin
PM.QuickUnlock.ClearQuickUnlock;
LogLine('Quick unlock cleared');
end
else if ACmd = 'quickunlock/status' then
begin
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&Bridge.onQuickUnlockStatus)' +
'Bridge.onQuickUnlockStatus(' +
BoolToStr(PM.QuickUnlock.HasQuickUnlock, True).ToLower + ')');
end
else
LogLine('Bridge: unknown command "' + ACmd + '"');
end;
procedure TMainForm.BridgeSystemLock;
begin
// Windows session locked — lock the vault in the JS layer immediately.
LogLine('Windows session locked — locking vault');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
end;
procedure TMainForm.BridgeTrayRestore;
begin
FBridge.RestoreFromTray;
// Notify the JS layer: the UI may want to reset the auto-lock timer,
// refresh state, or show a "welcome back" toast.
WebBrowser.ExecuteJavaScript(
'if(window.Bridge&&typeof Bridge.onTrayRestore==="function")Bridge.onTrayRestore()');
LogLine('Restored from tray');
end;
procedure TMainForm.BridgeLockRequest;
begin
// User picked "Lock vault" from the tray menu. Trigger lockVault() in
// JS — same path as the WTS_SESSION_LOCK auto-lock.
LogLine('Lock requested from tray menu');
WebBrowser.ExecuteJavaScript('if(typeof lockVault==="function")lockVault()');
end;
procedure TMainForm.BridgeQuit;
begin
// Re-entry guard: if Quit was already requested, ignore further calls.
if FQuitting then Exit;
LogLine('>>> BridgeQuit invoked (Quit from tray menu)');
FQuitting := True;
// Restore the form first so the tray icon goes away and FormDestroy
// executes from a normal (non-hidden) state. RestoreFromTray also
// deletes the tray icon.
FBridge.RestoreFromTray;
Application.Terminate;
end;
end.