ad5fb21a1804d3eef6c9203e54e714714ff44efd
11 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
ad5fb21a18 |
feat: website favicons + vault health dashboard
Favicons
- PM.Favicon (new): THTTPClient/WinHTTP proxy to icons.duckduckgo.com.
Native Windows TLS — no OpenSSL DLLs to ship (Indy would fail
silently without them). 5 s timeout, max 3 redirects, 64 KB cap,
magic-byte MIME sniffing.
- DB: vault_entries.icon_b64 TEXT (idempotent migration).
- Endpoints: POST /entries/{id}/icon stores a cached data URI without
forcing a full PUT (which would re-encrypt the password). DELETE
/entries/icons/all purges the cache.
- Bridge cmd://favicon/fetch?host=X&reqId=Y runs in an anonymous thread
so the up-to-5 s HTTP GET doesn't block the main thread; result
shipped back via Bridge.onFaviconResult(reqId, host, dataUri).
- Hostname validated on both sides (JS faviconHost + Delphi
NormalizeHost) so brand labels like "Gitea" never leak upstream.
- Settings: opt-in "Fetch website icons" toggle (synced), three explicit
actions (Fetch missing / Re-fetch all / Clear cache) that bypass the
toggle — manual user actions always work.
- Entry card avatar shows <img> when cached, falls back to initials.
onerror handler recovers silently from a corrupt data URI.
Vault health
- New sidebar Tools → "Vault health" view. Four category cards:
Weak (strength < 50), Reused (same plaintext on ≥ 2 entries), Old
(updated_at > 365d), Pwned (HIBP cache).
- Score 0-100 with colour band (Good/Fair/At risk/Critical).
- One-shot computation cached per session (healthCache), invalidated
on lockVault, entry save, and the explicit "Recompute" button.
- "Fix" button on each item opens the slideover for the affected
entry, unmasks the password, focuses it, and pulses the dice button
— full context preserved, user decides how to fix.
- Click handler stopPropagation prevents the document-level
"click outside slideover" listener from closing the panel that
we just opened in the same click event.
Fixes
- openSlideover typo (lowercase O) → openSlideOver across all call
sites. Was silently breaking the Authenticator card click and the
Vault health Fix button.
- W1050 WideChar warning in PM.Favicon — replaced set-membership
with explicit Ord-style range comparisons.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
33e4b4b614 |
feat(autostart): "Start with Windows" toggle
- PM.AutoStart wraps HKCU\Software\Microsoft\Windows\CurrentVersion\Run.
Value "PMServer" = "<exe>" -tray. Per-user, no admin required, shows
up in Task Manager → Startup so the user can override from there.
- UMainForm honours the -tray CLI flag (set by the registry entry):
after the server starts, MinimizeToTray via TThread.ForceQueue so the
app comes up directly in the tray with no visible window flash.
- Bridge cmd://autostart/{get,set} + Bridge.getAutoStart() /
setAutoStart() / onAutoStartStatus(). Settings exposes a toggle in
the Security section, visible only when Bridge.active (the PHP
frontend can't touch the registry).
- Toggle reads "on" only when the registered command matches the
current exe path, so a stale entry from a moved exe lets the user
re-enable to refresh.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
|
||
|
|
40b3154a34 |
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>
|
||
|
|
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.
|
||
|
|
01c56edf25 |
feat(recovery): single-use recovery code for forgotten master password
In a zero-knowledge vault, forgetting the master password normally
means losing the data — the AES key is derived from the master pw
and the server can't help. This commit adds the standard escape
hatch: a one-time recovery code that key-wraps the AES key so the
user can get back in.
Threat model
============
The plaintext recovery code is shown to the user exactly once, at
generation time. Server only ever stores SHA-256(code) + an AES-GCM
wrap of the vault key under a KEK = PBKDF2(code, kdf_salt, 600k).
Without the plaintext code the server cannot unwrap. The code is
high-entropy (96 bits from a 32-char ambiguity-free alphabet, in 4
groups of 4) — printed form is misreading-resistant.
Single use: redeeming deletes the row inside the same DB.Lock the
lookup happened in, so concurrent redeem attempts are race-free.
Failed redemptions feed both the per-IP rate limit AND the per-
username lockout, so brute-forcing the code is infeasible.
Schema
======
recovery_keys (
user_id INTEGER PRIMARY KEY (1:1 with users, FK cascade),
code_hash TEXT NOT NULL (SHA-256 hex of plaintext code),
kdf_salt TEXT NOT NULL (PBKDF2 salt for KEK derivation),
wrapped_key TEXT NOT NULL (base64 AES-GCM ciphertext of vault key),
wrapped_iv TEXT NOT NULL (base64 12B IV for the wrap),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
Backend: new unit PM.Handler.Recovery
=====================================
GET /recovery-key/status (auth) -> { configured, created_at? }
POST /recovery-key/setup (auth + CSRF) body {masterPassword, codeHash,
kdfSalt, wrappedKey, wrappedIv}
DELETE /recovery-key (auth + CSRF) -> remove config
POST /recovery-key/redeem (NO auth) body {username, code}
-> session + wrappedKey + wrappedIv + kdfSalt
+ user's current salt + kdfIterations
VerifyMasterPassword() helper handles both legacy 'pbkdf2' and
current 'pbkdf2-sha256' schemes consistently with PM.Handler.Auth.
Setup flow
==========
1. Settings → "Generate recovery code" button (asks master pw via reauth).
2. Client generates: 16-char code + fresh kdf_salt + exports the current
AES key via crypto.subtle.exportKey('raw').
3. Client wraps the raw key under KEK=PBKDF2(code, kdf_salt, 600k)
with a random 12B IV → base64.
4. POSTs to /recovery-key/setup. Server verifies master pw, INSERT-or-
replaces the row (DELETE+INSERT, no UPSERT — same pattern as the
lockout table since FireDAC's UPSERT support is patchy).
5. Confirm modal shows the plaintext code in a monospace, user-select-all
panel. The modal is forcing: "I saved it" button is the only way out.
Modal is the only place the code ever appears — server never sees it.
Redeem flow (forgot master pw)
==============================
1. Auth screen → "Forgot master password? Use a recovery code" link.
2. promptDialog: username, then code (masked input).
3. POST /recovery-key/redeem. Server hashes the typed code, joins with
users by username, ConstantTimeEquals against stored hash. On match:
- deletes the recovery_keys row (single-use)
- issues a fresh session token + CSRF
- returns: { token, csrfToken, salt, kdfIterations, kdfSalt,
wrappedKey, wrappedIv, userId }
4. Client unwraps the AES key with PBKDF2(code, kdfSalt, 600k) → raw bytes
→ importKey('raw') back into a CryptoKey.
5. State is reconstituted from the new session, persistCryptoKey, enterApp.
6. Client immediately opens the Change-master-password modal — the
recovery code is consumed and the account needs a fresh master pw
AND a fresh recovery code (the user generates a new one from Settings).
Backward compat
===============
Recovery is opt-in. Existing users see "No recovery key set" in Settings
until they generate one. No migration needed — the table is created via
CREATE TABLE IF NOT EXISTS at server startup, FK cascade on user delete.
Minor UI additions
==================
- .btn-link CSS class for the auth-screen "Forgot master password?" link.
- Recovery-status label in Settings refreshed on every openSettings()
via GET /recovery-key/status.
|
||
|
|
cf94f67488 |
feat(2fa): TOTP secret storage + live 6-digit code generation
Adds RFC 6238 TOTP (Google Authenticator-style) support to every entry.
The secret is encrypted client-side with the same AES-GCM key as the
password — the server stores opaque ciphertext and never sees the
plaintext base32 secret.
Schema
======
vault_entries.totp_secret TEXT -- AES-GCM ciphertext, base64
vault_entries.totp_iv TEXT -- 12-byte IV, base64
Both NULL when the entry has no 2FA configured. Added via
ApplyMigrations.AddColumnIfMissing so existing vaults migrate cleanly.
Backend
=======
HandleListEntries: includes totp_secret + totp_iv in the response (or
JSON null when not configured).
HandleCreateEntry / HandleUpdateEntry: accept both fields; empty string
in the body → server stores NULL. Clearing the secret removes 2FA
from the entry.
Frontend
========
TOTP primitives (pure crypto.subtle, no external lib):
- base32Decode(s) — RFC 4648, tolerates spaces / lowercase
- generateTOTP(secret) — HMAC-SHA1 + RFC 4226 dynamic truncation
- parseOtpAuthUri(raw) — extracts ?secret from otpauth:// URIs
UI in the slide-over (the canonical entry detail view):
- New "Two-factor (TOTP)" field below the password row.
- Input is password-masked by default with eye-toggle to reveal.
- Pasting a full otpauth:// URI auto-extracts the secret param so the
user can copy directly from a QR-code scanner without manual cleanup.
- X button clears the secret (= removes 2FA on next save).
- Live code panel below: large monospace "123 456" + Copy button
(routes through Bridge.copySecure → secure clipboard + 30s auto-clear).
- Linear progress bar drains over the 30s window, turns red < 5s.
- Refresh tick runs once per second while the slide-over is open;
stops on closeSlideOver to avoid background work.
Entry card meta now shows a "2FA" chip when totp_secret is non-null —
quick visual scan for which accounts have 2FA configured without
opening the slide-over.
Validation
==========
soSave calls base32Decode(secret) before encrypting to refuse obviously
broken input. Otherwise garbled base32 would save fine and only fail
in the code panel next time.
Migration interaction (KDF 100k→600k)
=====================================
KNOWN MINOR ISSUE: /migrate-kdf only re-encrypts encrypted_password+iv,
not totp_secret+totp_iv. In practice this is harmless because:
1) KDF migration runs immediately after login on legacy accounts —
before the user has a chance to add a TOTP secret.
2) New accounts start at 600k iterations, no migration ever needed.
A legacy user who somehow added a TOTP between login and the
background migration completing would end up with a TOTP encrypted
under the old key. The fix (extend /migrate-kdf to re-encrypt TOTP
fields too) is a one-line follow-up if anyone hits the edge case.
|
||
|
|
a45897c33d |
feat(security): HIBP password breach check + CSP tightening
HIBP integration ================ Opt-in (default OFF) password breach check via the Have I Been Pwned range API. The full master / entry password never leaves the machine — only the first 5 characters of its SHA-1 hash. HIBP returns ~500 candidate suffixes; the client matches its own suffix locally. UI: - New "Check passwords against breach database (HIBP)" toggle in Settings → Security with an explainer hint about k-anonymity. - On enable: background batch scan of all entries, results cached in state.hibpResults keyed by entry id. Concurrency capped at 6 to avoid hammering HIBP / hitting browser connection limits. - Entry cards show a red "Pwned" chip + breach count in the tooltip when count > 0. New i-alert icon added to the SVG sprite. - Auto-scan triggered after every enterApp() when the toggle is on. Functions added to app.js: - sha1Hex(text) — crypto.subtle wrapper - hibpCheckPassword(plaintext) — single-password check, returns count - hibpCheckAllEntries() — batched scan over state.entries The "Add-Padding: true" header is sent on every range request to defeat the response-size side-channel (HIBP adds 800-1000 random extra entries so an observer counting bytes can't narrow the prefix queried). CSP tightening ============== Audited the served HTML: zero <script> tags inline, only the external js/app.js. Removed 'unsafe-inline' from script-src — real XSS defense. Kept 'unsafe-inline' on style-src for now because index.html contains inline style="" attributes and app.js calls element.style.cssText extensively. Refactoring to CSS classes is a separate cleanup. Style injection alone cannot execute code, so the residual risk is bounded to visual manipulation in a single-user loopback app. Added api.pwnedpasswords.com to connect-src as the only allowed external origin (required by the HIBP feature above). Default still 'self' — everything else stays loopback. Before: script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; connect-src 'self'; After: script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://api.pwnedpasswords.com; |
||
|
|
e0e452306e |
feat(crypto): PBKDF2 iterations 100k → 600k with transparent re-encryption
Bumps the PBKDF2-SHA256 iteration count from 100,000 (OWASP 2017) to
600,000 (OWASP 2023). 6x slowdown on every brute-force attempt against
either the server-stored auth hash OR the AES-GCM ciphertext of the
entries — both currently use the same PBKDF2 output (see KNOWN ISSUE
below for why that's another problem to fix later).
Schema
======
users.kdf_iterations INTEGER DEFAULT 100000
Per-user iteration count. Legacy rows predating the column default
to 100k via the DEFAULT clause. New accounts insert 600k explicitly.
Migration flow
==============
Atomic from the user's perspective. No partial state ever persisted.
1. /login (or /reauth):
server reads users.kdf_iterations and verifies the master pw at
that count. Login succeeds at the legacy strength. Response now
includes kdfIterations (current) and optionally kdfMigration =
{ target: 600000 } when an upgrade is recommended.
2. Client:
derives the AES key at the OLD count to decrypt current entries
(state.cryptoKey). enterApp() loads the vault normally.
3. runKdfMigration() (background, after enterApp):
- derives the NEW key at target iterations
- decrypts every entry with the old key
- re-encrypts every entry with the new key + fresh random IVs
- POSTs { masterPassword, entries: [...] } to /migrate-kdf
4. /migrate-kdf (new endpoint):
- verifies the master pw against the OLD hash
- in a single transaction:
UPDATE users SET password_hash = pbkdf2(pw, salt, 600k),
kdf_iterations = 600000
UPDATE vault_entries SET encrypted_password, iv (per entry)
- on any failure: ROLLBACK. User stays at legacy config, retries
at next login. No half-migrated state possible.
5. Client (post-commit):
swaps state.cryptoKey to the new key, persists it, updates the
cached ciphertext in state.entries, shows a "Vault security
upgraded" toast.
Idempotency: server's /migrate-kdf short-circuits with "Already at
target" if users.kdf_iterations >= PBKDF2_ITERATIONS_TARGET.
Race conditions: two concurrent migrations from two tabs both
recompute the SAME new key (deterministic PBKDF2). The losing
transaction's entries get re-encrypted with the winning one's IVs,
but both clients can decrypt because the keys are identical.
KNOWN ISSUE (not fixed by this commit)
======================================
The server's password_hash IS the client's AES key, in hex form —
both sides compute PBKDF2(pw, salt, iters) and store/use the same
32 bytes. This means a stolen vault.db gives the attacker the
encryption key directly, without needing to brute-force anything.
The 100k → 600k bump still helps because the AES-GCM ciphertext
itself is also a brute-force target, but the architectural fix
(server stores SHA256(aes_key) instead of aes_key in hex) is a
separate concern that needs its own migration.
Other changes
=============
- HandleRegister: new accounts insert kdf_iterations=600000.
- HandleReauth: response upgraded to JSON with kdfIterations
+ optional kdfMigration. Unlock path now also triggers migration.
- SendAuthSuccess: extended signature, all callers updated.
- deriveKey(pwd, saltHex, iterations) in app.js: iterations param
required, defaults to 100000 for back-compat with any legacy caller.
|
||
|
|
bff9bdf9f2 |
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.
|
||
|
|
9f6636defc |
feat(auth): per-account brute-force lockout with exponential backoff
Existing protection was per-IP only (login_attempts table). On a loopback
deployment everyone hits 127.0.0.1, so the per-IP counter is mostly
ornamental — the real attacker is on the same machine. Adds a second
defense layer that tracks failures per username with an exponential
backoff schedule.
Schema:
account_lockouts (username TEXT PK, failed_count INT,
locked_until DATETIME, last_attempt_at, last_attempt_ip)
Backoff after threshold (4+ failures):
1, 2, 3 failures → no lockout (grace window for typos)
4th → 60 s
5th → 5 min
6th → 15 min
7th → 1 h
8th → 6 h
9th and beyond → 24 h (capped)
Counter resets to 0 on successful login or reauth. Old non-locked rows
older than 30 days are pruned by CleanupExpired alongside the existing
sessions / audit_log / login_attempts cleanups.
Wiring:
- HandleLogin / HandleReauth both check RejectIfAccountLocked() before
touching the users table. Lockout responses are 429 with JSON body
{ error, retry_after } and a Retry-After header.
- Failed attempts are recorded against the username even when the user
doesn't exist, preventing account enumeration via differential
"is this account locked?" probes.
- PBKDF2 hash comparison was already constant-time (ConstantTimeEquals);
no change there.
Client (js/app.js):
- api() now preserves response status + body on Error so callers can
distinguish 429-lockout from other errors.
- New showLockoutCountdown(seconds) renders a live "Account locked —
try again in Xm Ys" message in #authHint, disables #loginBtn until
the countdown reaches 0, then re-enables it.
- doLogin / doUnlock both branch on err.status === 429 + retry_after
to call showLockoutCountdown instead of a generic error toast.
Known limitation: an attacker can DoS-lock arbitrary usernames by
spamming /login with that name. This is intentional — the alternative
(per-(username,IP) tracking) would let attackers enumerate accounts.
DoS-lock is acceptable; auth bypass is not.
|
||
|
|
506aee7e6f |
feat: Delphi backend + JS↔Delphi bridge (clipboard, tray, auto-lock)
Introduces the Delphi 12 FMX backend (PMServer) that hosts the embedded
WebView2 vault on 127.0.0.1, and a native bridge between JS and Delphi
that wires three privacy-focused features:
1. Secure clipboard
Copying a password registers the Win32 "ExcludeClipboardContentFromMonitorProcessing"
format alongside CF_UNICODETEXT, so Win+V clipboard history never sees
the value. Auto-clears after 30s via TTimer. Bridge.copySecure() in
app.js routes all password/username/secret copy paths through the
native layer when running inside the Delphi WebView2 (falls back to
navigator.clipboard for the PHP standalone).
2. Tray icon (X-to-tray when server running)
Closing the dev panel hides both the form HWND and the TFMAppClass
per-process proxy window that owns the FMX taskbar entry — the form's
HWND alone is not the taskbar-visible one in FMX (took some iteration
to discover). Tray menu: Open, Lock vault, Quit. Clipboard is force-
cleared on minimize as extra safety. First-time minimize fires a
balloon notification so the user knows the app is still running.
3. Auto-lock on Windows session lock (Win+L)
wtsapi32.dll!WTSRegisterSessionNotification on a dedicated message-only
window. On WM_WTSSESSION_CHANGE / WTS_SESSION_LOCK, the bridge calls
ExecuteJavaScript('lockVault()'). Same path used by the tray "Lock vault"
menu item.
Bridge architecture:
- JS → Delphi via cmd:// URLs intercepted in OnBeforeNavigate
(pattern lifted from DeskInsight Monaco). Currently exposes
cmd://clipboard/copy?text=...&clear=... and cmd://clipboard/clear.
- Delphi → JS via TTMSFNCWebBrowser.ExecuteJavaScript with guarded
calls (typeof check) so the bridge degrades cleanly if app.js isn't
loaded yet.
Files:
- Source/PM.Bridge.pas (new) — TSecureClipboard + TPMBridge
- UMainForm.pas/.fmx — bridge wiring, FormCloseQuery intercept, tray
callbacks (BridgeTrayRestore / BridgeLockRequest / BridgeQuit)
- js/app.js — Bridge object, 5 navigator.clipboard sites migrated to
Bridge.copySecure with PHP-compatible fallback, Bridge.onTrayRestore
handler that resets the auto-lock timer
.gitignore extended with Delphi build artifacts (*.dcu, Win32/, Win64/,
__history/, __recovery/, *.identcache, *.dsk, *.local, etc.) so source
checkouts stay clean.
|