Files
Password-Manager/CLAUDE.md
T
r-zakarya 4ffbd63893 fix(sync): bump updated_at on set-icon + folder-delete reassignment
Both UPDATEs mutated a synced column without touching updated_at, so the
change rode in the sync snapshot but other devices skipped it (last-write-
wins saw "not newer"). Now both SET updated_at = datetime('now') (UTC).

- POST /entries/{id}/icon (PM.Handler.Entries)
- folder delete → entries reassigned to 'All' (PM.Handler.Folders)

accessed_at stays exempt (read timestamp, not synced); bulk "clear all
icons" stays exempt (device-local favicon cache purge). Invariant documented
in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 15:55:23 +01:00

927 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
### Découpage frontend (§3.1, en cours)
`app.js` (~12k lignes) est **progressivement scindé** en fichiers classic-script
chargés **dans l'ordre** via des `<script>` séparés (PAS de bundler, PAS d'ES
modules) : les classic scripts partagent **un seul environnement lexical
global** dans le navigateur, donc les `const`/fonctions d'un fichier sont
visibles des suivants exactement comme dans le monofichier. Ordre actuel :
```
js/argon2.js (IIFE, globalThis.NobleArgon2)
js/app.crypto.js (KDF, verifier, encrypt/decrypt — extrait §3.1)
js/app.js (le reste)
```
Règles pour extraire un nouveau module :
- Il doit se charger **avant** ses consommateurs et **ne jamais redéclarer**
un `const` d'un autre fichier (un `const` dupliqué entre deux classic
scripts jette « already declared »).
- Les corps de fonction peuvent référencer des globals d'un fichier chargé
après (`state` vit dans `app.js`) car résolus au **call-time**, jamais au
load-time. Ne pas mettre de code exécuté au top-level qui touche un global
pas encore déclaré.
- Ajouter le fichier au whitelist `BuildAssets.ps1` **dans l'ordre de chargement**
+ au `<script>` d'index.html + à `APP_PARTS` du harness de test.
- `node:vm` ne partage PAS les `const` top-level entre `runInContext` séparés
(contrairement au navigateur) → le harness **concatène** `APP_PARTS` en un
seul script. `argon2.js` reste séparé (IIFE autonome).
`BuildAssets.ps1` lance `node --check` sur chaque `.js` embarqué **avant**
de générer `assets.res` : une erreur de syntaxe JS avorte le build (au
lieu d'embarquer un bundle mort qui ne se révèle qu'après un rebuild
Delphi complet). Node optionnel — absent = warning, on continue. Toujours
`node --check js/app.js` après un gros edit JS.
Ensuite (même gate, node requis) il lance la **suite de tests frontend**
(`node --test js/tests/**/*.test.js`, ~1.7 s, zéro dépendance) : un
invariant crypto/merge cassé avorte le build comme une erreur de syntaxe.
`PM_SKIP_TESTS=1` pour bypasser en itération rapide. Lancer manuellement
via `npm test`. Voir [js/tests/README.md](js/tests/README.md) — le harness
concatène `APP_PARTS` (`app.crypto.js` + `app.js`) et les charge dans un
`node:vm` avec les globals navigateur stubbés (`argon2.js` chargé à part,
c'est un IIFE), puis expose les internals via un épilogue d'export. Couvre : round-trip crypto + dérivation verifier (legacy vs -v2),
parsing CSV d'import, et l'arbitrage merge/tombstone de sync
(`applyRemoteSnapshot`, seule `api()` est stubbée).
## 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 principal (en cours de découpage §3.1) | `js/app.js` |
| Crypto frontend (KDF, verifier, AES-GCM) — extrait §3.1 | `js/app.crypto.js` |
| Argon2id vendé (bundle `@noble/hashes`, IIFE) | `js/argon2.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).
## Sync (WebDAV, auto-merge)
Multi-device sync via a user-hosted WebDAV server (Nextcloud, ownCloud,
Apache mod_dav). Auto-merge strategy: last-write-wins per entry on
`updated_at`, tombstones propagate hard-deletes. No conflict UI — solo
personal use rarely produces simultaneous edits.
Foundations :
- `vault_entries.uuid` (TEXT, indexed) — stable cross-device identity.
Migration backfills existing rows via `hex(randomblob)` → RFC 4122 v4.
- `entry_tombstones (user_id, uuid, deleted_at)` — UNIQUE(user_id, uuid),
written on hard-delete (`DELETE permanent=1`, trash empty, auto-purge).
- GET `/entries` returns uuid ; POST/bulk-import accept it (mint fresh
if absent) ; PUT keeps it immutable.
- GET `/entries/tombstones` lists local tombstones.
- POST `/entries/tombstones {uuids:[...]}` adds tombstones + hard-deletes
any local rows matching those uuids (idempotent via INSERT OR IGNORE).
Transport ([UMainForm.pas](delphi-backend/UMainForm.pas)) :
- `cmd://webdav/get|put|test?reqId=&url=&user=&pwd=[&data=]` → async via
`THTTPClient` (WinHTTP under the hood, no OpenSSL DLLs required).
Basic auth, 10s connect / 30s response timeout. Callback
`Bridge.onWebdavResult(reqId, status, payload)`.
Settings : all config in DPAPI prefs (`syncEnabled`, `syncUrl`,
`syncUser`, `syncPwd`, `syncEncPwd`, `syncPreBackup`, `syncLast`).
**`syncEncPwd` MUST be the same on every device** — it's the secret
that encrypts the WebDAV-stored snapshot. User sets it once per
device, never transmitted.
`runSyncNow()` flow :
1. (Optional) Write `vault-presync-yyyymmdd-HHMMSS.json` to the
auto-backup folder if enabled.
2. `webdav/get` → 404 = first sync, treat as empty remote.
3. Decrypt with `syncEncPwd` (reuses `encryptExportPayload` container).
4. POST remote tombstones → server hard-deletes local matches, BUT
with resurrection arbitration : a remote tombstone (`{uuid, deleted_at}`)
is skipped if a local entry with that uuid has `updated_at > deleted_at`
(restored/edited after the delete → resurrection wins, no silent
re-kill). Ties + unparseable timestamps favour KEEP.
5. Folders : add missing ones additively (don't touch existing).
6. Entries : for each remote uuid → not in local = POST keeping uuid +
restore attachments ; both sides have it = compare `updated_at`,
PUT if remote newer.
7. `loadEntries()` + `buildSyncSnapshot()` for the post-merge state.
8. `webdav/put` push the merged snapshot, with `If-Match: <etag>` where
the etag was captured from the step-2 pull (optimistic concurrency).
A **412** means another device changed the file between our pull and
push → re-run the whole pull→merge→push (`runSyncNow(_attempt+1)`,
bounded to 3) so their changes are folded in instead of clobbered.
Servers without ETag support return an empty etag → no If-Match sent
→ falls back to last-write-wins (same as before).
9. Toast `pulled X new · Y updated · Z deleted · pushed N entries`.
Sensitive actions (export, change master pw, recovery code…) still
require master pw via `askReauth` — sync never substitutes.
**Tombstone purge on (re)create** : `POST /entries` and
`POST /entries/bulk-import` both DELETE any `entry_tombstones` row
matching the inserted uuid (same transaction). Without this, restoring
a backup whose entries were previously hard-deleted would leave the
tombstone in place — the local `buildSyncSnapshot` would then re-push
it and the next pull would kill the just-restored entries. Purge-on-
insert + the resurrection arbitration (step 4) together make
restore-then-sync actually stick. A live `vault_entries` row can never
coexist with its tombstone (hard-delete removes the row), so `PUT`
needs no purge.
## Auth-hash schemes (`users.hash_algo`)
Four markers, all zero-knowledge (server never sees the master pw) :
- `pbkdf2` (**LEGACY**) : stored hash = raw `PBKDF2(pw,salt,iters)` hex.
Those bytes ARE the AES vault key → a stolen `vault.db` = the key.
Auto-upgraded to `pbkdf2-sha256` at next login via `/migrate-kdf`.
- `pbkdf2-sha256` (**older default**) : stored = `SHA256(verifier)`
where the client's transmitted `verifier` is still the key hex. Safe
at rest, but the `/login` body carries the key.
- `pbkdf2-sha256-v2` (**DECOUPLED**) : the client sends
`verifier = SHA256(keyHex + "pmserver/auth-verifier/v2")` instead of
`keyHex`. The transmitted verifier is now a one-way function of the
key → intercepting `/login` no longer hands over the AES key. Stored =
`SHA256(verifier)` (same server wrap as `pbkdf2-sha256`; only the algo
LABEL differs, telling the client which verifier formula to use).
- `argon2id-v2` (**current default**) : same decoupled verifier as above,
but the client derives the key with **Argon2id** (memory-hard) instead
of PBKDF2. KDF params (`m`/`t`/`p`) live in `users.argon2_m/t/p` and are
echoed by `/login/challenge` so the client knows how to derive. OWASP
baseline `m=19456 KiB, t=2, p=1` (`ARGON2_DEFAULT_PARAMS`, ~0.65 s/unlock).
**The server NEVER runs Argon2** — it only stores/echoes the params and
SHA256-wraps the 64-hex verifier exactly like any `-v2` scheme, so no new
verify branch. Argon2 is a **pure-JS** vendored bundle (`js/argon2.js`,
`@noble/hashes`) — WASM would need CSP `wasm-unsafe-eval`, which we don't
grant. Verified against the RFC 9106 test vector in the unit suite.
**The AES key (`cryptoKey`) is ALWAYS the raw KDF output** (Argon2id *or*
`hex(PBKDF2)`) regardless of the verifier scheme — the verifier string is a
separate layer, so entries stay decryptable. Switching the *verifier* scheme
(pbkdf2→v2) never re-encrypts, but switching the *KDF* (PBKDF2→Argon2id)
changes the derived key → the master-pw-change flow **re-encrypts the whole
vault** (its natural migration point).
Client verifier formula = `verifierFromKeyHex(keyHex, algo)`;
`isDecoupledVerifierAlgo(algo)` = `algo.endsWith('-v2')` (both `-v2` markers
decouple). The KDF branch is `deriveKeyBytes(pwd, salt, algo, iters,
argonParams)` — Argon2id for `argon2id-*`, else PBKDF2.
Adoption is **new-registration + master-pw-change only** — existing accounts
stay on their algo until they rotate (no forced login-path migration; the
PBKDF2 100k→600k `/migrate-kdf` upgrade is unrelated and stays PBKDF2).
`state.hashAlgo` + `state.argon2Params` are cached from `/login/challenge`
and also carried in the quick-unlock / PIN cold-start blobs (so a
cold-started session can still derive-from-password for reauth/rotation).
Cold-start verifier paths use the raw stored key via `verifierFromKeyHex`
**no** KDF params needed there. Unknown/empty `hashAlgo` → key hex → correct
for every pre-decoupling account. Since all Argon2 accounts currently use
`ARGON2_DEFAULT_PARAMS`, a `null → default` params fallback is also correct
today (the blob persistence is future-proofing for tunable params).
### Argon2 server plumbing touch-points (keep in sync)
`POST /register` + `POST /change-master-password` accept `hashAlgo:
'argon2id-v2'` + `argon2:{m,t,p}` (bounds-checked via `ReadArgon2Params`,
persisted to `argon2_m/t/p`). `/login/challenge` returns them via
`AppendArgon2Params`. DB columns default 0 (= PBKDF2). Verify path
(`VerifierToStoredHash`/`CheckVerifier`) is KDF-agnostic — untouched.
## PIN unlock
Optional shortcut unlock with a 412 digit PIN, complementary to Quick
Unlock. Three modes (`state.unlockMode`, synced via `settings_json`) :
- `pw` — master password only (legacy, default)
- `pin` — PIN unlocks the vault on this device
- `both` — master password first, then PIN verified before access
Storage : `PM.PinUnlock.pas` writes a DPAPI blob to
`%LOCALAPPDATA%\PMServer\pin-unlock.bin`. Bridge cmds: `pin/store`,
`pin/get`, `pin/clear`, `pin/status` (mirror Quick Unlock exactly,
separate file so both features coexist).
Crypto wrap: `wrapKey = PBKDF2(pin, salt, 100k iter)` — 100k instead of
600k because PIN entropy is low (~1340 bits), more iterations mostly
slow down honest users. Vault key bytes are AES-GCM(wrapKey, key_raw)
inside the blob. Successful PIN → unwrap → import as `state.cryptoKey`
→ fresh `/login` with `verifier = hex(rawKey)` (same pattern as Quick
Unlock cold-start).
Anti-brute-force : each failed PIN attempt increments `attempts` in the
blob and rewrites it via `pin/store`. Past 5 fails → `pin/clear` → user
falls back to master pw. Successful unlock resets the counter to 0.
`both` mode : `doLogin` saves the typed PIN on `window._pinAfterMaster`,
runs the regular master-pw flow, then `enterApp` calls
`verifyPinAfterMasterUnlock(pin)` BEFORE flipping to the app shell. PIN
mismatch → `lockVault()` + "Wrong PIN" hint. Quick Unlock cold-start
bypasses the PIN check (the device is already trusted).
Sensitive actions (`askReauth` paths: export, change master pw,
recovery code, etc.) ALWAYS require master pw — PIN never substitutes.
Master pw rotation **clears the PIN blob** (stored wrapped key + server
verifier drift). Unlike Quick Unlock — which stores the raw key directly
(DPAPI-wrapped, no user secret) and is therefore **re-wrapped in place**
with the new key/salt/iters/algo during rotation so it survives — the PIN
blob is wrapped by `PBKDF2(pin)` and can't be re-wrapped without the PIN,
so it's wiped and the user re-sets it from Settings. `doChangeMasterPassword`
does the two via `window.location.href` (quickunlock/store then pin/clear)
with a `setTimeout(0)` between them so the back-to-back navigations don't
coalesce and drop the re-wrap.
## 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).
## Transport gros fichiers JS→Delphi (chunké — PIÈGE MAJEUR)
Les bridge cmds passent par `window.location.href = 'cmd://...'`. WebView2
**cape l'URL à ~2 Mo** : un gros payload base64 dans l'URL (attachment,
export, backup, snapshot de sync) fait dépasser la limite → la navigation
**vide le document → écran noir**. Symptôme classique quand le vault
grossit.
Solution : `_streamChunks(reqId, b64, onProgress)` (js/app.js) découpe le
base64 en morceaux de 1 Mo envoyés **séquentiellement** (chaque chunk
attend son ack `Bridge.onFileChunkAck` avant le suivant — sinon les
`location.href` se coalescent et seul le dernier passe). Côté Delphi,
`file/chunk` accumule dans `FFileSaveChunks[reqId]` (un `TStringBuilder`),
puis un `*-commit` reconstruit et agit :
- `file/save-commit``SaveDecodedFile` (dialogue Save As)
- `file/write-commit``WriteDecodedFile` (write silencieux vers un path)
- `webdav/put-commit` → PUT WebDAV (avec If-Match)
`Bridge.saveFile` / `writeFile` / `_webdavCall(put)` basculent
automatiquement sur ce chemin si `b64.length > 1 Mo`, sinon single-shot.
**Bug historique corrigé** : chaque chunk réutilise le même `reqId` ; un
`setTimeout(30s)` d'un chunk déjà résolu finissait par supprimer le
resolver du chunk courant → hang permanent. `onFileChunkAck` **clear
maintenant le timer** (`clearTimeout(r.timer)`) — ne PAS régresser ça.
Tout nouveau transfert de gros payload JS→Delphi DOIT passer par
`_streamChunks`, jamais mettre le data brut dans l'URL.
## Busy overlay (ops longues)
`showBusy(text)` / `updateBusy(text)` / `hideBusy()` (js/app.js) → overlay
plein écran avec spinner + texte, `z-index 200`. Utilisé pour export,
download attachment, backup manuel, rotation master pw. Pattern : `showBusy`
+ `await new Promise(r => setTimeout(r,0))` (laisse peindre l'overlay avant
de bloquer le thread) puis `finally hideBusy()`. Le spinner utilise
`--border` (pas `--bg-elev-3` qui n'était pas défini avant — piège CSS :
un `var()` non défini SANS fallback = déclaration invalide, élément
invisible).
## Auto-VACUUM SQLite
SQLite ne réduit **jamais** le fichier sur `DELETE` (pages marquées libres,
fichier gardé). Supprimer de gros attachments laisse `vault.db` gonflé.
`DB.CompactIfBloated` (PM.Database) fait `VACUUM` **seulement si** > 20 %
des pages sont libres ET > ~2 Mo récupérables → appelé au démarrage +
après chaque `DELETE /attachments/{id}`. Un petit vault sain ne paie rien.
## Divers UI / data (session récente)
- **Profil / avatar** : colonne `users.avatar_b64 TEXT` (data URI, non
chiffré, cosmétique). Endpoints `GET/POST /avatar`. `state.avatarDataUri`
chargé à `enterApp`. Pas de photo → initiale sur couleur déterministe
(`avatarColorFor(username)`). Upload downscale 128px JPEG via `FileReader`
`data:` URI (PAS `blob:` — la CSP `img-src 'self' data:` bloque blob).
Inclus dans l'export JSON (`avatar_b64`), restauré à l'import si absent.
- **Quick search — modes fill** (Ctrl+Shift+Q) : Enter/clic-gauche = full
(user + Tab + pwd), Shift+Enter/clic-droit = username seul, Ctrl+Enter/
Ctrl+clic = password seul. `Bridge.executeAutofill(u, p, hide, 'user')`
`field=user``ExecuteAutofill(..., AUsernameOnly=True)`. En copy-mode
(tray/palette, pas de HWND cible) : clic = copy pwd, clic-droit = copy user.
Fix clipboard : `MinimizeToTray(AClearClipboard=False)` via `keepclip=1`
pour ne pas effacer le pwd juste copié quand on re-minimise.
- **Custom fields combobox éditable** : les champs avec `options` (card
brand, expiry…) rendent un `<input>` + menu custom `.so-combo` (flèche =
toutes les options, input = frappe libre). PAS un `<datalist>` natif
(il filtre au texte tapé → n'affiche que ce qui matche, déroutant).
- **Settings search** : `#settingsSearch` filtre les `.setting-row` par
texte ; match sur le label de section garde toute la section. Esc dans
la search avec query = clear ; Esc vide = ferme Settings.
- **Password reveal** : `promptDialog` a un œil en mode `password` → tous
les prompts (export, import, backup pwd, recovery, sync pwd) le montrent.
## Clean shutdown (WAL)
`WM_QUERYENDSESSION` / `WM_ENDSESSION` captés dans la fenêtre message-only
(`PM.Bridge`) → `FShutdownPending`. `FormCloseQuery` bypasse le
minimize-to-tray sur arrêt/redémarrage/logoff Windows → `FServer.Free`
checkpoint le WAL proprement (sinon Windows force-kill après timeout →
résidus `-shm`/`-wal`). Kill brutal (Task Manager) laisse les résidus, mais
SQLite récupère au prochain open.
## WebView2 nav race (écran noir cold-start)
Le timer de navigation (1.5 s) ne consomme `FPendingURL` que si
`FBrowserInitialized` (set dans `OnInitialized`) — sinon il ré-arme (borné
10 essais). Sans ça, un tick timer avant l'init du moteur Navigate() dans
le vide ET vide `FPendingURL` → écran noir permanent sur cold-start lent.
## 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.
- **Toute mutation `vault_entries` qui doit se synchroniser DOIT bumper
`updated_at`** : la sync est last-write-wins sur `updated_at`, donc un
`UPDATE` qui ne le touche pas voyage bien dans le snapshot mais est ignoré
par les autres devices (« pas plus récent »). Corrigé pour set-icône
(`POST /entries/{id}/icon`) et réassignation-sur-delete-folder
(`PM.Handler.Folders`). `accessed_at` est **exempté** exprès (timestamp de
lecture, non synced). Le bulk « clear all icons » ne bump pas non plus —
assumé device-local (purge de cache favicon).
- **Timestamps = UTC partout** : tout `created_at`/`updated_at`/`deleted_at`
écrit côté Delphi passe par `NowUTC`/`NowUTCStr` (`PM.Database`) — JAMAIS
`FormatDateTime(..., Now)` (heure locale). SQLite `CURRENT_TIMESTAMP` /
`datetime('now')` sont déjà UTC. Mélanger les deux décalait l'arbitrage
sync (résurrection tombstone) de l'offset UTC de la machine, même en solo.
Côté JS, générer les timestamps via `toISOString()` (UTC) uniquement.
- **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