370d35f69a
Differe volontairement : l'APK ne part chez personne aujourd'hui. Le jour venu, l'instantane publie par le poste portera la licence et le telephone exigera d'y en trouver une — il le lit deja, donc aucun nouveau format, aucune empreinte Android, aucune emission de plus. Les trois raisons de ne pas refaire un second dispositif sont notees pour ne pas les re-deriver : Java attend du DER la ou CNG accepte r||s brut, ANDROID_ID est remis a zero par une reinitialisation d'usine (donc des clients legitimes refuses), et un APK se repaquette en dix minutes. Et l'APK n'aura PAS a verifier la signature, seulement la presence : payer le DER et le JNI pour durcir un controle que le repaquetage retire serait le mauvais echange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1404 lines
78 KiB
Markdown
1404 lines
78 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.
|
||
|
||
### Blocs embarqués brouillés
|
||
|
||
Depuis 2026-09-05, `BuildAssets.ps1` **brouille** chaque fichier avant de le
|
||
donner à `brcc32` (dans `assets/obf/`, gitignoré ; les sources en clair ne sont
|
||
jamais touchées) et `PM.EmbeddedAssets.DeobfuscateAsset` le débrouille au moment
|
||
de servir.
|
||
|
||
```
|
||
bloc i = SHA256( secret(32) || nom de ressource UTF-8 || i en 4 octets LE )
|
||
sortie = XOR( magie 'PMA1' + contenu du fichier )
|
||
```
|
||
|
||
- **C'est de l'opacité, pas du secret.** La clé est dans l'exe : qui tient le
|
||
binaire retrouve le texte. Ce que ça supprime, c'est l'onglet *Image* de
|
||
Process Explorer et n'importe quel `strings` — le frontend n'est plus lisible
|
||
en survolant le fichier. L'onglet *Memory*, un débogueur ou la réponse HTTP le
|
||
rendent toujours : WebView2 doit recevoir du JavaScript en clair pour
|
||
l'exécuter. D'où **XOR plutôt qu'AES** : à sécurité rigoureusement égale
|
||
(nulle, contre qui a l'exe), dix lignes au lieu d'un appel CNG, et surtout
|
||
aucune force affichée qu'on n'a pas.
|
||
- Le flux dépend du **nom de la ressource** : tous les fichiers commencent par
|
||
les mêmes lignes de commentaire, un flux commun rendrait ces préfixes
|
||
identiques d'un bloc à l'autre.
|
||
- **La magie `PMA1` préfixe le clair.** Sans elle, une clé qui a dérivé entre le
|
||
script et l'exe servirait du binaire au navigateur : écran blanc, aucune
|
||
trace. Avec elle, `TryServeEmbedded` refuse. **Symptôme à reconnaître :
|
||
`/index.html` en 404 juste après avoir touché `AssetObfSecret` d'un seul
|
||
côté** — les deux constantes (`$ObfSecret` / `AssetObfSecret`) doivent rester
|
||
identiques.
|
||
- **Coût mesuré : 2,45 ms** pour les 820 Ko embarqués (AES chronométré comme
|
||
borne haute ; le XOR est plus rapide), une seule fois au chargement de la
|
||
page. À comparer aux ~650 ms d'Argon2id au déverrouillage.
|
||
- **Les tests frontend ne sont pas concernés** : ils lisent les `.js` sur le
|
||
disque, jamais le `.res`.
|
||
- Vérifié en comparant les flux des deux côtés bloc par bloc (ils doivent être
|
||
identiques au bit près) puis en refaisant l'aller-retour sur `app.js`,
|
||
`index.html` et `style.css`.
|
||
|
||
### 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.favicon.js (favicon fetch/cache + faviconHost — extrait §3.1)
|
||
js/app.import.js (export container + import CSV/JSON — extrait §3.1)
|
||
js/app.backup.js (auto-backup planifié — extrait §3.1)
|
||
js/app.health.js (vault health dashboard — extrait §3.1)
|
||
js/app.overlays.js (quick search + cheatsheet + password history — extrait §3.1)
|
||
js/app.attachments.js (crypto blob + UI attachments — extrait §3.1)
|
||
js/app.autofill.js (hotkey combos + matching titre→entry + picker — extrait §3.1)
|
||
js/app.js (le reste : state, Bridge, api, UI…)
|
||
js/app.unlock.js (Quick Unlock + PIN + recovery code — extrait §3.1, APRÈS
|
||
app.js car effets de bord top-level `Bridge.onPinResult`,
|
||
`Bridge.onQuickUnlockResult`…)
|
||
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` |
|
||
| Favicons (fetch/cache, `faviconHost`) — extrait §3.1 | `js/app.favicon.js` |
|
||
| Import/export frontend (CSV/JSON parse, export container) — extrait §3.1 | `js/app.import.js` |
|
||
| Auto-backup frontend (planifié, chiffré) — extrait §3.1 | `js/app.backup.js` |
|
||
| Vault health dashboard — extrait §3.1 | `js/app.health.js` |
|
||
| Quick search + cheatsheet + history modal — extrait §3.1 | `js/app.overlays.js` |
|
||
| Attachments chiffrés (crypto blob + UI) — extrait §3.1 | `js/app.attachments.js` |
|
||
| Autofill (combos Win32, matching titre→entry, picker) — extrait §3.1 | `js/app.autofill.js` |
|
||
| Quick Unlock + PIN + recovery code (charge APRÈS app.js) — extrait §3.1 | `js/app.unlock.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+T` : autofill du **code 2FA** (cliquer le champ 2FA d'abord).
|
||
ID hotkey 47 / `akTotp`. Ne matche que les entrées avec `totp_secret` (filtre
|
||
appliqué **avant** le scoring → le picker ne propose jamais un candidat sans
|
||
TOTP). JS génère le code et l'envoie **dans le slot password avec un username
|
||
vide** → réutilise le chemin password-only de `ExecuteAutofill`, aucun code
|
||
natif en plus.
|
||
- `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`).
|
||
|
||
## Fill bar (remplir à la souris)
|
||
|
||
Menu du tray → **Fill bar** : une barrette de trois
|
||
boutons *Full* / *Password* / *2FA* qui flotte au-dessus de tout et fait
|
||
exactement ce que font `Ctrl+Shift+L` / `Ctrl+Shift+P` / `Ctrl+Shift+T`. Deux
|
||
boutons de plus, *Open* et `X` : quand la barrette est allumée, elle est souvent
|
||
la seule chose de l'application à l'écran — revenir au coffre ou s'en
|
||
débarrasser ne doit pas obliger à retrouver l'icône du tray.
|
||
|
||
**`WS_EX_NOACTIVATE` est toute l'astuce.** Une fenêtre ordinaire prendrait le
|
||
premier plan au clic — le champ que l'utilisateur vient de cliquer perdrait son
|
||
curseur, et `GetForegroundWindow` nous désignerait nous-mêmes comme cible.
|
||
NOACTIVATE veut dire que le clic ne déplace jamais le premier plan : le
|
||
navigateur reste actif, son champ reste focalisé, et la capture voit exactement
|
||
ce que voit le chemin hotkey.
|
||
|
||
Détails :
|
||
- Fenêtre créée par un **second** `AllocateHWnd` (`FLazyWnd`), séparée de la
|
||
fenêtre message-only : `WS_EX_TOPMOST | WS_EX_TOOLWINDOW | WS_EX_NOACTIVATE`,
|
||
deux enfants `BUTTON` (IDs 101/102) → `WM_COMMAND` → `FireAutofillRequest`.
|
||
- `FireAutofillRequest(AKind)` est le corps **extrait** du handler `WM_HOTKEY` :
|
||
hotkey et barrette partagent capture de la cible, matching de titre et picker.
|
||
Un seul chemin, deux gestes.
|
||
- `WM_NCHITTEST` → `HTCAPTION` : toute la barrette est sa propre barre de titre
|
||
(elle finit par se poser sur le champ qu'elle remplit, il faut pouvoir la
|
||
pousser). `WM_ERASEBKGND` peint le fond — la classe `TPUtilWindow`
|
||
d'`AllocateHWnd` n'a **pas** de brosse de fond.
|
||
- **Le nom interne est resté `lazy`** — `FLazyWnd`, `LAZY_*`, les prefs
|
||
`lazyOn`/`lazyPos`/`lazyAlphaPct`. Seul le libellé a changé : renommer les
|
||
clés de prefs perdrait les réglages déjà posés sur la machine.
|
||
- **L'état allumé/éteint est retenu** en pref DPAPI (`lazyOn`) et repris au
|
||
démarrage par `RestoreLazyBar`, appelé depuis `FormCreate` **après**
|
||
`ApplyTitleBarTheme` et après le câblage de `OnTrayRestore` : la barrette se
|
||
peint elle-même (il lui faut le thème) et son bouton *Open* passe par cet
|
||
événement.
|
||
- Position : coin bas-droit de la *work area* au premier lancement, puis
|
||
**retenue** en pref DPAPI (`lazyPos`, `'x,y'`), écrite sur `WM_EXITSIZEMOVE`
|
||
(une écriture par déplacement, pas une par pixel parcouru). Elle n'est reprise
|
||
que si `MonitorFromRect(…, MONITOR_DEFAULTTONULL)` la trouve encore sur un
|
||
écran : la barrette n'a **pas d'entrée dans la barre des tâches**, donc une
|
||
position héritée d'un écran débranché la rendrait définitivement
|
||
irrécupérable.
|
||
- Le `X` **poste** `WM_LAZY_CLOSE` à la fenêtre message-only au lieu de détruire
|
||
sur place : libérer une fenêtre depuis son propre gestionnaire libérerait, en
|
||
plein envoi, l'instance qui lui sert de window proc.
|
||
- **Transparence au repos** (`WS_EX_LAYERED` + `SetLayeredWindowAttributes`) :
|
||
pleine opacité quand la souris est dessus, `FLazyAlphaPct` sinon. Réglée par
|
||
**clic droit sur la barrette** → 100/75/50/25 %, persistée en pref DPAPI
|
||
(`lazyAlphaPct`, défaut 60) — **pas** dans `settings_json`, qui exige une
|
||
session alors que la barrette peut être allumée coffre fermé. Le menu
|
||
appartient à la fenêtre message-only : `TrackPopupMenu` réclame un
|
||
`SetForegroundWindow` pour se refermer seul, et le faire sur la barrette
|
||
détruirait le `NOACTIVATE` qui la fait fonctionner. Deux portes vers ce menu —
|
||
`WM_NCRBUTTONUP` (le fond est non-client à cause de `HTCAPTION`) et
|
||
`WM_CONTEXTMENU` (remonté par les boutons).
|
||
- **`TOPMOST` n'est pas un rang, c'est une bande** : la barre des tâches y est
|
||
aussi, et cliquer son fond vide la remonte en tête de cette bande — une
|
||
barrette posée dessus passe **derrière** et semble avoir disparu. Le même
|
||
battement de 200 ms la repose (`SetWindowPos … SWP_NOMOVE or SWP_NOSIZE`) ;
|
||
sans mouvement ni taille à changer, l'appel ne repeint rien. **Ce battement
|
||
s'arrête pendant le menu d'opacité** (`KillTimer` / `SetTimer` autour de
|
||
`TrackPopupMenu`, qui est modal) : le menu déroulant est lui aussi dans la
|
||
bande `TOPMOST`, et se faisait donc recouvrir par la barrette qui venait de
|
||
l'ouvrir.
|
||
- **Traîner depuis un bouton** : les boutons sont subclassés (`GWLP_WNDPROC`,
|
||
une seule proc d'origine — même classe). Sur `WM_LBUTTONDOWN`, **`DragDetect`**
|
||
tranche entre clic et glissement en respectant le seuil réglé par
|
||
l'utilisateur. Glissement → `ReleaseCapture` + `WM_SYSCOMMAND, SC_MOVE or
|
||
HTCAPTION`. Clic → on **envoie `BN_CLICKED` à la place du bouton** : DragDetect
|
||
a avalé le `WM_LBUTTONUP`, lui rendre la main le laisserait enfoncé pour
|
||
toujours. Le clic droit n'est pas intercepté, donc le menu d'opacité tient.
|
||
- Le survol est **sondé** par un `SetTimer` de 200 ms, pas par `TrackMouseEvent` :
|
||
avec quatre fenêtres enfants, passer de la barrette à un bouton envoie déjà un
|
||
`WM_MOUSELEAVE` au parent. Comparer le curseur au rectangle ignore ce piège.
|
||
- Transparente **n'est pas traversante** : la barrette avale toujours les clics
|
||
sur toute sa surface (`WS_EX_TRANSPARENT` contredirait ses boutons).
|
||
- **Thème** : boutons `BS_OWNERDRAW` + `WM_DRAWITEM`, palette recopiée des
|
||
variables de `css/style.css` en COLORREF (`$00BBGGRR` — octets **inversés**
|
||
par rapport à l'hexa CSS). `SetPreferredAppMode(ForceDark)` ne themise que ce
|
||
que le système dessine lui-même (menus, ascenseurs, infobulles) : un bouton
|
||
Win32 standard reste gris clair sur fond sombre. `ApplyTitleBarTheme` mémorise
|
||
le mode dans `FDarkTheme` et `RedrawWindow(… RDW_ALLCHILDREN)` la barrette.
|
||
Le cadre est peint dans `WM_ERASEBKGND` plutôt que par `WS_BORDER`, qui se
|
||
sert dans les couleurs système.
|
||
|
||
## Licences (`PM.Licence`)
|
||
|
||
Verification **hors ligne** d'une licence signee ECDSA P-256. Une licence est
|
||
une chaine `charge-utile.signature`, chacune en base64url, fabriquee par
|
||
`tools/licence.ps1` avec la cle privee qui reste chez l'editeur. Seule la cle
|
||
**publique** est dans l'exe : elle ne permet que de verifier.
|
||
|
||
```
|
||
tools\licence.ps1 -Init # une seule fois : cree la paire,
|
||
# imprime la constante Delphi
|
||
tools\licence.ps1 -For "a@b.com" -Name "Ami" # une licence
|
||
```
|
||
|
||
- **`LicencePublicKeyBlob`** (72 octets) est un `BCRYPT_ECCPUBLIC_BLOB` :
|
||
magic `ECS1` + longueur(32) + X(32) + Y(32). **Livre a zero** — tant qu'on
|
||
n'y a pas colle la sortie de `-Init`, aucune licence n'est valide. Ne jamais
|
||
regenerer la paire ensuite : toutes les licences deja emises tomberaient.
|
||
- **La cle privee s'ecrit dans `%USERPROFILE%\Documents\pmserver-licence-private.key`,
|
||
hors du depot par construction.** Elle a d'abord vecu dans `tools\`, ou elle
|
||
n'attendait qu'un menage pour disparaitre — ce qui est arrive le 2026-09-05,
|
||
sans copie, obligeant a regenerer la paire et a reemettre. Le `.gitignore` la
|
||
protegeait du depot ; rien ne la protegeait de son proprietaire. Sa vraie
|
||
place est un coffre ; le fichier n'est qu'un intermediaire, et `-KeyFile`
|
||
permet de la relire d'ou on l'a mise.
|
||
- **Perdre la privee et perdre la publique n'ont pas le meme effet** : sans la
|
||
privee on ne peut plus emettre AUCUNE licence nouvelle (les existantes
|
||
continuent — l'exe n'a que la publique) ; la reparation est de regenerer,
|
||
reconstruire et reemettre chacune. Indolore avec une licence en circulation,
|
||
ingerable avec vingt.
|
||
- **Aucun ASN.1 nulle part.** .NET sur Windows EST CNG : la signature produite
|
||
par le script (`r||s` brut, 64 octets) est deja celle qu'attend
|
||
`BCryptVerifySignature`, et les coordonnees exportees se rangent telles
|
||
quelles dans le blob. Le decodage DER, moitie penible de l'exercice
|
||
d'habitude, n'existe pas ici.
|
||
- `BCryptVerifySignature` attend **l'empreinte**, pas le message : SHA-256 est
|
||
calcule avant l'appel (`THashSHA2.Create` + `Update`, pas `GetHashBytes` qui
|
||
n'a pas de surcharge `TBytes`).
|
||
- **La signature d'abord, la lecture ensuite** : `SplitAndVerify` n'analyse
|
||
jamais une charge utile dont il ne sait pas encore qu'elle vient de nous.
|
||
- `IsLicensed` **REVERIFIE** la licence rangee au lieu de lire un drapeau : un
|
||
drapeau se retourne, une signature se refabrique seulement avec la cle privee.
|
||
Resultat mis en cache en memoire, invalide par `ActivateLicence`/`ClearLicence`.
|
||
- Rangee en pref DPAPI (`licenceKey`) — liee au compte Windows, survit au
|
||
changement de port comme le reste.
|
||
- **Pas d'expiration dans la charge utile**, volontairement : une date qu'on
|
||
fait respecter avec l'horloge de la machine du client ne prouve rien. Le
|
||
champ `d` n'est qu'une date d'emission.
|
||
|
||
### Liage au poste (`PM.MachineId`)
|
||
|
||
Le champ `m` de la charge utile porte l'empreinte du poste ; `MachineMatches`
|
||
la compare **apres** la verification de signature — une empreinte n'a de sens
|
||
que dans une charge utile dont on sait deja qu'elle vient de nous.
|
||
|
||
- Empreinte = `SHA256('pmserver/machine/v1|' + UUID carte mere + '|' + serie du
|
||
volume systeme)`, tronquee a 16 hexa, affichee en groupes de quatre
|
||
(`CD6F-F1B3-25C4-0111`). **Hachee, pas concatenee** : elle se recopie dans un
|
||
courriel, et l'UUID brut de la carte mere est un identifiant materiel unique
|
||
qui n'a pas a circuler en clair ni a finir dans nos journaux.
|
||
- UUID lu par WMI (`Win32_ComputerSystemProduct`, COM via `Winapi.ActiveX` pour
|
||
`IEnumVariant`). **`BoardUUID` appelle `CoInitializeEx` lui-meme** : sans ca
|
||
l'empreinte dependait de qui avait demarre avant — apres l'init de WebView2
|
||
elle lisait le vrai UUID, avant elle echouait et retombait sur `MachineGuid`,
|
||
soit DEUX empreintes pour une seule machine et une licence liee a l'une que
|
||
l'autre refuse. Un identifiant qui depend de l'ordre de demarrage n'est pas un
|
||
identifiant. **Et on ne fait PAS de `CoUninitialize` symetrique** : il parait
|
||
propre et se paie d'une violation d'acces, les mandataires WMI et le cache de
|
||
type-info de ComObj se liberant plus tard, dans un appartement ferme. **Un UUID nul ou tout a FF est rejete** : beaucoup de VM et
|
||
d'images clonees l'annoncent, et s'en contenter ferait tomber des postes
|
||
differents sur la MEME empreinte — une licence emise pour l'un ouvrirait chez
|
||
l'autre. Repli sur `MachineGuid` (HKLM\...\Cryptography), distinct par
|
||
installation. Les deux sources absentes → `'unavailable'`, qui ne correspond a
|
||
aucune licence : mieux qu'une chaine vide, identique partout.
|
||
- Calculee une fois puis retenue (l'appel WMI coute quelques centaines de ms).
|
||
- **`m` absent = licence REFUSEE.** Les licences flottantes ont existe le temps
|
||
d'une seance et l'une d'elles est sortie ; les refuser en bloc est la **seule
|
||
facon de l'annuler**. Un schema hors ligne n'a pas de revocation — rien ne
|
||
peut aller dire a un exe deja installe qu'une cle ne vaut plus — donc la seule
|
||
prise qui reste est de cesser d'accepter toute une classe de cles. Le cout
|
||
assume : plus de licence de site, une cle par poste. `-Floating` a ete retire
|
||
du script, qui ne peut donc plus fabriquer une cle que l'exe rejetterait.
|
||
- Un poste dont l'empreinte vaut `'unavailable'` (ni WMI ni MachineGuid) ne peut
|
||
plus etre licencie : le script n'accepte que 16 hexa. C'est le bon sens de
|
||
l'erreur — mieux vaut un poste qu'on ne peut pas licencier qu'une empreinte
|
||
degeneree qui ouvrirait partout.
|
||
- **Consequence de flux** : on ne peut plus emettre une licence avant que le
|
||
client ait installe. L'ecran d'activation affiche l'Installation ID, il
|
||
l'envoie, on emet. Changer la carte mere ou le disque systeme casse la
|
||
licence — la reparation est une reemission.
|
||
- Ce qui casse l'empreinte : carte mere remplacee, disque systeme remplace,
|
||
Windows reinstalle sur un autre volume, machine clonee.
|
||
- `SelfTestLicence` a l'`initialization` (comme `SelfTestCrypto`) rejoue un
|
||
vecteur fige signe par une **paire de test** dont la privee a ete jetee :
|
||
licence valable acceptee, un octet retourne dans la charge utile refuse.
|
||
`VerifyAgainstKey` prend la cle publique en parametre exactement pour que
|
||
l'auto-test n'ait pas besoin d'un second jeu de constantes de production.
|
||
|
||
### Ecran d'activation au demarrage
|
||
|
||
`ensureLicence()` (js/app.js) est appele **en tete d'`init()`**, avant tout le
|
||
reste : un ecran d'authentification qui s'affiche puis se fait recouvrir
|
||
donnerait l'impression que le coffre etait accessible. Il ne rend la main que
|
||
sur une licence acceptee — pas de bouton « plus tard ».
|
||
|
||
- Balisage `#licenceScreen` dans index.html, **place apres `#authScreen`** et
|
||
reprenant ses classes (`.auth-screen`, `.auth-card`, `.auth-hint`,
|
||
`.auth-form`) : zero style nouveau, sauf `#licenceScreen { z-index: 300 }`
|
||
pour passer au-dessus du bandeau (100) et du voile d'attente (200).
|
||
- Pont : `cmd://licence/status` et `cmd://licence/activate?key=`, reponse par
|
||
`Bridge.onLicenceResult(ok, porteur)`. La cle est du base64url plus un point,
|
||
donc rien a encoder de special sur le trajet.
|
||
- **Le statut est demande deux fois** avant de montrer l'ecran : un demarrage a
|
||
froid lent peut manquer la fenetre de 3 s, et le silence se lirait comme un
|
||
refus — presenter l'activation a quelqu'un qui a deja paye coute plus cher
|
||
qu'un second appel.
|
||
- Une cle refusee ne dit **pas pourquoi** : signature invalide, recopie fautive
|
||
et charge utile trafiquee se ressemblent, et detailler n'aiderait que celui
|
||
qui essaie d'en fabriquer une. Le journal note le verdict, **jamais la cle**.
|
||
- Hors du navigateur hote (`Bridge.active` faux : dev en navigateur, harnais de
|
||
test) il n'y a pas de licence a verifier — la porte est ouverte.
|
||
- **Settings → Licence** affiche porteur, courriel, date d'emission et
|
||
Installation ID. Rempli par `applyLicenceInfo(st)` **une fois au demarrage**
|
||
(les deux chemins : deja licencie, et activation reussie) plutot que branche
|
||
sur le rendu des reglages — rien n'y change pendant une session, et le panneau
|
||
est deja dans le DOM, cache. Section masquee hors navigateur hote : une suite
|
||
de tirets ferait croire a une activation perdue.
|
||
- Les champs sont **echappes avant interpolation** dans `ExecuteJavaScript`,
|
||
bien qu'ils viennent d'une charge utile signee : signee ne veut pas dire
|
||
inoffensive, c'est l'editeur qui les a tapes au moment d'emettre.
|
||
- Piege Delphi rencontre : **une routine imbriquee ne se declare pas au milieu
|
||
d'un corps de procedure** (seulement dans la section de declaration). En
|
||
glisser une casse l'inference des methodes anonymes de tout ce qui suit —
|
||
cinq `TThread.Queue` sans rapport se sont mis a ne plus compiler.
|
||
|
||
**Scenario d'essai** : [LICENCE-TESTS.md](LICENCE-TESTS.md) — cinq series, dont
|
||
une (D) dediee a la stabilite de l'empreinte, qui est le defaut deja rencontre.
|
||
La remise a zero passe par le bouton **Deactivate (debug)** au bas de
|
||
Settings -> Licence, revele seulement quand `config.txt` existe a cote de l'exe
|
||
(`cmd://licence/clear`, inerte sans lui). Il donne aussi son unique appelant a
|
||
`PM.Licence.ClearLicence`, et evite d'avoir a supprimer `prefs.bin` — qui
|
||
desactive mais emporte le mode de deverrouillage, le nom retenu et la sync.
|
||
|
||
### Cote Android : differe, et le jour venu on reutilisera la licence desktop
|
||
|
||
**Rien n'est fait, et c'est deliberе** — l'APK ne part chez personne aujourd'hui,
|
||
on l'installe soi-meme par adb. Un dispositif de licences sans destinataire est
|
||
du code qui vieillit sans jamais s'executer chez quelqu'un.
|
||
|
||
Le jour ou l'APK partira chez des clients, **ne pas refaire un second dispositif**.
|
||
Trois raisons, a garder pour ne pas les re-deriver :
|
||
|
||
- **L'elegance du cote Windows ne se transporte pas.** Elle venait de ce que
|
||
.NET sur Windows EST CNG : la signature `r||s` brute est deja celle qu'attend
|
||
`BCryptVerifySignature`. **Java attend du DER** — il faudrait emettre deux
|
||
formats ou convertir, plus un pont JNI vers `java.security.Signature`. La
|
||
moitie penible qu'on avait evitee reviendrait.
|
||
- **Pas d'empreinte materielle solide sur Android.** Plus d'IMEI ni de numero de
|
||
serie sans permission privilegiee ; il reste `ANDROID_ID`, **remis a zero par
|
||
une reinitialisation d'usine** — donc des clients legitimes qui perdent leur
|
||
licence sans rien avoir fait. C'est le defaut couteux, celui qui refuse
|
||
quelqu'un qui a paye.
|
||
- **Un APK se contourne bien plus facilement qu'un exe.** `repack-apk.ps1` en
|
||
est la demonstration : decompiler, retirer un test, resigner est de la routine
|
||
outillee.
|
||
|
||
**La forme retenue** : l'instantane publie par le poste porte la licence, et
|
||
l'APK exige d'y en trouver une. Le telephone lit deja cet instantane — aucun
|
||
nouveau format d'emission, aucune empreinte Android, aucune licence
|
||
supplementaire a fabriquer.
|
||
|
||
**Et l'APK n'a PAS a verifier la signature** : il constate la presence, rien de
|
||
plus. Payer le DER et le JNI pour durcir un controle que le repaquetage retire
|
||
en dix minutes serait le mauvais echange. Le but est de tenir le mobile attache
|
||
a un poste paye, pas de resister a une attaque — l'APK ne peut pas resister, et
|
||
faire semblant couterait plus que de l'admettre.
|
||
|
||
**ORDRE OBLIGATOIRE.** Tant que `LicencePublicKeyBlob` est a zero, aucune
|
||
licence n'est valide et l'ecran d'activation est **infranchissable** — l'exe
|
||
construit ne s'ouvre plus. Poser la cle publique AVANT de deployer :
|
||
`-Init` → coller les 72 octets → reconstruire → `-For` pour s'emettre une
|
||
licence a soi.
|
||
|
||
**Ce que ca arrete** : sans la cle privee, personne ne fabrique de licence
|
||
valable. **Ce que ca n'arrete pas** : rapiecer l'exe pour sauter l'appel — vrai
|
||
de tout schema hors ligne. La seule parade reelle serait de deporter une
|
||
fonction sur un serveur, ce qui contredit le « 100 % hors ligne ».
|
||
|
||
## Debug mode
|
||
|
||
Fichier `config.txt` à côté de l'exe (tous champs optionnels) :
|
||
```
|
||
port=8765 # 0 ou absent = port aléatoire ephemeral (49152-65535)
|
||
# occupé = jusqu'à 3 essais, replis en éphémère
|
||
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é. Tiré de
|
||
`PM.Crypto.RandomBytes` (BCryptGenRandom), **pas** de `Random` : `Randomize`
|
||
n'est appelé nulle part, donc `Random` rendait la même suite à chaque
|
||
lancement — le port « aléatoire » était en réalité le même tous les jours, ce
|
||
qui vidait de son sens l'aléatoire censé compliquer un balayage.
|
||
- **Port occupé → jusqu'à 3 essais** (`BindAttempts`). Le premier honore le port
|
||
demandé ; les suivants prennent un éphémère. Se lier EST le test : un sondage
|
||
préalable serait plus faible (un service qui écoute sans répondre passerait
|
||
inaperçu) et sujet à une course (le port peut être pris entre le sondage et le
|
||
lien). Chaque déplacement est journalisé — un serveur qui change de port en
|
||
silence est pire qu'un serveur qui refuse de démarrer.
|
||
- 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é). Body `{icon_b64, custom:bool}`.
|
||
- **Provenance `icon_custom INTEGER`** (0 = favicon auto-fetchée = cache
|
||
jetable, 1 = upload utilisateur). **Invariant : une icône custom n'est
|
||
JAMAIS touchée par une action automatique.** `saveEntryIcon(id, uri,
|
||
isCustom)` pose le flag (upload→1, fetch/remove→0). `backfillFavicons`
|
||
skippe `icon_custom` (même sur "Re-fetch all"), `DELETE /entries/icons/all`
|
||
filtre `COALESCE(icon_custom,0)=0`. Seul l'utilisateur change/retire une
|
||
custom, par entrée (boutons Upload/Remove du slideover). `GET /entries`
|
||
echoe `icon_custom`. **Propagé** de bout en bout : export manuel,
|
||
auto-backup, snapshot de sync l'écrivent ; `encryptImportEntry` le porte
|
||
dans le body ; les INSERT **create + bulk-import** le lisent
|
||
(`GetValue<Boolean>('icon_custom', False)` → 0 par défaut si absent, ex.
|
||
CSV étranger). Round-trip export→import et sync préservent donc la
|
||
provenance. Une icône vide (`icon_b64=''`) force toujours `icon_custom=0`.
|
||
- Bulk : `DELETE /entries/icons/all` purge le cache auto (bouton "Clear
|
||
cache") — préserve les uploads custom
|
||
- 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`,
|
||
**`username_enc/iv`, `site_enc/iv`, `title_enc/iv`, `tags_enc/iv`**
|
||
(métadonnées-at-rest, cf. plus bas), plus le champ-icône `icon_b64` et les
|
||
méta non chiffrées (`folder, 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.
|
||
|
||
**Métadonnées chiffrées (§1.3)** : `username`, `site`, `title`, `tags` sont
|
||
chiffrés au repos (colonnes `<f>_enc/<f>_iv`, AES-GCM sous la clé du vault
|
||
comme `encrypted_password`). **Les colonnes en clair `username`/`site`/`title`/
|
||
`tags` ont été DROP** après migration complète (both comptes) → GET/POST/PUT/
|
||
bulk ne les référencent plus (helpers `AddNullableField`/`BindNullable`) ;
|
||
`decryptEntryMeta` défaut `e.<f>=''` pour une ligne sans `<f>_enc`.
|
||
`loadEntries`/`loadTrash` déchiffrent → `e.<f>` en mémoire, donc
|
||
**recherche/tri/render/autofill-match/favicon marchent inchangés** (tout est
|
||
déjà côté client). Liste des champs : `ENCRYPTED_META_FIELDS =
|
||
['username','site','title','tags','template']`. Choke-point d'écriture :
|
||
`withEncryptedMeta(obj)` (chiffre chaque champ, blanchit le clair) — enveloppe
|
||
**chaque** body POST/PUT `/entries` (saveEntry, soSave, duplicateEntry,
|
||
moveEntryToFolder, addTagToEntry, batchMove/AddTag, encryptImportEntry,
|
||
migration). Lecture : `decryptEntryMeta(list)`. Anciennes lignes migrées au
|
||
unlock par `migrateMetadataAtRest` (PUT re-ship, y compris corbeille, bump
|
||
`updated_at` assumé une fois). Rotation master-pw re-chiffre les champs sous
|
||
la nouvelle clé (JS loop `ENCRYPTED_META_FIELDS` + UPDATE serveur).
|
||
**Cas `template` (ajouté 2026-07-11, subtil)** : contrairement aux 4 autres,
|
||
`template` est **omis** par les re-ships partiels (addTag, moveEntryToFolder,
|
||
batch) — historiquement le serveur le préservait via `LHasTemplate` (clé
|
||
absente). Pour garder ce contrat, `withEncryptedMeta` **skippe un champ absent
|
||
de l'objet** (`if (!(f in obj)) continue`) au lieu de synthétiser un `_enc` vide
|
||
qui ferait wiper le template stocké. La colonne clair `template` a été **DROP**
|
||
comme les 4 autres (migration vérifiée 0 restant sur les 2 comptes, refs code
|
||
retirées le 2026-07-11). Piège appris au passage : retirer aussi le
|
||
`AddColumnIfMissing` de toute colonne droppée, sinon elle **revient vide** au
|
||
prochain démarrage (c'était le cas de `tags`/`title`).
|
||
**Le `?q=` serveur est neutralisé** (site+username chiffrés → LIKE inutile ;
|
||
le front cherche côté client). **La validation « Site required » serveur est
|
||
retirée** (site='' quand chiffré) — le client la fait. `folder` reste en clair
|
||
(requête serveur de réassignation sur delete-folder). Reste en clair :
|
||
`folder`, `kind`, métadonnées d'attachments, nombre de lignes, timestamps.
|
||
|
||
**La corbeille voyage dans les sauvegardes, pas dans la sync** (2026-08-03) :
|
||
`buildBackupEntry` (js/app.import.js) est le point unique qui met en forme une
|
||
entrée pour un conteneur — partagé par l'export manuel **et** l'auto-backup, qui
|
||
en portaient deux copies. Les deux exportent `state.entries` **+**
|
||
`state.trashed`, avec `deleted` / `deleted_at` ; `deleted_at` est préservé pour
|
||
que l'auto-purge reprenne où elle en était au lieu de repartir à zéro (d'où son
|
||
ajout à l'écho de `GET /entries`). `buildSyncSnapshot` **n'utilise pas** ce
|
||
helper et n'envoie que le vivant : la corbeille est device-local, et republier
|
||
des lignes supprimées les dupliquerait (cf. le bug F8 où 30 entrées mises à la
|
||
corbeille revenaient vivantes, deux lignes pour un même uuid).
|
||
|
||
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).
|
||
Ces trois-là sont **device-only mais pas localStorage-only** : chacun est
|
||
miroité en pref DPAPI, sinon le changement de port les ramène à leur défaut
|
||
à chaque lancement (`autofillEnabled` revenait ainsi sur ON une fois
|
||
désactivé). Lu dans `enterApp` juste avant `autofillPushHotkeys`, la copie
|
||
DPAPI faisant autorité.
|
||
|
||
**Piège : tout réglage lu AVANT le login doit être en pref DPAPI.**
|
||
`settings_json` demande une session, et `localStorage` est vidé à chaque
|
||
lancement (port éphémère = nouvelle origine). Un réglage qui ne vit que
|
||
dans ces deux-là est systématiquement à sa valeur par défaut sur l'écran
|
||
d'auth. C'est ce qui faisait retomber un coffre en mode PIN sur le mot de
|
||
passe maître à chaque démarrage : `unlockMode` est donc **miroité en pref
|
||
DPAPI** (écrit au changement + dans `loadServerSettings`, relu par
|
||
`showAuth`), la copie DPAPI faisant autorité au boot. Même raison pour
|
||
`rememberedUsername`.
|
||
|
||
## 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`, dans `settings_json` **et** en pref
|
||
DPAPI) :
|
||
|
||
- `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, l'auto-backup ET le snapshot de sync
|
||
(`avatar_b64`). Restauré **additivement** (seulement si le device n'a pas
|
||
déjà d'avatar) à l'import et dans `applyRemoteSnapshot` — pas de timestamp
|
||
par avatar, donc on n'écrase jamais une photo locale (changer un avatar
|
||
existant ne se propage pas ; cosmétique, assumé).
|
||
- **Quick search — modes fill** (Ctrl+Shift+Q) : Enter/clic-gauche =
|
||
password seul, Shift+Enter/clic-droit = full (user + Tab + pwd), Ctrl+Enter/
|
||
Ctrl+clic = username seul. `Bridge.executeAutofill(u, p, hide, 'user')`
|
||
→ `field=user` → `ExecuteAutofill(..., AUsernameOnly=True)`. En copy-mode
|
||
(tray/palette, pas de HWND cible) le même `mode` pilote la copie : clic/Enter
|
||
= copy pwd, Ctrl+clic/Ctrl+Enter = copy user (`full` n'a pas de sens en copie
|
||
→ retombe sur pwd).
|
||
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).
|
||
- **Champ Notes multiligne** (`field.multiline`) : bouton *Add note* du
|
||
panneau d'édition → champ `textarea` **secret par défaut**, masqué via
|
||
`-webkit-text-security` (un `textarea` n'a pas de `type=password`).
|
||
Hauteur dérivée du contenu (`autoSizeTextarea`, plafond 320 px) plutôt
|
||
que mémorisée — la rangée est reconstruite à chaque ouverture et
|
||
`localStorage` ne survit pas au changement de port.
|
||
**Pas de colonne `notes_enc` dédiée** : le corps d'une entrée *note* EST
|
||
`encrypted_password`, donc un vrai champ notes aurait exigé une nouvelle
|
||
colonne chiffrée + les 7 points de passage ci-dessus ; `custom_fields`
|
||
est déjà porté par rotation / duplicate / export-import / sync.
|
||
**Piège** : les champs personnalisés sont reconstruits **clé par clé** à
|
||
l'ouverture (normalisation) et au Save, et chacun jette ce qu'il ne
|
||
nomme pas → toute nouvelle clé (`multiline`, comme `options` avant elle)
|
||
doit être portée dans les **deux**, sinon elle disparaît silencieusement.
|
||
- **Rangée de champ personnalisé** : `libellé | valeur (œil dedans) | ⋮`.
|
||
L'œil n'existe que sur un champ secret (sa présence EST l'indicateur) ;
|
||
*Copy value*, *Store as secret* / *Make public* et *Remove field* sont
|
||
dans le menu `.entry-kebab-menu` réutilisé des cartes (la fermeture au
|
||
clic extérieur est déjà câblée). Basculer le flag secret se fait **en
|
||
place**, jamais via `rerender()` : reconstruire la liste fait flasher le
|
||
panneau et perd le focus + la hauteur auto du multiligne.
|
||
- **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). **`favorite` et `pinned`
|
||
(toggles `POST /entries/{id}/favorite|pin`) sont aussi exemptés exprès** :
|
||
absents de `buildSyncSnapshot`, donc **device-local par design** (décidé
|
||
2026-07-11) — comme l'avatar. En revanche ils **round-trippent** par
|
||
export/import + auto-backup (restaurer une sauvegarde rend la liste telle
|
||
quelle) : container → `parseEntriesFromJSON` → `encryptImportEntry` →
|
||
INSERT create + bulk-import (`GetValue<Boolean>(…, False)` → 0 si absent,
|
||
ex. CSV étranger). **C'est l'omission dans le snapshot de sync qui les garde
|
||
device-local** — `encryptImportEntry` fait un passthrough générique, donc
|
||
les remettre dans `buildSyncSnapshot` suffirait à les propager (+ bumper
|
||
`updated_at` sur le toggle + appliquer au merge). Piège historique : le
|
||
snapshot écrivait `favorite` mais **rien ne le relisait** (ni parser, ni
|
||
body, ni INSERT) → l'export semblait le porter alors que l'import le
|
||
perdait, et `pinned` n'était nulle part.
|
||
- **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`,
|
||
et les artefacts BuildAssets **générés** `assets.res`/`assets.rc`/`assets.inc`
|
||
gitignored (régénérés avant chaque build Delphi, puis linkés dans l'exe —
|
||
intermédiaires jetables, ne pas tracker)
|
||
- Messages commit : `type(scope): description` style conventional
|