# 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` | | Favicon proxy (DuckDuckGo, async THTTPClient/WinHTTP) | `delphi-backend/Source/PM.Favicon.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) - `autostart/{get,set}?enabled=1|0` (HKCU Run registry, "Start with Windows") - `favicon/fetch?host=X&reqId=Y` (async via anonymous thread, callback `Bridge.onFaviconResult(reqId, host, dataUri)`) - `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). ## Favicons (`PM.Favicon`) Opt-in (`state.faviconsEnabled`, default OFF, synced via `settings_json`). La SEULE feature qui sort sur le réseau côté Delphi (HIBP est côté JS). - Source : `https://icons.duckduckgo.com/ip3/.ico` — proxy DDG, pas de tracking, retourne PNG 16-32 px. **DDG-only par design** : pas de fallback vers `/favicon.ico` direct (qui leakerait DNS à chaque domaine stocké). 2 essais : host complet puis SLD (`chat.qwen.ai` → `qwen.ai`). Pour les sites privés/self-hosted que DDG ne couvre pas, l'user upload une icône custom via `soIconField` dans le slideover - `THTTPClient` (`System.Net.HttpClient`) qui wrappe **WinHTTP** sur Windows → TLS natif via le store de certificats Windows. Pas de DLLs OpenSSL à shipper (Indy aurait silencieusement fail sans `libcrypto-3.dll`/`libssl-3.dll`). 5 s timeout, max 3 redirects, cap 64 KB. Toute erreur → return `''` - Magic-byte sniffing pour le MIME (PNG/JPEG/GIF/SVG/ICO) - Cmd async : `TThread.CreateAnonymousThread` car un GET HTTP bloquerait le main thread jusqu'à 5 s. Callback via `TThread.Queue` → `Bridge.onFaviconResult(reqId, host, dataUri)` - Stockage : colonne `vault_entries.icon_b64 TEXT` (nullable). Endpoint dédié `POST /entries/{id}/icon` pour ne pas forcer un PUT complet (qui re-PUT le password chiffré) - Bulk : `DELETE /entries/icons/all` purge tout (bouton "Clear cache") - Render : `entry-avatar` contient `` si `icon_b64`, sinon fallback aux initials. `onerror` repasse aux initials si la data URI est corrompue - Auto-fetch après save d'entry, backfill via "Fetch missing" / "Re-fetch all" boutons dans Settings ## 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"` = `"" -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 (`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`). ## Vault health dashboard Sidebar Tools → "Vault health" (`state.view = 'health'`). Vue dédiée avec un score 0-100 et 4 cards de catégorie : - **Weak** : `computeStrength(plain) < 50` - **Reused** : groupes d'entries partageant le même plaintext (≥ 2) - **Old** : `updated_at > 365 days` - **Pwned** : depuis `state.hibpResults` (n'apparaît que si HIBP actif) `computeHealthCache()` décrypte chaque entry une fois et stocke dans `healthCache` (module-level let, pas dans state pour ne pas polluer). Invalidé sur lock, save d'entry, et bouton "Recompute". Score : start 100 → -5/weak (cap -40), -10/reused (cap -30), -2/old (cap -20), -15/pwned (cap -50). Bandes : ≥80 Good (vert), ≥50 Fair (cyan), ≥25 At risk (orange), <25 Critical (rouge). Bouton "Fix" par item → `openEntryForFix(id)` = `openSlideover` puis click sur le bouton edit-password (fallback : juste ouvre le slideover). Liste tronquée à 20 items par catégorie + count "+N more". ## 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. ## Entry payload — call sites à toucher ensemble Une `vault_entries` row porte **plusieurs blobs chiffrés indépendants** : `encrypted_password/iv`, `totp_secret/totp_iv`, `custom_fields/custom_fields_iv`, plus le champ-icône `icon_b64` et les méta non chiffrées (`site, title, username, folder, tags, kind, template`). `template` est le sous-type (ex: `credit-card`, `ssh-key`, `server`, `recovery-codes`) qui drive le label de card/table — vide pour login/note génériques. Quand tu ajoutes un nouveau champ (chiffré ou non), il faut **toujours** mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement sur certaines actions : 1. **DB schema** : `PM.Database.CreateSchema` `AddColumnIfMissing(...)` 2. **GET /entries** : `PM.Handler.Entries.HandleGetEntries` — ajouter au `LObj.AddPair(...)` (NULL → `TJSONNull`) 3. **POST + PUT /entries** : `HandleCreateEntry` + `HandleUpdateEntry` — lire du body, binder le param, gérer `Clear` pour NULL 4. **Master pw rotation** : `PM.Handler.Auth.HandleChangeMasterPassword` — UPDATE doit inclure la colonne, sinon la rotation l'écrase à NULL 5. **Master pw rotation JS** : `doChangeMasterPassword` → la boucle `for (const e of state.entries)` re-chiffre chaque blob et push dans `encrypted[]`. Manquer un champ chiffré = donnée perdue. 6. **`duplicateEntry`** (js/app.js) — copier le blob chiffré tel quel (même vault key, pas besoin de re-chiffrer). Pour `icon_b64` : inclure dans le body POST. Pour attachments : boucle séparée après create qui GET source attachments + POST sur le nouveau id. 7. **`moveEntryToFolder`** (js/app.js) — PUT partiel sans ces champs = wipe silencieux (TOTP perdu, custom_fields perdus, et notes rejetées en 400 "Site required" parce que `kind` default à 'login'). Re-ship le payload complet, seul `folder` change. Bonus utile (pas critique) : `soDirtyCheck` doit comparer le nouveau champ, et `openSlideOver` doit le déchiffrer et l'exposer via `soState`. Historiquement on a oublié `kind` dans `duplicateEntry` (bug "Site required" sur duplique-note), et `custom_fields` dans la rotation + duplicate. Cette liste évite de répéter ces erreurs. ## Encrypted attachments Per-entry file storage (PDFs, images of backup codes, etc.) encrypted client-side with the vault key. - **Table** : `entry_attachments` (id, user_id, entry_id, filename, mime, size_bytes, encrypted_blob TEXT base64, iv, created_at). FK cascade on user + entry delete. - **Endpoints** ([PM.Handler.Attachments.pas](delphi-backend/Handlers/PM.Handler.Attachments.pas)) : - `GET /entries/{id}/attachments` → metadata array (no blob) - `POST /entries/{id}/attachments` → full upload, ciphertext capped at ~10 MB base64 (~7.5 MB raw) - `GET /attachments/{id}` → metadata + blob (fetched on Download click) - `DELETE /attachments/{id}` → permanent (no trash) - **Crypto** : `encryptBlobBytes(uint8)` / `decryptBlobBytes(b64, iv)` use the same AES-GCM 256 + `state.cryptoKey` as passwords. Filename, mime, size are stored in cleartext (leaked metadata) so the listing doesn't have to decrypt all rows on slideover-open. - **Cap** : 5 MB raw client-side check, ~10 MB base64 server-side. - **Master pw rotation** : attachments ARE re-encrypted client-side after the entries flip. Loop fetches each blob via `GET /attachments/:id`, decrypts with old key, re-encrypts with new key, PUTs the new blob via `PUT /attachments/:id`. Best-effort: a failure on one attachment shows a warning but doesn't undo the rotation. - **UI** : `soAttachmentsField(entryId)` rendered in slideover (existing entries only, never on new). Upload via hidden file input + paperclip button. Download reuses `Bridge.saveFile` (native Save As dialog). ## Auto-backup (encrypted JSON, silent) Silent periodic encrypted export. Triggered on unlock (5s defer) if `autoBackupInterval` days écoulés depuis `autoBackupLast`. Bouton "Backup now" dans Settings pour trigger manuel. - **State** (DPAPI prefs via `Bridge.getPref/setPref`) — survivent au port-change : `autoBackupEnabled`, `autoBackupDir`, `autoBackupInterval`, `autoBackupKeep`, `autoBackupLast` (ISO), `autoBackupPwd` (prompté une fois à l'enable). - **Pwd** : indépendant du master pw, choisi par l'user au premier toggle. Stocké DPAPI, utilisé silencieusement à chaque run. User le retape pour restaurer via l'import standard. **Pourquoi pas dérivé du cryptoKey** : master pw rotation re-génère cryptoKey → backups antérieurs deviennent inaccessibles. Pwd séparée découple du cycle de vie de la vault key. - **Filename** : `vault-autobackup-yyyymmdd-HHmmss.json` — sort lexical = chronologique pour la rétention. Container = même format que `doExport` user-driven → restore via "Import vault" classique. - **Bridge cmds** ([UMainForm.pas](delphi-backend/UMainForm.pas)) : - `folder/pick` → `SelectDirectory` FMX, callback `Bridge.onFolderPickResult(reqId, path)` - `file/write?path=&data=` → silent write (no dialog) - `file/listMatch?dir=&prefix=` → JSON `[{name,size,mtime}]` - `file/delete?path=` → single delete - **Retention** : après chaque write OK, list dir + sort name desc, delete au-delà de `keep`. Best-effort. ## 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