Files
Password-Manager/CLAUDE.md
T
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

21 KiB

Password Manager — Architecture éclair

Desktop password vault Windows. Exe Delphi FMX embarquant WebView2 qui charge un frontend HTML/JS via Indy HTTP loopback (127.0.0.1:8765). 100 % offline, multi-utilisateur, chiffrement client-side (AES-GCM, clé dérivée PBKDF2-SHA256 600 k iter).

Stack

  • Backend : Delphi 12, FMX, TMS FNC WebBrowser (Edge WebView2)
  • HTTP : Indy TIdHTTPServer bindé 127.0.0.1 only
  • DB : SQLite via FireDAC (vault.db, gitignored)
  • Frontend : vanilla JS + CSS, pas de framework (js/app.js monofile ~4000 lignes)

Build pipeline (CRITIQUE)

Toute modif index.html / js/ / css/ nécessite :

  1. delphi-backend\assets\BuildAssets.cmd → régénère assets.res
  2. Build Delphi (F9 ou MSBuild PMServer.dproj)

Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le plus courant après modif frontend.

Carte des fichiers

Rôle Path
Form principale, wire bridge, dispatch cmd:// delphi-backend/UMainForm.pas
Bridge JS↔Delphi (clipboard, tray, hotkeys, autofill, focus, debug) delphi-backend/Source/PM.Bridge.pas
HTTP server (ordre dispatch : router → embedded → static → 404) delphi-backend/Source/PM.HTTPServer.pas
Routes API (regex match) delphi-backend/Source/PM.Router.pas
Auth + sessions + CSRF delphi-backend/Source/PM.Session.pas
Migrations DB idempotentes (AddColumnIfMissing) delphi-backend/Source/PM.Database.pas
Static files disque (mort en prod, embedded gagne) delphi-backend/Source/PM.StaticFiles.pas
Assets embedded dans .res delphi-backend/Source/PM.EmbeddedAssets.pas
Quick unlock DPAPI (device-bound) delphi-backend/Source/PM.QuickUnlock.pas
Prefs key/value DPAPI (prefs.bin) delphi-backend/Source/PM.UserPrefs.pas
Single-instance mutex + broadcast delphi-backend/Source/PM.SingleInstance.pas
Start with Windows (HKCU Run) delphi-backend/Source/PM.AutoStart.pas
Handlers REST delphi-backend/Handlers/PM.Handler.*.pas
Frontend complet js/app.js
HTML racine index.html
Styles css/style.css

Bridge cmd:// (JS → Delphi)

JS navigue vers cmd://action/sub?params → intercepté par WebBrowserBeforeNavigateHandleBridgeCommand.

