Files
Password-Manager/CLAUDE.md
T
r-zakarya 63e532d5bb feat(entries): Precedent / Suivant parmi les entrees ouvertes
Fleches dans l'en-tete du panneau et Alt+fleches, comme un navigateur :
un clic par erreur dans une liste filtree ne force plus a refaire la
recherche. Historique en memoire (50), vide au verrouillage ; la navigation
repasse par openSlideOver et sa confirmation de modifications non
enregistrees. Logique pure testee (history.test.js).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-10 17:39:24 +01:00

101 KiB
Raw Blame History

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 — 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 — 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, 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) :
    • 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) :
    • 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) :

  • 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

  • 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.
  • 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