5fc07aed7a
Fourth slice of the app.js split. Moves TOTP (base32Decode, generateTOTP, parseOtpAuthUri) plus the TOTP-secret and custom-field AES-GCM wrappers to js/app.totp.js. Loads before app.js (pure declarations), after app.crypto.js (uses encryptPwd/decryptPwd). Also called by app.import.js and app.sync.js via shared global scope. - Byte-for-byte identical extraction; no duplicate const; syntax OK on all five app parts. - NEW: js/tests/totp.test.js — 13 tests including the 5 RFC 6238 Appendix B reference vectors (generateTOTP reads Date.now(), so each case stubs the sandbox clock to the vector's fixed time), base32 decode edge cases, and parseOtpAuthUri. Extraction AND new coverage in one slice. - Suite: 42 → 55 tests, all green. - Assets regenerated (manifest now embeds all 6 ordered JS files: argon2 → crypto → totp → import → app → sync); also fixes the previous import commit's not-yet-rebuilt manifest. - Delphi build artifacts (*.vrc, *.$manifest) gitignored. app.js: 11936 → 10138 lines (4 modules extracted, ~1800 lines). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
944 lines
48 KiB
Markdown
944 lines
48 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.
|
||
|
||
### 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.totp.js (TOTP RFC 6238 + TOTP/custom-field crypto — extrait §3.1)
|
||
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
|
||
js/app.js (le reste : state, Bridge, api, UI…)
|
||
js/app.sync.js (WebDAV + merge — extrait §3.1, APRÈS app.js car
|
||
effet de bord top-level `Bridge.onWebdavResult = …`)
|
||
```
|
||
|
||
`app.import.js` contient `encryptImportEntry`, appelé aussi par
|
||
`app.sync.js` (`applyRemoteSnapshot`) — OK, référence cross-fichier résolue
|
||
au call-time via le scope global partagé.
|
||
|
||
Note ordre : un module **sans** exécution top-level (que des déclarations,
|
||
comme `app.crypto.js`) peut se charger AVANT `app.js`. Un module **avec** un
|
||
effet de bord top-level qui touche un global d'`app.js` (`Bridge`, `state`…)
|
||
doit se charger APRÈS (`app.sync.js`).
|
||
|
||
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)
|
||
+ Argon2id (vecteur RFC 9106), TOTP (vecteurs RFC 6238), 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` |
|
||
| TOTP (RFC 6238) + TOTP/custom-field crypto — extrait §3.1 | `js/app.totp.js` |
|
||
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
|
||
| Sync frontend (WebDAV, snapshot, merge) — extrait §3.1 | `js/app.sync.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)`
|
||
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/<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 4–12 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 (~13–40 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
|