Commandes connues :

  • clipboard/copy, clipboard/clear, clipboard/read (Paste custom menu)
  • quickunlock/{store,get,clear,status}
  • prefs/{get,set}?key=... (device-bound DPAPI key/value, voir plus bas)
  • autostart/{get,set}?enabled=1|0 (HKCU Run registry, "Start with Windows")
  • autofill/{configure,hotkeys,execute,cancel}
  • app/focus (ramène la fenêtre au premier plan, pour le picker)
  • app/ready (page chargée → SetFocus WebBrowser + DOM focus auth input)
  • app/theme?mode=dark|light (sync title bar + popup menus via SetPreferredAppMode)
  • audit (POST log d'action user-visible)
  • entry/new-from-title (Ctrl+Shift+A → modal pré-rempli)

Retour Delphi → JS via WebBrowser.ExecuteJavaScript('Bridge.onX(...)').

Hotkeys globaux Win32

  • Ctrl+Shift+L : autofill complet (user + Tab + password)
  • Ctrl+Shift+P : autofill password seul (forms step-2, unlock screens)
  • Ctrl+Shift+A : new entry pré-rempli avec le titre de la fenêtre foreground
  • Ctrl+Shift+D : toggle debug panel (uniquement si config.txt présent)

Combos autofill configurables depuis Settings (synced en DB via settings_json).

Debug mode

Fichier config.txt à côté de l'exe (tous champs optionnels) :

port=8765                    # 0 ou absent = port aléatoire ephemeral (49152-65535)
debug=true                   # affiche PanelTop (Start/Stop, log memo)
require_token=false          # désactive le token validation (curl/Postman direct)
require_process_check=false  # désactive le PID-on-socket check

Sans config.txt, Ctrl+Shift+D ne fait rien (sécurité, panel jamais exposé en prod).

HTTP server lockdown (anti-browser-direct)

Le serveur Indy n'accepte une requête que si elle a :

  • Query param ?pmt=<token> (sur la 1ʳᵉ navigation) → Set-Cookie
  • OU cookie pm_token=<token>; HttpOnly; SameSite=Strict; Path=/

Au start :

  • Port aléatoire dans [49152, 65535] si pas configuré
  • Token random 32-chars hex généré
  • WebView2 navigate vers http://127.0.0.1:{port}/index.html?pmt={token}
  • JS strip le ?pmt=... du URL bar via history.replaceState

Requête sans token valide → 404 (pas 403 pour ne pas révéler que le serveur existe). Override via require_token=false.

Couche 2 : PID-on-socket process check

Sur chaque request, PM.ProcessLockdown :

  • GetExtendedTcpTable (winapi iphlpapi.dll) lookup le PID qui possède la connexion entrante (match sur Chrome's localPort + remotePort = our port)
  • IsDescendantOfCurrentProcess walk parent tree via Toolhelp32 → notre exe est-il ancêtre ?
  • Si non (curl externe, autre browser, etc.) → 404

Defense in depth : si quelqu'un connaît le token (leaked logs, mémoire), il faut AUSSI exécuter le code dans notre lignée de process pour passer. Override via require_process_check=false. Notre WebView2 (children msedgewebview2.exe) passent naturellement.

Token masqué dans les logs

MaskAccessToken(url) dans UMainForm remplace ?pmt=<32 chars> par ?pmt=*** avant LogLine. Évite le leak via copie/upload du log.

Title bar + popup menus dark mode

PM.Bridge.ApplyTitleBarTheme(ADark: Boolean) fait 2 choses :

  1. DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE=20) → title bar. No-op sur Win < 19044.
  2. uxtheme!SetPreferredAppMode (ordinal #135) + FlushMenuThemes (#136) — API privée stable depuis Win10 1809 utilisée par Explorer/Edge/Office. Themea automatiquement les popup menus (tray context menu inclus), scrollbars, tooltips. Valeurs : ForceDark=2, ForceLight=3.

Appelé au FormCreate + via cmd://app/theme?mode=dark|light quand JS toggle.

Single instance

Au démarrage, PM.SingleInstance.AcquireOrSignal :

  • Crée un mutex nommé Local\PMServer.SingleInstance.Mutex (namespace Local\ = per-session, donc deux users Windows distincts peuvent chacun lancer le leur)
  • Si ERROR_ALREADY_EXISTS : PostMessage(HWND_BROADCAST, WM_PMSHOW, 0, 0)WM_PMSHOW = RegisterWindowMessage('PMServer_ShowExisting') est un ID system-unique. Puis exit immédiat avant Application.Initialize.

L'instance existante reçoit WM_PMSHOW dans son message-only window (PM.Bridge.MsgWindowHandler) → déclenche FOnTrayRestoreRestoreFromTray. AllocateHWnd crée une fenêtre top-level (pas HWND_MESSAGE) donc elle reçoit bien le broadcast.

Tray icon

Shell_NotifyIcon(NIM_ADD) au constructor de TPMBridge, pas au premier minimize → ic ône visible dès le démarrage, même si la fenêtre est ouverte. NIM_DELETE uniquement au destructor.

MinimizeToTray ne touche plus à l'icône, juste à la visibilité de la fenêtre + clipboard clear + balloon first-time.

Menu : Open / Lock vault / Quit (via TrackPopupMenu, themé par SetPreferredAppMode ci-dessus).

Start with Windows (PM.AutoStart)

  • Toggle dans Settings → "Start with Windows" (visible seulement quand Bridge.active)
  • Mécanisme : HKCU\Software\Microsoft\Windows\CurrentVersion\Run, valeur "PMServer" = "<exe-path>" -tray (single dash — --tray ne match pas FindCmdLineSwitch)
  • Per-user, pas d'admin requis. Visible dans Task Manager → Startup tab
  • Toggle "on" = registered AND la valeur pointe vers notre exe courant (un old entry d'un exe déplacé lit comme "off" → user peut re-enable pour rafraîchir le path)
  • CLI flag -tray (FindCmdLineSwitch('tray', True)) dans FormCreate → FBridge.MinimizeToTray via TThread.ForceQueue (defer après la show initiale FMX pour minimiser le flash)

Device-bound prefs (PM.UserPrefs)

Problème résolu : le serveur HTTP bind un port éphémère aléatoire (49152-65535) sur chaque start. localStorage est keyed par origin (scheme+host+port) → reboot = nouvelle origine = localStorage wipé. Pour les prefs qui doivent survivre (rememberedUsername, etc.), on persiste via DPAPI.

  • %LOCALAPPDATA%\PMServer\prefs.bin — DPAPI-encrypted UTF-8 JSON {"key":"value",...}
  • PM.UserPrefs.GetPref(key) / SetPref(key, value) Delphi-side
  • Bridge JS : await Bridge.getPref(key) (Promise) / Bridge.setPref(key, value)
  • Callback : Bridge.onPrefResult(key, value) posé par Delphi via ExecuteJavaScript. Resolvers stockés dans prefResolvers[key] avec timeout 2 s.

LoadAll retourne toujours un TJSONObject valide (jamais nil) — défault = objet vide, remplacé seulement sur parse success.

Quick unlock — interactions

  • Au boot, init() call bridgeQuickUnlockStatus() et synchronise state.quickUnlockEnabled + localStorage depuis la source of truth DPAPI (corrige le décalage localStorage quand le port change).
  • Sur Windows lock / sleep (BridgeSystemLockBridge.onSystemLock) :
    • Si state.quickUnlockEnabledpas de lockVault. Le blob DPAPI gate déjà l'accès via le compte Windows, re-locker est redondant. Toast informatif affiché.
    • Sinon → comportement d'origine (lockVault()).
  • L'auto-lock par inactivité (autoLockTimer) reste indépendant — il ignore le flag QU (opt-in utilisateur explicite via Settings).

Authenticator + TOTP tool

Sidebar Tools expose 2 items MFA :

  • Authenticator (state.view = 'authenticator') : grille de cards (chaque entry avec TOTP secret) — code 6 chiffres en gros + barre countdown. renderAuthenticatorGrid() décrypte tous les secrets upfront puis tick 1 s. authTickTimer clear sur lock / view change. Snap instantané à 100% sur reset de période (sinon CSS transition anime le saut backward → effet "freeze").
  • TOTP generator (openTotpTool()) : modal standalone. Paste base32 ou otpauth:// URI → code live. Bouton "Generate" génère un secret base32 aléatoire 20 bytes (randomBase32Secret()). Rien n'est sauvegardé.

Code TOTP retourne { code, period, secondsLeft } — attention au nom, pas remaining (utiliser t.secondsLeft).

Sidebar sections collapsibles

Sections Folders, Tags, Tools ont chacune un .section-toggle button (chevron + label + badge count) qui toggle .is-collapsed sur la section parent. Body masqué via display: none sous .is-collapsed.

État persisté dans state.sidebarCollapsed = { folders, tags, tools }, synced via SYNCED_SETTING_KEYS (settings_json). Badges (#countFolders, #countTags) restent visibles quand replié.

Hotkey verrouillé (UX)

autofillHandleRequest : si state.locked ou pas de cryptoKey → Bridge.cancelAutofill() + Bridge.focusApp() puis focus le master password input via DOM. Remplace l'ancien no-op silencieux qui laissait l'utilisateur perplexe.

WebView2 / TMS settings — pièges majeurs

TTMSFNCWebBrowser expose des props qui semblent settables au FormCreate mais ne sont effectivement appliquées qu'après l'init async du WebView2 sous-jacent. Set ces props dans WebBrowser.OnInitialized, pas FormCreate :

  • EnableContextMenu := False → kill le menu Edge natif (Inspecter, Importer mots de passe…)
  • EnableShowDebugConsole := False → kill DevTools
  • EnableAcceleratorKeys := FalseNE PAS METTRE : casse aussi Ctrl+C/V/X/Z/A/K dans les inputs

Le blocking F12 / Ctrl+Shift+I/J / Ctrl+U se fait côté JS (keydown preventDefault) en complément.

Custom right-click menu

Le menu natif est désactivé via TMS. On gère un mini-menu JS dédié sur right-click dans les inputs : installCustomContextMenu() → Cut / Copy / Paste / Select All. Paste utilise Bridge.readClipboard() (sans prompt WebView2 contrairement à navigator.clipboard.readText()). stopPropagation sur les click items sinon le slideover se ferme (chip retiré du DOM avant que le doc click handler check .closest('.slideover')).

Browser shortcuts bloqués (keydown JS)

Bloqués via preventDefault :

  • F12, Ctrl+Shift+I/J, Ctrl+U — DevTools / View source
  • Ctrl+J — Downloads overlay (le moins évident, déclenche un popup Edge)
  • Ctrl+H/S/P/T/N/R, F5 — History / Save / Print / New tab+win / Reload
  • Ctrl+Shift+N/W/Delete — Incognito / Close window / Clear data

Conservés : Ctrl+C/V/X/A/Z (édition), Ctrl+F (find in page), Ctrl+0/+/- (zoom accessibility), Ctrl+K (notre command palette), Ctrl+Shift+L/P/A/D (nos hotkeys).

Pagination

State : pageSize (10/25/50/100, default 25, synced via settings_json)

  • currentPage runtime-only. Active sur les 3 view modes (cards/list/ table) si > 10 items. Position top (au-dessus du grid/table).

CSS : .pagination { grid-column: 1 / -1 } pour span full width en cards view (sinon ça occupe une slot de card).

Reset currentPage = 1 sur : search, sort, view (folder/fav/tag), viewMode change, pageSize change. Render via renderPagination(total, totalPages)

  • computePageList(current, total) avec ellipsis intelligente (1 … 4 5 6 … 12).

Flash animation (nouvelle entry)

flashEntry(id) appelée après saveEntry success + duplicateEntry : setTimeout(0)querySelector('.entry-card[data-id],.entry-row[data-id]')scrollIntoView({block:'center'}) + classe is-flash 2.5s (@keyframes entryFlash pulse cyan).

Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" — scroll + pulse trouvent la nouvelle entry dans la grille triée.

Settings sync

Per-user blob JSON dans users.settings_json, exposé via GET/PUT /settings. Synced : theme, autoLock, viewMode, sortBy/sortDir, hibp, hotkeys autofill, etc. Device-only (localStorage seulement) : quickUnlockEnabled (DPAPI lié au compte Windows), autofillEnabled (toggle hotkey Win32), rememberedUsername (auth screen autofill local).

Quick Unlock

DPAPI blob à %LOCALAPPDATA%\PMServer\quickunlock.bin (tied to Windows user, pas à l'emplacement de l'exe — survit au déplacement).

Cold-start restore : tryQuickUnlock ne check pas localStorage comme source of truth (WebView2 user-data folder est relatif à l'exe → move = localStorage perdu). Le fichier DPAPI seul gouverne. Si présent :

  • Décrypte blob → restore key
  • Re-login fresh via /login avec verifier = bytesToHex(rawKey) (le token stocké dans le blob peut être expiré ; on en récupère un nouveau à chaque cold-start)
  • Resync localStorage à '1' pour que Settings affiche "Disable"

Recovery code (refactor 2026-05)

  • 5 uses par code (compteur remaining_uses dans recovery_keys)
  • Décrémenté à chaque redeem. Si tombe à 0 → row deletée
  • Row deletée aussi sur successful change-master-password
  • Workflow attendu : recover → set new master pw (consomme le code) → regenerate un nouveau code

State justRecovered : flag JS posé après redeem, cleared sur

  • successful master pw change (le code est consumed)
  • lockVault (lock = perte de contexte recovery)
  • doLogin success (login normal avec pw = sortie du mode recovery)

En mode recovery, le modal "Change master password" masque le champ "Current master password" ET retire son required (sinon HTML5 form validation bloque silencieusement le submit). Le verifier "current" est dérivé du state.cryptoKey en mémoire (qu'on vient de recover).

Autofill pipeline

Workflow utilisateur (important)

L'utilisateur doit cliquer le champ cible AVANT de presser le hotkey. Le code ne fait plus de "click au centre de la fenêtre" pour deviner le champ — ça détruisait le focus sur les forms qui n'étaient pas centrés (Gitea login, etc.).

  • Ctrl+Shift+L (full) : click sur username field → hotkey → user+Tab+pwd
  • Ctrl+Shift+P (password only) : click sur password field → hotkey → pwd seul

Pipeline Delphi

  1. RegisterHotKey Ctrl+Shift+L/P sur message-only window
  2. WM_HOTKEY → capture GetForegroundWindow + titre, fire callback
  3. JS reçoit Bridge.onAutofillRequest(title, kind) → strip browser suffix (- Google Chrome, etc.) → matching state.entries.site
  4. Single match → Bridge.executeAutofill(user, pwd) direct Multi-match → Bridge.focusApp() + picker modal → user pick → execute Note : closeAutofillPicker(false) quand user pick (skip cancel cmd qui clearrait FAutofillTargetHWND)
  5. Delphi ExecuteAutofill :
    • Si app foreground → SW_MINIMIZE (laisse la place au target)
    • ForceForegroundWindow(target) avec AttachThreadInput + poll jusqu'à GetForegroundWindow == target (max 600ms)
    • WaitForModifierRelease (sinon Ctrl+Shift restent down → toutes les frappes deviennent Ctrl+Shift+X)
    • Sleep(120) settle
    • Pas de click parasite — le user a déjà focusé le bon champ
    • Ctrl+A + Del avant chaque champ (clear contenu existant)
    • SendInput : Ctrl+A+Del → username → 200ms → Tab → 200ms → Ctrl+A+Del → password

Matching titre → entry

  • Strip suffixe browser via BROWSER_SUFFIX_RE (Chrome, Firefox, Edge, Brave, Opera, Vivaldi, Safari, Tor, Arc) avant toute comparaison
  • Score = max sur 3 champs : entry.title (display name, score 1500+), entry.site hostname full (1000+), SLD (500+)
  • Pour SLD courts (x, qq), match avec word boundaries
  • entry.title priorisé → si utilisateur stocke site = "git.example.com" (hostname), mettre aussi title = "Gitea" (brand) couvre le browser title sans ambiguïté

Contraintes connues autofill

  • UIPI : SendInput vers process élevé bloqué. Notepad++ admin, regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
  • Chrome password manager : si Chrome a des credentials sauvegardés pour le domaine, il peut racer avec notre SendInput et écraser notre username pendant le Tab. Workaround : user désactive Chrome PM par site (clic-droit password field → "Never save passwords for this site")
  • Gitea-like forms : tabindex explicite + autocomplete="current-password" → Chrome PM s'active. Désactiver pour ce domaine ou utiliser Ctrl+Shift+P
  • Title-based matching : si l'utilisateur stocke site = "git.ai-agents4you.com" (hostname) mais le titre browser n'a que la marque ("Gitea"), pas de match. Convention : préférer site = "Gitea" (brand) pour les sites brandés. URL hostname-only fonctionne pour les sites dont le titre contient le hostname (rare).

Contraintes / pièges connus

  • UIPI : process non-élevé ne peut pas SendInput vers process élevé (Notepad++ admin, regedit, …). Solution future : manifest uiAccess=true + exe signé EV + installé dans C:\Program Files\.
  • Assets embedded > static dans le dispatch HTTP : modifier JS sans rebuild assets.res n'a aucun effet sur l'exe.
  • WebView2 mange tous les keydown : ne pas utiliser FormKeyDown pour des raccourcis globaux → toujours RegisterHotKey.
  • FMX FormCloseQuery : minimize-to-tray sauf si FQuitting=True (set par le tray menu "Quit" uniquement).
  • SetForegroundWindow Win10/11 : règles strictes anti-stealing → nécessite AttachThreadInput pour cross-process.
  • updated_at est bumpé à chaque PUT entry → sort par défaut = name asc pour que modifier une entry ne change pas sa position.
  • TMS render-time vs DOM-attachment : pattern courant — fonctions qui rendent un widget (soTagsField, soTotpField) appellent un helper (renderSoChips, startTotpTick) qui fait $('#id') sur un élément pas encore dans le DOM. Solution : render inline OU setTimeout(fn, 0) pour deferrer jusqu'au tick suivant.
  • HTML5 required + display:none : un input required caché bloque silencieusement le form submit. Toggle aussi .required = false quand on cache (cf. modal recovery).
  • WebView2 user-data folder est relatif à l'exe : déplacer l'exe = nouveau folder = localStorage/sessionStorage perdu. Pour ce qui doit survivre au move, stocker via Delphi (DPAPI, fichiers user).
  • Port éphémère = nouvelle origin = localStorage wipé chaque launch : cause distincte du point précédent (l'exe ne bouge pas, mais le port change). Mêmes conséquences : pour tout pref qui doit survivre au reboot, passer par PM.UserPrefs via Bridge.getPref/setPref. Le fix port-fixe est possible (port=N dans config.txt) mais on perd l'aléatoire qui complique le scan externe.
  • Modifier les Enable* props TMS en OnInitialized, pas FormCreate.

Conventions code (héritage sessions précédentes)

  • Pas de commentaires multi-lignes pour chaque fix : préférer noms de variables/fonctions descriptifs (OwnFormHwnd, EnsureTargetThreadHasKeyboardFocus, FocusSettleDelayMs)
  • Migrations DB : toujours via AddColumnIfMissing (idempotent)
  • Nouveau setting UI : ajouter à SYNCED_SETTING_KEYS + handler dans loadServerSettings switch case + listener change qui appelle saveServerSettings()
  • Tout new bridge cmd : handler dans UMainForm.HandleBridgeCommand, log via LogLine pour traçabilité

Audit / docs

  • security-issues.md à la racine : audit sécu legacy (api.php)
  • Ce CLAUDE.md = source de vérité pour l'architecture courante

Git

  • Pas de remote configuré (dev local)
  • vault.db, *.db-shm, *.db-wal, delphi-backend/Win32/, *.dcu, assets.res gitignored
  • Messages commit : type(scope): description style conventional