Files
Password-Manager/CLAUDE.md
T
r-zakarya a3e63ee8f1 fix(autofill): vraies touches vers un visualiseur VNC
VK_PACKET (texte Unicode) n'est pas retransmis par TigerVNC : le Mac
distant recevait un V par caractere. Cible VNC -> touches du clavier via
VkKeyScanEx dans la disposition de la cible, modificateurs compris.
Les autres fenetres gardent l'envoi Unicode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:34:47 +01:00

1786 lines
103 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Password Manager — Architecture éclair
Desktop password vault Windows. Exe Delphi FMX embarquant WebView2 qui
charge un frontend HTML/JS via Indy HTTP loopback (127.0.0.1:8765).
100 % offline, multi-utilisateur, chiffrement client-side (AES-GCM,
clé dérivée PBKDF2-SHA256 600 k iter).
## Stack
- **Backend** : Delphi 12, FMX, TMS FNC WebBrowser (Edge WebView2)
- **HTTP** : Indy `TIdHTTPServer` bindé 127.0.0.1 only
- **DB** : SQLite via FireDAC (`vault.db`, gitignored)
- **Frontend** : vanilla JS + CSS, pas de framework (`js/app.js` monofile ~4000 lignes)
## Build pipeline (CRITIQUE)
Toute modif `index.html` / `js/` / `css/` nécessite :
1. `delphi-backend\assets\BuildAssets.cmd` → régénère `assets.res`
2. Build Delphi (F9 ou MSBuild `PMServer.dproj`)
Sans étape 1, l'exe embarque l'ancienne version des assets — le bug le
plus courant après modif frontend.
### 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`).
**Desactiver un raccourci (2026-10-05)** : clic sur le bouton, puis **Suppr**
(ou Retour arriere) → `key: ''` → vk 0, que Delphi n'enregistre pas ; libelle
*Off*. Les combinaisons sont aussi **retenues en pref DPAPI `hotkeys`** (la
requete `autofill/hotkeys` telle quelle, hors suspension de capture) et
**rejouees au demarrage** a la place des valeurs par defaut : `settings_json`
n'arrive qu'apres la connexion, et un `Ctrl+Shift+A` desactive restait pris
tant que le coffre etait ferme. Piege corrige au passage : l'Esc des reglages
(ecouteur `document` en capture, enregistre avant) fermait Settings au lieu
d'annuler la capture — il ignore Esc tant qu'un `[data-capturing="1"]` existe.
**La barrette remplit meme raccourcis coupes** : `onAutofillRequest` recoit
`fromBar`, l'option « Autofill with global hotkey » ne bloque que les
raccourcis.
## 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.
- **Posée SUR la barre des tâches, elle disparaît quand celle-ci est active**
(clic sur Démarrer, clic sur la barre) et revient au premier clic sur une
autre fenêtre. Mesuré le 2026-09-22 : la barrette reste au bon endroit,
au-dessus de `Shell_TrayWnd` dans l'ordre des fenêtres, et n'est pas masquée
par DWM (`DWMWA_CLOAKED` = 0) — elle n'est simplement plus dessinée. La barre
des tâches active occupe une **bande réservée au shell**, au-dessus de tout
`TOPMOST` ordinaire. Deux contournements essayés, tous deux sans effet :
`NOTOPMOST` puis `TOPMOST` (les appels rendent True), et le même avec
`AttachThreadInput` sur le fil de la barre des tâches. Seul `uiAccess=true`
(exe signé, dans `C:\Program Files\`) y accède. **Assumé** : l'utilisateur a
choisi de garder sa position plutôt que d'être remonté automatiquement
(2026-09-22). Le battement ne redemande donc `TOPMOST` que lorsque le centre
de la barrette appartient à une autre fenêtre — utile contre les autres
fenêtres `TOPMOST`, impuissant contre le shell. **Deux pièges corrigés le
2026-09-28** : les fenêtres de **notre propre processus** ne comptent pas
comme un recouvrement (l'info-bulle d'un bouton se pose par-dessus la
barrette, et la remontée qu'elle déclenchait la refermait — boucle infinie
info-bulle / barrette invisible) ; et la remontée est un simple `TOPMOST`,
**sans détour par `NOTOPMOST`**, qui faisait passer la barrette sous les
fenêtres ordinaires un instant — un flash à chaque battement.
- **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.
### Réglages de la barrette (2026-09-18)
**Settings → onglet Fill bar**, et le **clic droit** sur la barrette pour les
mêmes choix au plus court.
- **Boutons au choix** (`lazyButtons`, liste ordonnée) : Full, Password, 2FA,
**Add** (= `Ctrl+Shift+A`, nouvelle entrée depuis la fenêtre en avant),
**Search** (= `Ctrl+Shift+Q`), Open, **Settings** (ouvre cet onglet). Le `X`
est toujours là. `'none'` = aucun bouton choisi (liste vide ≠ liste par défaut).
Add et Search appellent **les mêmes corps que les raccourcis**, extraits en
`FireNewEntryRequest` / `FireQuickSearchRequest` — une seule capture de la
fenêtre cible, deux gestes. `NOACTIVATE` les rend justes : la fenêtre en avant
au clic est bien celle de l'utilisateur.
- **Icônes ou texte** (`lazyIcons`) : glyphes de la police **Segoe MDL2 Assets**
(Windows 10/11), rien à embarquer, couleur du thème comme un mot. Info-bulles
(`TOOLTIPS_CLASS`, `TTS_ALWAYSTIP` car la barrette n'est jamais active) sur
chaque bouton, dans les deux modes. **La largeur de la barrette se calcule**
(`FLazyW`) : elle dépend des boutons et du mode.
- **Tout en prefs DPAPI**, pas dans `settings_json` : la barrette s'allume coffre
fermé. Pont : `fillbar/get` → `Bridge.onFillBar({on,buttons,icons,alpha})`,
`fillbar/set?on=&buttons=&icons=&alpha=`. Rien de secret dedans.
- **Reconstruire = `ReloadLazyBar`, POSTÉ** (`WM_LAZY_RELOAD`) : le clic droit
vient de la barrette, qu'on ne détruit pas depuis son propre gestionnaire
(même raison que le `X`).
- **L'onglet suit la barrette** : un changement par clic droit ou par le menu du
tray déclenche `OnFillBarChanged` → la page relit `fillbar/get`. Gardé par
`FBrowserInitialized` : `RestoreLazyBar` passe par là au démarrage.
- **Settings depuis la barrette** (bouton ou clic droit → *Fill bar
settings...*) : la fenêtre revient, puis les réglages s'ouvrent **par-dessus**
une entrée en cours d'édition, qui reste intacte dessous (Échap ferme d'abord
les réglages). Coffre verrouillé : message, pas de réglages.
## 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.
**Le reste du travail Android est dans
[android-client/README.md](android-client/README.md)**, tenu a jour le
2026-09-28 — dont le formulaire d'edition de l'entree entiere, le scan du QR
2FA, la verification a l'ouverture au lieu du retelechargement, et deux pieges
de la page (`view` qui defocalisait les champs, `toast` qui etait un `<div>`) ;
et avant cela, le 2026-09-17 : les trois facons d'ouvrir le coffre (verrouillage d'ecran, code
d'application, mot de passe), la copie de secours `vault-recover.bin` qui
survit au schema retire, le scanner de QR (`ThirdParty/ZXing`), le define
`AllowCreateLocal` — creer un coffre sur le telephone est reserve au debogage —
et le correctif du manifeste, dont l'APK **release n'est plus `debuggable`**.
### Deuxieme couche : l'API refuse sans licence (`PM.LicenceGate`)
La premiere couche (l'ecran d'activation) ne consultait qu'un branchement,
`IsLicensed` ; le rapiecer ouvrait tout, l'API ne demandant rien. **Chaque route
de `PM.Router` passe maintenant par `ApiLicenceOk` avant son handler** — sauter
l'ecran ne donne plus acces aux donnees.
- **Aucune fonction de decision partagee avec `PM.Licence`.** Si les deux
couches appelaient `IsLicensed`, un seul rapiecage les ouvrirait toutes les
deux. `PM.LicenceGate` refait sa propre verification de bout en bout —
decodage, SHA-256, appels CNG **redeclares sous d'autres noms**, lecture du
champ `m` — et ne prend a `PM.Licence` que des **donnees** : la cle publique
(`LicencePublicKey`) et le nom de la pref (`LicencePrefKey`).
- **Branchee dans `DispatchRequest`, pas avant.** Seules les routes de l'API
atteignent ce point ; les fichiers de l'ecran d'activation passent par
`TryServeEmbedded`, non garde — sinon l'ecran qui permet de s'activer ne se
chargerait plus. **404**, pas 403, comme le reste du verrouillage.
- **Le refus est le MEME 404 qu'une route inexistante** (`TJSONHelper.SendError(404,
'Not found')`). Un corps vide ne suffisait pas : Indy le remplace par sa page
HTML, distincte du JSON d'une route inconnue — ce qui revelait les routes
existantes (vu a l'essai F de LICENCE-TESTS.md).
- **Exe d'essai qui saute l'ecran** : define `LICENCE_TEST_SKIP_SCREEN`, qui fait
repondre `licence/status` ok. **A la compilation et jamais par un reglage** —
un interrupteur dans `config.txt`, que n'importe qui cree a cote de l'exe,
ferait sauter la 1re couche sans rapiecage. Build dedie, dossiers exe ET
`.dcu` a part (des unites compilees avec ce define ne doivent jamais etre
liees dans un exe normal) ; `;` des defines echappe en `%3B` pour MSBuild :
```
msbuild PMServer.dproj /p:Config=Release /p:Platform=Win32
"/p:DCC_Define=RELEASE%3BLICENCE_TEST_SKIP_SCREEN"
"/p:DCC_ExeOutput=E:\password manager\Win32-TEST-licence"
"/p:DCC_DcuOutput=E:\password manager\dcu\Win32-TEST-licence"
```
Verifier apres build : la chaine `LICENCE TEST BUILD` (UTF-16) ne doit
exister que dans l'exe d'essai.
- Un **oui** est retenu pour le processus (le test est sur le chemin de chaque
requete) ; un **non** jamais, pour qu'une activation faite juste avant soit
vue a la requete suivante.
- **Le prix de l'independance : deux implementations qui doivent dire la meme
chose.** Une divergence ferait passer l'ecran puis tomber chaque requete en
404 — refuser quelqu'un qui a paye. `SelfTestLicenceGate` eprouve la 2e couche
seule sur un vecteur lie a une empreinte fixe (`0123456789abcdef`, paire
jetable dont la privee n'a jamais ete ecrite), **en commencant par le sens qui
accepte**. L'**accord** entre couches ne se teste pas en vecteur — la 1re
compare a l'empreinte reelle du poste — et se prouve en conditions reelles :
au 2026-09-16, sur ce poste avec la vraie licence, `IsLicensed` = TRUE et
`ApiLicenceOk` = TRUE.
- **Ce que ca n'arrete pas** : quelqu'un qui trouve et rapiece les deux. Une
heure devient deux ; la nature d'un schema hors ligne ne change pas. Les
protections du commerce (VMProtect, Themida) iraient plus loin mais
declenchent des **faux positifs antivirus** — ruineux pour un gestionnaire de
mots de passe. Les outils libres ne s'appliquent pas : OLLVM exige un
compilateur LLVM (dcc32 Win32 n'en est pas un), Alcatraz est x64 seulement,
UPX se defait par `upx -d`.
**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é.
**Scanner le QR à l'écran** (bouton QR du champ 2FA de l'éditeur, 2026-09-18) :
`cmd://qr/screen` → la fenêtre passe au tray, `PM.ScreenQr` capture **tout le
bureau virtuel** en mémoire (GDI, octets bruts, jamais sur disque) et le décode
par ZXing.Delphi — le même que l'APK, `android-client\ThirdParty\ZXing` dans le
chemin de recherche du `.dproj`. Pas de sélection de zone : le décodeur trouve
le QR où qu'il soit. **Seul un `otpauth://` repart vers la page**
(`Bridge.onScreenQr(texte, trouvé)`), le reste de l'écran ne quitte pas l'exe.
Le second argument dit qu'**un QR a été lu** même s'il n'est pas un code 2FA :
« This QR code is not a 2FA code » plutôt que « aucun QR » (cas vu : un QR
**dynamique** de générateur en ligne encode un lien de suivi, pas le texte). Format `BGR32`
et non `BGRA32` : l'octet alpha d'une capture GDI vaut 0, lu comme transparent
→ image blanche.
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.
### QR des reglages pour le telephone (`sync/qr`)
Recopier une URL d'API et quarante caracteres de jeton sur un telephone est le
vrai obstacle a l'installation. **Settings -> Sync -> QR for phone** affiche un
QR que l'application Android scanne elle-meme.
- **C'est l'EXE qui fabrique le QR**, pas la page : il lit `syncUrl` et
`syncPwd` dans ses prefs DPAPI et n'envoie a la page que la **matrice**
noir/blanc (`Bridge.onSyncQr(taille, bits)`). Le jeton ne traverse donc pas le
moteur de rendu en clair - meme regle que `prefs/has`.
- **Le mot de passe de synchro n'y est JAMAIS** : un QR se photographie
par-dessus l'epaule, et avec lui il ouvrirait le coffre du depot. Il se tape
une fois sur le telephone. Le QR se masque seul au bout de 60 s.
- **Le contenu est un lien `pmserver://sync?u=&t=`, pas du texte.** L'appareil
photo du telephone envoie tout TEXTE a une recherche web : le jeton serait
parti chez Google a un appui pres. Un schema propre n'a qu'une application
pour l'ouvrir.
- **Le plus court possible** : l'adresse du depot (`https://hote/Owner/Repo`) et
non l'URL d'API - le telephone la rallonge a l'enregistrement -, et seuls
`% & # + espace` sont echappes. `TNetEncoding.URL.Encode` changeait chaque
`:` et `/` en trois caracteres, et le QR devenait trop dense pour etre lu.
- Generateur : **`Source/DelphiZXingQRCode.pas`** (portage ZXing, Apache 2.0,
licence a cote). `BCryptVerifySignature` n'a rien a voir ici : le QR n'est pas
signe, il ne porte que ce que l'utilisateur voit deja a l'ecran.
- **Piege CSS rencontre** : `css/style.css` impose `svg { width: 16px }` a
toutes les icones, ce qui reduisait le QR a la taille d'une icone. La taille
est donc posee **en style direct** sur le `<svg>`.
- **WebDAV (2026-10-02)** : le telephone n'a qu'un champ « jeton », donc pour une
URL qui n'est ni Gitea ni GitHub le QR porte `syncUser:syncPwd` — le
telephone en fait une authentification Basic (voir android-client/README.md).
Le QR reprend l'URL telle quelle : une configuration en `127.0.0.1` designe
le telephone lui-meme une fois scannee, il faut l'IP du poste.
### GitHub, et refus d'un depot public (2026-09-18)
- **GitHub parle la meme API des contenus que Gitea** (contenu base64, `sha`,
meme corps de PUT) : meme chemin, reconnu a `api.github.com/repos/` en plus de
`/api/v1/repos/`. `syncIsGitea` (JS), `LGitea` (UMainForm) et
`TSyncClient.IsGitea` (APK) reconnaissent les deux.
- **`User-Agent: PMServer` partout** : GitHub rend 403 a une requete sans ;
Gitea l'ignore.
- **Un depot PUBLIC est refuse**, Gitea comme GitHub, avant toute lecture ou
ecriture : `GET` du depot (URL des contenus coupee a `/contents/`), champ
`private`. Chaque synchro est un commit, l'historique garde tous les
instantanes chiffres ; public, n'importe qui les attaque hors ligne. Un doute
reseau ne bloque pas (pas de verdict = on laisse la vraie requete parler).
- **Limite GitHub** : au-dela de 1 Mo, l'API des contenus ne rend plus le fichier
(`encoding: none`). Le coffre chiffre fait ~250 Ko au 2026-09-18.
- Telephone : `https://github.com/O/R` <-> URL d'API, comme pour Gitea. Le QR
du PC porte aussi la forme courte `github.com/O/R`.
### Ce que le depot laisse voir, et le reseau (2026-09-26)
- **Nom par defaut `data.json`** (`SYNC_DEFAULT_FILE`, JS et page du
telephone ; `SyncDefaultFile` dans le QR). `vault-sync.json` annoncait un
coffre a qui parcourt la liste des depots. Les configurations existantes
gardent leur nom : c'est l'URL complete qui est enregistree. **Le QR ne
raccourcit que le nom par defaut courant** — un autre nom voyage en URL
complete, sinon le telephone rallongerait vers `data.json` et les deux
appareils ne liraient pas le meme fichier.
- **Messages de commit neutres** : `update from desktop` / `update from phone`.
Le poste ecrivait `sync from <utilisateur du coffre>`, le telephone decrivait
ce qu'il faisait. Le detail reste dans le journal local.
- **8 s pour se connecter, 30 s pour repondre, trois essais sur status 0**
(poste et telephone). Mesure : `api.github.com` rend plusieurs adresses ;
`140.82.121.5` n'est joignable depuis ce reseau ni par l'application ni par un
navigateur, `140.82.121.6` repond en 0,22 s. Un navigateur passe a la
suivante aussitot ; nous attendions le delai entier. Un 404 ou un 409 sont
des reponses et ne relancent rien.
### Idee gardee : « Reinitialiser l'historique » du depot de synchro
**Rien n'est fait, et le code actuel reste tel quel** (decide le 2026-09-17).
Chaque synchro est un commit, donc le depot garde TOUS les instantanes
precedents. Ca ne coute rien en place (Git ne stocke que les differences,
86 commits au 2026-09-17), mais **apres un changement de mot de passe de
synchro les anciens restent dechiffrables avec l'ancien** - c'est la seule
raison de vouloir les couper.
- **L'application ne peut pas le faire par l'API des contenus de Gitea** :
elle sait ajouter un commit, rien d'autre. Supprimer des commits demande
un `git push --force`, donc un client git dans l'exe.
- **Ce qui serait possible par l'API** : `DELETE /repos/…` puis
`POST /user/repos`, puis republier. **Ecarte en automatique** : entre les
deux, le depot ne contient plus rien - une coupure reseau la et la copie
distante est perdue -, et le jeton devrait porter le droit de supprimer un
depot, ce qui en fait une bien plus grosse prise s'il fuit.
- **La forme retenue le jour ou on le fera** : un bouton
« Reinitialiser l'historique » dans Settings -> Sync, avec confirmation,
jamais a chaque synchro.
- **En attendant**, la commande manuelle sur un clone, apres une rotation du
mot de passe de synchro :
```
git checkout --orphan tronque HEAD~10 && git commit -m "historique tronque"
git rebase --onto tronque HEAD~10 main && git push --force origin main
```
Sans danger pour les appareils : Gitea identifie le fichier par le hachage
de son **contenu**, que la reecriture d'historique ne change pas.
**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é
### Exclure une entrée : étiquette `no-autofill` (2026-09-18)
`AUTOFILL_SKIP_TAG` (js/app.autofill.js) retire l'entrée du pool **avant** le
scoring : raccourcis `Ctrl+Shift+L/P/T` et barrette l'ignorent. Une étiquette et
non une colonne : elle est déjà chiffrée, synchronisée, exportée et reprise par
la rotation — un champ de plus aurait demandé les 7 points de passage de
« Entry payload ». La recherche rapide `Ctrl+Shift+Q` garde ces entrées (choix
explicite, pas une correspondance devinée sur un titre).
**Idée gardée (rien n'est fait)** : une case « Ne pas proposer à l'autofill »
dans l'éditeur d'entrée (slideover), qui **ajoute ou retire la même étiquette**
— même stockage, mais plus besoin de connaître son nom exact. À brancher sur
`soDirtyCheck` (la case modifie les tags, donc l'entrée devient modifiée) et
sur la reconstruction des chips d'étiquettes pour que les deux restent
d'accord.
### Contraintes connues autofill
- **VNC (2026-10-14)** : les caracteres partent en `KEYEVENTF_UNICODE`
(`VK_PACKET`), que TigerVNC & co ne savent pas retransmettre - un Mac
recevait un `V` par caractere. Cible dont l'exe contient « vnc »
(`TargetTypesByKeyCodes`) → **vraies touches** (`SendKeyCodeString` :
`VkKeyScanEx` dans la disposition de la cible, + Maj/Ctrl/Alt, AltGr =
Ctrl+Alt) ; un caractere sans touche repart en Unicode. Journal : `typed as
key codes (VNC)` sur la ligne `Autofill waits`. Eprouve sur un mot de passe
simple ; caracteres speciaux dependants de la disposition du Mac distant.
- **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.
**Boutons par ligne** (2026-09-18) : utilisateur, mot de passe ou note, 2FA —
seulement ceux qui existent. Une **note est toujours copiée**, même en Fill :
tapée, chacun de ses retours à la ligne partirait comme un Entrée.
**Interrupteur Copy / Fill** (2026-09-28, `qsFills()`) : visible seulement
quand une fenêtre cible existe (Ctrl+Shift+Q, bouton Search de la barrette).
Depuis le tray, pas de cible — cliquer le tray donne le premier plan à la
barre des tâches — donc copie seule ; suivre « la dernière fenêtre active »
a été écarté : les frappes partiraient au mauvais endroit si l'utilisateur a
changé d'application entre-temps. Choix retenu en pref DPAPI `qsCopy`.
- **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.
- **Precedent / Suivant des entrees ouvertes** (2026-10-10) : fleches ‹ › de
l'en-tete du panneau + **Alt+←/→** (le `preventDefault` empeche aussi la page
de « reculer »). `state.entryHistory = {ids, pos}`, 50 au plus, memoire
seulement, vide par `lockVault`. Alimente par `openSlideOver` (sauf
`opts.fromHistory`) ; la navigation repasse par `openSlideOver`, donc par la
confirmation « modifications non enregistrees » — la position ne bouge
qu'une fois l'entree vraiment ouverte. Entree supprimee sautee ; panneau
ferme, Alt+← rouvre celle qu'on vient de quitter. Logique pure
(`entryHistoryPush` / `entryHistoryTarget`) testee dans
`js/tests/history.test.js`. Raison : un clic par erreur dans une liste
filtree obligeait a refaire la recherche.
- **Notes de commandes** (2026-10-10, modele `commands`) : le corps reste du
texte - `# titre` ouvre un groupe, chaque autre ligne non vide est une
commande (`parseCommandNote`, teste dans `js/tests/commands.test.js`). Aucune
colonne : le texte est deja chiffre, synchronise, exporte et lu par l'APK.
Panneau : case *Command list* (bascule le modele, compte dans `isSoDirty` via
`originalTemplate`) + liste avec **Copier** seulement - la fenetre au premier
plan y serait l'app elle-meme. Recherche rapide : choisir la note ouvre sa
liste (`qsCommands`), la saisie filtre, Entree **tape sans Entree** et
**sans Ctrl+A + Suppr** (`executeAutofill(..., noClear)` : dans un terminal
Ctrl+A ramene en debut de ligne), Ctrl+Entree copie, Esc revient aux entrees.
**Masquage** (partage d'ecran) sur l'oeil existant des notes (`maskNotes`) :
`maskCommand` garde le premier mot et les 3 derniers caracteres
(`ssh ••••••••••••.11`), une fin de 6 caracteres ou moins est masquee en
entier. Affichage seulement : filtre, copie et remplissage voient le vrai
texte. L'oeil re-declenche `input` sur le textarea pour redessiner la liste.
**Lignes continuees** : une ligne finie par `\`, `` ` `` ou `^` se recolle a
la suivante (sans le caractere) - une seule commande, sur UNE ligne, car un
retour a la ligne tape dans un terminal vaut Entree. Ligne vide ou fin du
texte = fin de la commande ; un `#` apres une continuation reste du texte.
Limite : un chemin Windows fini par `\` en fin de ligne passe pour une
continuation.
- **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.
## Page tenue éveillée (autofill lent après le tray, 2026-10-02)
Premier autofill après des heures dans le tray ou une veille : `Autofill: page
answered in 4907 ms` (8 ms coffre tout juste ouvert). Les drapeaux
`--disable-renderer-backgrounding` & co **sont bien appliqués** (vérifié sur la
ligne de commande de `msedgewebview2.exe`) : ce n'est pas Chromium qui gèle la
page, c'est Windows qui laisse s'endormir un processus que personne ne sollicite.
- `FKeepWarmTimer` (UMainForm) : un `ExecuteJavaScript` trivial **toutes les
5 min** (choix de l'utilisateur, pas 30 s).
- `TPMBridge.OnSystemResume` : `PBT_APMRESUMEAUTOMATIC` et `WTS_SESSION_UNLOCK`
réveillent la page **tout de suite** — le minuteur ne tourne pas pendant la
veille. Journal : `Windows resumed / unlocked — waking the page`.
## 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.
- **`F2048 Bad unit format … FMX.TMSFNC*.dcu … Found x64`** : le dossier des
sources `…\tmssoftware\registered\TMS FNC Core\` est dans le chemin de
recherche, et une compilation y depose ses `.dcu` **a cote des `.pas`**. Une
compilation **Win64** (le 2026-09-07 a 11 h 45) en a reecrit 33 en x64, et le
build Win32 buttait dessus — **sans aucun rapport avec le code du projet** :
l'arbre propre echouait a l'identique. Correctif : les deplacer
(sauvegarde dans `…\tmssoftware\_backup-x64-dcu-2026-09-07`) ; le build Win32
retombe sur `Delphi12\Win32\Release\`. Le symptome reviendra a chaque
alternance Win32/Win64 sur ce poste.
- **Z: est quasi plein (0,3 Go au 2026-09-16).** Le build sort sur `E:\password manager\`,
**un dossier par plateforme** — exe ET `.dcu` :
```
msbuild PMServer.dproj /t:Build /p:Config=Release /p:Platform=Win32|Win64
"/p:DCC_ExeOutput=E:\password manager\<Plateforme>"
"/p:DCC_DcuOutput=E:\password manager\dcu\<Plateforme>"
```
Surcharge MSBuild, pas dans le `.dproj` : un chemin propre a une machine n'a
pas sa place dans un fichier versionne. **F9 dans l'IDE sort toujours sur Z:.**
**Build de livraison = `E:\password manager\build-both.cmd`** (hors depot, memes
raisons) : il appelle d'abord `backup-previous-exe.ps1`, qui range les exe
actuels (`Win32/PMServer.exe`, `Win64/PMServer.exe`, rien d'autre - ni
`vault.db` ni `config.txt`) dans `PMServer-previous.zip`, **supprime et recree
a chaque build** : la version d'avant reste toujours recuperable. Echec de la
sauvegarde = pas de build. Assets d'abord si le frontend a change.
**La sortie Android suit le meme chemin** : `Z:\password-manager\Android64` est
une **jonction** vers `E:\password manager\Android64`, posee le 2026-09-17 quand
`Z:` est tombe a 40 Ko libres et que `aapt` echouait sans dire pourquoi.
**Des `.dcu` separes par plateforme sont ce qui empeche la pollution TMS**
ci-dessus : verifie au premier build 32+64, aucun `.dcu` depose a cote des
sources TMS. L'exe de `E:\password manager\` cree **son propre `vault.db`** a
cote de lui, sans `config.txt` : ni le coffre de `delphi-backend\`, ni celui
de `E:\a pmanager\`.
- **SQLite doit etre LIE dans l'exe : `FireDAC.Phys.SQLiteWrapper.Stat` dans
`PM.Database`.** Sans cette unite, FireDAC **charge `sqlite3.dll`** au
demarrage. Le piege : **sur un poste de developpement ca marche quand meme**,
parce que `…\Embarcadero\Studio\23.0\bin\sqlite3.dll` est dans le PATH. On
croit donc livrer un exe autonome ; sur une machine sans Delphi il refuse de
demarrer (`[FireDAC][Phys][SQLite]-314 Cannot load vendor library`). C'est
le Win64 qui l'a revele — la DLL du PATH est 32 bits, donc introuvable pour
lui — mais le **Win32 livre jusqu'au 2026-09-16 etait casse de la meme facon**
chez quiconque n'a pas Delphi, y compris le paquet prepare pour un tiers.
Prouve avec un PATH reduit a `C:\Windows\System32;C:\Windows` : avec l'unite
« SQLite OUVERT 3.42.0 », sans elle l'erreur exacte. `Stat` et non `FDEStat` :
le coffre est un SQLite ordinaire, le chiffrement se fait cote JavaScript.
**Pour verifier qu'un exe est vraiment autonome, retirer Delphi du PATH** —
lire la table d'import ne suffit pas, le chargement est dynamique.
- **Win64 (active le 2026-09-16).** Le groupe `Base_Win64` du `.dproj` etait
quasi vide : **ni prefixes d'unites** (`Winapi;System.Win;…`), d'ou
`F2613 Unite 'Windows' non trouvee` en recompilant TMS, **ni `Cfg_1_Win64`**
— l'exe aurait compile sans icone et sans `PerMonitorV2`, donc flou sur un
ecran haute resolution. Complete en miroir exact de Win32. **L'empreinte de
licence est identique en 32 et 64 bits** (verifie : `2179-C42D-2DF2-AE0B`
des deux cotes, les deux couches a TRUE) — une licence vaut pour les deux.
L'exe 64 bits cite `WebView2Loader_x64` : son ouverture reelle reste a
eprouver en le lancant.
## 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
- Remote : `origin` = `https://git.ai-agents4you.com/Zaki/Password-Manager.git`
(Gitea auto-heberge, le meme serveur que celui qui sert de cible de sync).
`main` suit `origin/main`, mais **rien n'est pousse automatiquement** — les
commits s'accumulent en local et partent quand on le demande. Au 2026-09-06,
427 commits d'avance.
- `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