# 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` | | 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 `WebBrowserBeforeNavigate` → `HandleBridgeCommand`. 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) - `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=` (sur la 1ʳᵉ navigation) → Set-Cookie - OU cookie `pm_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)` où `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 `FOnTrayRestore` → `RestoreFromTray`. `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). ## 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 (`BridgeSystemLock` → `Bridge.onSystemLock`) : - Si `state.quickUnlockEnabled` → **pas 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 := False` ← **NE 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