Commit Graph

6 Commits

Author SHA1 Message Date
Zaki 2ef636ce30 feat: unified entry slideover + custom icons + UX fixes
Unified create/edit slideover
- openSlideOver(id) now accepts null for new entries. Same UI
  (icon, name, site, user, password, TOTP, folder, tags) for both
  create and edit. Drops the separate entry modal — no more "save
  first, then add TOTP" two-step.
- "+ New" button, Ctrl+K → New entry, and Ctrl+Shift+A all route
  through the slideover. Ctrl+Shift+A pre-fills the title with the
  foreground window's name.
- Save button visible from the start in new mode (no dirty wait).
- Title shows mode unambiguously: cyan "+ New entry" vs
  "Edit · <name>".

Custom icon upload (soIconField)
- 56×56 preview at the top of every slideover + Upload icon /
  Remove buttons. Same POST /entries/{id}/icon endpoint as the
  auto-fetch path. Validates type / size (64 KB cap matching server).
- Solves the case where DDG doesn't index a domain (self-hosted
  apps, private sites): the user pastes any image and it sticks.

Favicon: privacy-first, DDG only
- Removed the direct-fetch fallback steps (3-5). Privacy stance:
  zero DNS leak outside icons.duckduckgo.com. Domains DDG doesn't
  cover stay icon-less until the user uploads a custom one.
- PM.Favicon.FetchFaviconDataUri takes an optional TFaviconLog
  callback so UMainForm can stream per-step trace into LogLine for
  diagnostics.

Fixes
- Slideover z-index 30 → 50. The topbar's backdrop-filter creates a
  stacking context at z-index 40 which was clipping the slideover
  header (title + close button hidden behind topbar).
- RestoreFromTray no longer un-maximises a maximised window when
  called outside a tray-restore context (Ctrl+Shift+A, Ctrl+Shift+L
  picker, app/focus cmd). SW_RESTORE on a maximised window reverts
  to normal — now we only SW_RESTORE if IsIconic.
- "Show all"/"Show less" per-category state survives renderGrid
  re-renders (healthExpanded map).
- "+ New" and dashboard "Fix" buttons stopPropagation so the
  document-level click-outside handler doesn't close the slideover
  they just opened.
- soDirtyCheck keeps Save visible while in new mode regardless of
  diff.
- openSlideover → openSlideOver typo fix across all call sites.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-10 00:15:20 +01:00
Zaki 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>
2026-06-09 00:05:54 +01:00
Zaki 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>
2026-06-08 21:39:37 +01:00
Zaki 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>
2026-06-08 21:31:39 +01:00
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
Zaki 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.
2026-05-22 23:47:57 +01:00