Files
Password-Manager/CLAUDE.md
T
r-zakarya e23a78dda7 feat: entry templates + tag autocomplete + slideover push + robustness bundle
- Entry templates: new vault_entries.template column drives a typed
  sub-kind ('credit-card', 'ssh-key', 'server', 'recovery-codes'). Card
  + table label off the template, badge reads "credit card" instead of
  "note". Templates seed kind=note (no site/password required), use
  custom_fields with optional dropdown options (brand, month/year,
  protocol). Round-tripped across export/import/duplicate/master-pw
  rotation, preserved by partial PUTs via a HasTemplate flag.
- Custom fields: support per-field `options[]` rendering as <select>
  (card brand, expiry MM/YYYY, SSH/server protocol).
- Tags: existing-tag autocomplete dropdown under the chip input,
  filtered against what's already selected.
- Search history: per-query X for individual delete + 1s debounced
  commit (no Enter required).
- Slideover: clicking outside closes again (drag-selection respected
  via mousedown origin tracker), Esc closes, X closes. App shell is
  pushed left by 420px when the panel is open so the table / pagination
  / sort / search stay visible and interactive.
- Export/import: JSON now round-trips custom_fields, attachments
  (decrypted to base64, re-encrypted under current key on restore),
  icon_b64, and template. CSV warning lists what's not included.
- Auto-backup: same payload shape as user-driven export.
- Notes: import (JSON + CSV) accepts kind=note with empty site,
  preserves title/template/custom_fields. CSV parser detects kind/
  template columns.
- Bulk-import response returns `ids[]` parallel to input so the
  client can map back to new entry IDs (drives attachment restore).
- Move-to-folder bugs fixed: moveEntryToFolder, batchMoveToFolder,
  addTag, batchAddTag were all silently wiping TOTP / custom_fields
  / kind / template via partial PUT. Now re-ship full payload.
- Master-pw rotation: server mints a fresh session token + csrf so
  the very next request after rotation no longer ESessionRejects.
  Client adopts the new pair. Attachments are re-encrypted client-side
  during rotation (GET old → decrypt with old key → encrypt with new
  → PUT). New endpoints: GET /attachments/all, PUT /attachments/:id.
- Duplicate: carries icon_b64 + template + attachments to the copy.
- HandleCreateEntry: accepts icon_b64.
- FireDAC param fix: all blob/icon/custom_fields params use ftMemo +
  .Value assignment so SQLite TEXT no longer truncates to 4000 chars
  (deepseek's 200+ KB favicon was being wiped on lock/unlock).
- HandleSetEntryIcon cap: 262144 → 524288 chars (base64 of a 256 KB
  raw fetch overflows the old cap, fails silently in saveEntryIcon).
- Native save dialog: surfaces server errors instead of swallowing.
- Modals: reauth (export) + backup-password prompt support inline
  error display, retry up to 5 attempts, then hard-stop.
- Keyboard cursor (j/k): bootstraps to current page, auto-paginates
  when the cursor crosses a page boundary, Enter opens slideover.
- Slideover focuses Title on edit-open so j/k → Enter → type Just
  Works.
- TOTP tool: Esc closes the modal.
- App version + launch mode (auto/manual): exposed via bridge,
  surfaced in Settings → Account. Autostart launches suppress the
  first-time tray balloon.
- Passkey button hidden (Delphi backend stubs WebAuthn at 501).
- TEST_PLAN.md captured for regression coverage.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-26 21:20:07 +01:00

606 lines
29 KiB
Markdown

# 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=<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 `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/<host>.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 `<img class="entry-avatar-img">` 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"` = `"<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 (`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=<b64>` → 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