Files
Password-Manager/CLAUDE.md
T
r-zakarya 3f8ecde571 feat(security): decouple the login verifier from the AES vault key
The zero-knowledge verifier sent to /login used to be the raw PBKDF2
output in hex — i.e. the exact bytes of the AES key that encrypts every
entry. Intercepting a /login body (loopback, but still) handed over the
vault key. This introduces a decoupled scheme where the transmitted
verifier is a one-way function of the key.

New auth-hash scheme
- users.hash_algo 'pbkdf2-sha256-v2': the client sends
  verifier = SHA256(keyHex + "pmserver/auth-verifier/v2") instead of
  keyHex. Stored form is still SHA256(verifier) (identical server wrap
  to 'pbkdf2-sha256'), so only the algo LABEL differs — it tells the
  client which verifier formula to use. Verification needs no new server
  branch (VerifierToStoredHash already SHA256-wraps any non-legacy
  verifier).
- The AES key (cryptoKey) stays hex(PBKDF2) for EVERY algo, so entries
  remain decryptable and switching schemes never re-encrypts data.

Adoption: new-registration + master-pw-change only
- Register and change-master-password write v2. Existing accounts keep
  their algo until they rotate — the login/reauth migration signal now
  fires only for LEGACY 'pbkdf2' (was: anything != CURRENT), so
  sha256/v2 accounts are never force-migrated (which would have
  downgraded v2 → sha256 via migrate-kdf).

Client (js/app.js): algo-aware everywhere
- verifierFromKeyHex(keyHex, algo) central helper; deriveKeyAndVerifier
  / computeVerifier take an algo arg. state.hashAlgo caches the account
  scheme, set from /login/challenge, register, change-master, the
  quick-unlock / PIN cold-start blobs, and the /recovery-key/redeem
  response. All ~12 verifier sites updated (login, register, reauth ×4,
  change-master current+new, migrate-kdf, quick-unlock + PIN cold-start,
  recovery-mode current verifier).

Safety invariant: unknown/empty hashAlgo → key hex → byte-identical to
the old behaviour, so every pre-decoupling account (and every existing
quick-unlock / PIN blob without the new field) keeps working unchanged.
Verified: existing account + pre-change quick-unlock still unlocks; a
master-pw change now writes 'pbkdf2-sha256-v2' in vault.db.

Server: recovery redeem returns hashAlgo; register + change-master store
the decoupled algo; login + reauth migration signal narrowed to legacy.

Also: BuildAssets.ps1 pipes $null into node --check so the JS syntax
gate can't block on stdin in the Delphi pre-build environment.

Addresses CODE_AUDIT.md section 1.1.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-03 12:38:20 +01:00

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

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 complet js/app.js
HTML racine index.html
Styles css/style.css

Bridge cmd:// (JS → Delphi)

JS navigue vers cmd://action/sub?params → intercepté par WebBrowserBeforeNavigateHandleBridgeCommand.

Commandes connues :

  • clipboard/copy, clipboard/clear, clipboard/read (Paste custom menu)
  • quickunlock/{store,get,clear,status}
  • prefs/{get,set}?key=... (device-bound DPAPI key/value, voir plus bas)
  • autostart/{get,set}?enabled=1|0 (HKCU Run registry, "Start with Windows")
  • favicon/fetch?host=X&reqId=Y (async via anonymous thread, callback Bridge.onFaviconResult(reqId, host, dataUri))
  • autofill/{configure,hotkeys,execute,cancel}
  • app/focus (ramène la fenêtre au premier plan, pour le picker)
  • app/ready (page chargée → SetFocus WebBrowser + DOM focus auth input)
  • app/theme?mode=dark|light (sync title bar + popup menus via SetPreferredAppMode)
  • audit (POST log d'action user-visible)
  • entry/new-from-title (Ctrl+Shift+A → modal pré-rempli)

Retour Delphi → JS via WebBrowser.ExecuteJavaScript('Bridge.onX(...)').

Hotkeys globaux Win32

  • Ctrl+Shift+L : autofill complet (user + Tab + password)
  • Ctrl+Shift+P : autofill password seul (forms step-2, unlock screens)
  • Ctrl+Shift+A : new entry pré-rempli avec le titre de la fenêtre foreground
  • Ctrl+Shift+D : toggle debug panel (uniquement si config.txt présent)

Combos autofill configurables depuis Settings (synced en DB via settings_json).

Debug mode

Fichier config.txt à côté de l'exe (tous champs optionnels) :

port=8765                    # 0 ou absent = port aléatoire ephemeral (49152-65535)
debug=true                   # affiche PanelTop (Start/Stop, log memo)
require_token=false          # désactive le token validation (curl/Postman direct)
require_process_check=false  # désactive le PID-on-socket check

Sans config.txt, Ctrl+Shift+D ne fait rien (sécurité, panel jamais exposé en prod).

HTTP server lockdown (anti-browser-direct)

Le serveur Indy n'accepte une requête que si elle a :

  • Query param ?pmt=<token> (sur la 1ʳᵉ navigation) → Set-Cookie
  • OU cookie pm_token=<token>; HttpOnly; SameSite=Strict; Path=/

Au start :

  • Port aléatoire dans [49152, 65535] si pas configuré
  • Token random 32-chars hex généré
  • WebView2 navigate vers http://127.0.0.1:{port}/index.html?pmt={token}
  • JS strip le ?pmt=... du URL bar via history.replaceState

Requête sans token valide → 404 (pas 403 pour ne pas révéler que le serveur existe). Override via require_token=false.

Couche 2 : PID-on-socket process check

Sur chaque request, PM.ProcessLockdown :

  • GetExtendedTcpTable (winapi iphlpapi.dll) lookup le PID qui possède la connexion entrante (match sur Chrome's localPort + remotePort = our port)
  • IsDescendantOfCurrentProcess walk parent tree via Toolhelp32 → notre exe est-il ancêtre ?
  • Si non (curl externe, autre browser, etc.) → 404

Defense in depth : si quelqu'un connaît le token (leaked logs, mémoire), il faut AUSSI exécuter le code dans notre lignée de process pour passer. Override via require_process_check=false. Notre WebView2 (children msedgewebview2.exe) passent naturellement.

Token masqué dans les logs

MaskAccessToken(url) dans UMainForm remplace ?pmt=<32 chars> par ?pmt=*** avant LogLine. Évite le leak via copie/upload du log.

Title bar + popup menus dark mode

PM.Bridge.ApplyTitleBarTheme(ADark: Boolean) fait 2 choses :

  1. DwmSetWindowAttribute(DWMWA_USE_IMMERSIVE_DARK_MODE=20) → title bar. No-op sur Win < 19044.
  2. uxtheme!SetPreferredAppMode (ordinal #135) + FlushMenuThemes (#136) — API privée stable depuis Win10 1809 utilisée par Explorer/Edge/Office. Themea automatiquement les popup menus (tray context menu inclus), scrollbars, tooltips. Valeurs : ForceDark=2, ForceLight=3.

Appelé au FormCreate + via cmd://app/theme?mode=dark|light quand JS toggle.

Single instance

Au démarrage, PM.SingleInstance.AcquireOrSignal :

  • Crée un mutex nommé Local\PMServer.SingleInstance.Mutex (namespace Local\ = per-session, donc deux users Windows distincts peuvent chacun lancer le leur)
  • Si ERROR_ALREADY_EXISTS : PostMessage(HWND_BROADCAST, WM_PMSHOW, 0, 0)WM_PMSHOW = RegisterWindowMessage('PMServer_ShowExisting') est un ID system-unique. Puis exit immédiat avant Application.Initialize.

L'instance existante reçoit WM_PMSHOW dans son message-only window (PM.Bridge.MsgWindowHandler) → déclenche FOnTrayRestoreRestoreFromTray. 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.aiqwen.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.QueueBridge.onFaviconResult(reqId, host, dataUri)
  • Stockage : colonne vault_entries.icon_b64 TEXT (nullable). Endpoint dédié POST /entries/{id}/icon pour ne pas forcer un PUT complet (qui re-PUT le password chiffré)
  • Bulk : DELETE /entries/icons/all purge tout (bouton "Clear cache")
  • Render : entry-avatar contient <img class="entry-avatar-img"> si icon_b64, sinon fallback aux initials. onerror repasse aux initials si la data URI est corrompue
  • Auto-fetch après save d'entry, backfill via "Fetch missing" / "Re-fetch all" boutons dans Settings

Start with Windows (PM.AutoStart)

  • Toggle dans Settings → "Start with Windows" (visible seulement quand Bridge.active)
  • Mécanisme : HKCU\Software\Microsoft\Windows\CurrentVersion\Run, valeur "PMServer" = "<exe-path>" -tray (single dash — --tray ne match pas FindCmdLineSwitch)
  • Per-user, pas d'admin requis. Visible dans Task Manager → Startup tab
  • Toggle "on" = registered AND la valeur pointe vers notre exe courant (un old entry d'un exe déplacé lit comme "off" → user peut re-enable pour rafraîchir le path)
  • CLI flag -tray (FindCmdLineSwitch('tray', True)) dans FormCreate → FBridge.MinimizeToTray via TThread.ForceQueue (defer après la show initiale FMX pour minimiser le flash)

Device-bound prefs (PM.UserPrefs)

Problème résolu : le serveur HTTP bind un port éphémère aléatoire (49152-65535) sur chaque start. localStorage est keyed par origin (scheme+host+port) → reboot = nouvelle origine = localStorage wipé. Pour les prefs qui doivent survivre (rememberedUsername, etc.), on persiste via DPAPI.

  • %LOCALAPPDATA%\PMServer\prefs.bin — DPAPI-encrypted UTF-8 JSON {"key":"value",...}
  • PM.UserPrefs.GetPref(key) / SetPref(key, value) Delphi-side
  • Bridge JS : await Bridge.getPref(key) (Promise) / Bridge.setPref(key, value)
  • Callback : Bridge.onPrefResult(key, value) posé par Delphi via ExecuteJavaScript. Resolvers stockés dans prefResolvers[key] avec timeout 2 s.

LoadAll retourne toujours un TJSONObject valide (jamais nil) — défault = objet vide, remplacé seulement sur parse success.

Quick unlock — interactions

  • Au boot, init() call bridgeQuickUnlockStatus() et synchronise state.quickUnlockEnabled + localStorage depuis la source of truth DPAPI (corrige le décalage localStorage quand le port change).
  • Sur Windows lock / sleep (BridgeSystemLockBridge.onSystemLock) :
    • Si state.quickUnlockEnabledpas de lockVault. Le blob DPAPI gate déjà l'accès via le compte Windows, re-locker est redondant. Toast informatif affiché.
    • Sinon → comportement d'origine (lockVault()).
  • L'auto-lock par inactivité (autoLockTimer) reste indépendant — il ignore le flag QU (opt-in utilisateur explicite via Settings).

Authenticator + TOTP tool

Sidebar Tools expose 2 items MFA :

  • Authenticator (state.view = 'authenticator') : grille de cards (chaque entry avec TOTP secret) — code 6 chiffres en gros + barre countdown. renderAuthenticatorGrid() décrypte tous les secrets upfront puis tick 1 s. authTickTimer clear sur lock / view change. Snap instantané à 100% sur reset de période (sinon CSS transition anime le saut backward → effet "freeze").
  • TOTP generator (openTotpTool()) : modal standalone. Paste base32 ou otpauth:// URI → code live. Bouton "Generate" génère un secret base32 aléatoire 20 bytes (randomBase32Secret()). Rien n'est sauvegardé.

Code TOTP retourne { code, period, secondsLeft } — attention au nom, pas remaining (utiliser t.secondsLeft).

Vault health dashboard

Sidebar Tools → "Vault health" (state.view = 'health'). Vue dédiée avec un score 0-100 et 4 cards de catégorie :

  • Weak : computeStrength(plain) < 50
  • Reused : groupes d'entries partageant le même plaintext (≥ 2)
  • Old : updated_at > 365 days
  • Pwned : depuis state.hibpResults (n'apparaît que si HIBP actif)

computeHealthCache() décrypte chaque entry une fois et stocke dans healthCache (module-level let, pas dans state pour ne pas polluer). Invalidé sur lock, save d'entry, et bouton "Recompute".

Score : start 100 → -5/weak (cap -40), -10/reused (cap -30), -2/old (cap -20), -15/pwned (cap -50). Bandes : ≥80 Good (vert), ≥50 Fair (cyan), ≥25 At risk (orange), <25 Critical (rouge).

Bouton "Fix" par item → openEntryForFix(id) = openSlideover puis click sur le bouton edit-password (fallback : juste ouvre le slideover).

Liste tronquée à 20 items par catégorie + count "+N more".

Sidebar sections collapsibles

Sections Folders, Tags, Tools ont chacune un .section-toggle button (chevron + label + badge count) qui toggle .is-collapsed sur la section parent. Body masqué via display: none sous .is-collapsed.

État persisté dans state.sidebarCollapsed = { folders, tags, tools }, synced via SYNCED_SETTING_KEYS (settings_json). Badges (#countFolders, #countTags) restent visibles quand replié.

Hotkey verrouillé (UX)

autofillHandleRequest : si state.locked ou pas de cryptoKey → Bridge.cancelAutofill() + Bridge.focusApp() puis focus le master password input via DOM. Remplace l'ancien no-op silencieux qui laissait l'utilisateur perplexe.

WebView2 / TMS settings — pièges majeurs

TTMSFNCWebBrowser expose des props qui semblent settables au FormCreate mais ne sont effectivement appliquées qu'après l'init async du WebView2 sous-jacent. Set ces props dans WebBrowser.OnInitialized, pas FormCreate :

  • EnableContextMenu := False → kill le menu Edge natif (Inspecter, Importer mots de passe…)
  • EnableShowDebugConsole := False → kill DevTools
  • EnableAcceleratorKeys := FalseNE PAS METTRE : casse aussi Ctrl+C/V/X/Z/A/K dans les inputs

Le blocking F12 / Ctrl+Shift+I/J / Ctrl+U se fait côté JS (keydown preventDefault) en complément.

Custom right-click menu

Le menu natif est désactivé via TMS. On gère un mini-menu JS dédié sur right-click dans les inputs : installCustomContextMenu() → Cut / Copy / Paste / Select All. Paste utilise Bridge.readClipboard() (sans prompt WebView2 contrairement à navigator.clipboard.readText()). stopPropagation sur les click items sinon le slideover se ferme (chip retiré du DOM avant que le doc click handler check .closest('.slideover')).

Browser shortcuts bloqués (keydown JS)

Bloqués via preventDefault :

  • F12, Ctrl+Shift+I/J, Ctrl+U — DevTools / View source
  • Ctrl+J — Downloads overlay (le moins évident, déclenche un popup Edge)
  • Ctrl+H/S/P/T/N/R, F5 — History / Save / Print / New tab+win / Reload
  • Ctrl+Shift+N/W/Delete — Incognito / Close window / Clear data

Conservés : Ctrl+C/V/X/A/Z (édition), Ctrl+F (find in page), Ctrl+0/+/- (zoom accessibility), Ctrl+K (notre command palette), Ctrl+Shift+L/P/A/D (nos hotkeys).

Pagination

State : pageSize (10/25/50/100, default 25, synced via settings_json)

  • currentPage runtime-only. Active sur les 3 view modes (cards/list/ table) si > 10 items. Position top (au-dessus du grid/table).

CSS : .pagination { grid-column: 1 / -1 } pour span full width en cards view (sinon ça occupe une slot de card).

Reset currentPage = 1 sur : search, sort, view (folder/fav/tag), viewMode change, pageSize change. Render via renderPagination(total, totalPages)

  • computePageList(current, total) avec ellipsis intelligente (1 … 4 5 6 … 12).

Flash animation (nouvelle entry)

flashEntry(id) appelée après saveEntry success + duplicateEntry : setTimeout(0)querySelector('.entry-card[data-id],.entry-row[data-id]')scrollIntoView({block:'center'}) + classe is-flash 2.5s (@keyframes entryFlash pulse cyan).

Résout le cas "j'ai ajouté un mot de passe avec tri A-Z, où se loge-t-il ?" — scroll + pulse trouvent la nouvelle entry dans la grille triée.

Entry payload — call sites à toucher ensemble

Une vault_entries row porte plusieurs blobs chiffrés indépendants : encrypted_password/iv, totp_secret/totp_iv, custom_fields/custom_fields_iv, plus le champ-icône icon_b64 et les méta non chiffrées (site, title, username, folder, tags, kind, template). template est le sous-type (ex: credit-card, ssh-key, server, recovery-codes) qui drive le label de card/table — vide pour login/note génériques.

Quand tu ajoutes un nouveau champ (chiffré ou non), il faut toujours mettre à jour ces 6 endroits sous peine de perdre la donnée silencieusement sur certaines actions :

  1. DB schema : PM.Database.CreateSchema AddColumnIfMissing(...)
  2. GET /entries : PM.Handler.Entries.HandleGetEntries — ajouter au LObj.AddPair(...) (NULL → TJSONNull)
  3. POST + PUT /entries : HandleCreateEntry + HandleUpdateEntry — lire du body, binder le param, gérer Clear pour NULL
  4. Master pw rotation : PM.Handler.Auth.HandleChangeMasterPassword — UPDATE doit inclure la colonne, sinon la rotation l'écrase à NULL
  5. Master pw rotation JS : doChangeMasterPassword → la boucle for (const e of state.entries) re-chiffre chaque blob et push dans encrypted[]. Manquer un champ chiffré = donnée perdue.
  6. duplicateEntry (js/app.js) — copier le blob chiffré tel quel (même vault key, pas besoin de re-chiffrer). Pour icon_b64 : inclure dans le body POST. Pour attachments : boucle séparée après create qui GET source attachments + POST sur le nouveau id.
  7. moveEntryToFolder (js/app.js) — PUT partiel sans ces champs = wipe silencieux (TOTP perdu, custom_fields perdus, et notes rejetées en 400 "Site required" parce que kind default à 'login'). Re-ship le payload complet, seul folder change.

Bonus utile (pas critique) : soDirtyCheck doit comparer le nouveau champ, et openSlideOver doit le déchiffrer et l'exposer via soState.

Historiquement on a oublié kind dans duplicateEntry (bug "Site required" sur duplique-note), et custom_fields dans la rotation + duplicate. Cette liste évite de répéter ces erreurs.

Encrypted attachments

Per-entry file storage (PDFs, images of backup codes, etc.) encrypted client-side with the vault key.

  • Table : entry_attachments (id, user_id, entry_id, filename, mime, size_bytes, encrypted_blob TEXT base64, iv, created_at). FK cascade on user + entry delete.
  • Endpoints (PM.Handler.Attachments.pas) :
    • 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/pickSelectDirectory FMX, callback Bridge.onFolderPickResult(reqId, path)
    • file/write?path=&data=<b64> → silent write (no dialog)
    • file/listMatch?dir=&prefix= → JSON [{name,size,mtime}]
    • file/delete?path= → single delete
  • Retention : après chaque write OK, list dir + sort name desc, delete au-delà de keep. Best-effort.

Settings sync

Per-user blob JSON dans users.settings_json, exposé via GET/PUT /settings. Synced : theme, autoLock, viewMode, sortBy/sortDir, hibp, hotkeys autofill, etc. Device-only (localStorage seulement) : quickUnlockEnabled (DPAPI lié au compte Windows), autofillEnabled (toggle hotkey Win32), rememberedUsername (auth screen autofill local).

Sync (WebDAV, auto-merge)

Multi-device sync via a user-hosted WebDAV server (Nextcloud, ownCloud, Apache mod_dav). Auto-merge strategy: last-write-wins per entry on updated_at, tombstones propagate hard-deletes. No conflict UI — solo personal use rarely produces simultaneous edits.

Foundations :

  • vault_entries.uuid (TEXT, indexed) — stable cross-device identity. Migration backfills existing rows via hex(randomblob) → RFC 4122 v4.
  • entry_tombstones (user_id, uuid, deleted_at) — UNIQUE(user_id, uuid), written on hard-delete (DELETE permanent=1, trash empty, auto-purge).
  • GET /entries returns uuid ; POST/bulk-import accept it (mint fresh if absent) ; PUT keeps it immutable.
  • GET /entries/tombstones lists local tombstones.
  • POST /entries/tombstones {uuids:[...]} adds tombstones + hard-deletes any local rows matching those uuids (idempotent via INSERT OR IGNORE).

Transport (UMainForm.pas) :

  • 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.
  9. Toast X added · Y updated · Z deleted.

Sensitive actions (export, change master pw, recovery code…) still require master pw via askReauth — sync never substitutes.

Tombstone purge on (re)create : POST /entries and POST /entries/bulk-import both DELETE any entry_tombstones row matching the inserted uuid (same transaction). Without this, restoring a backup whose entries were previously hard-deleted would leave the tombstone in place — the local buildSyncSnapshot would then re-push it and the next pull would kill the just-restored entries. Purge-on- insert + the resurrection arbitration (step 4) together make restore-then-sync actually stick. A live vault_entries row can never coexist with its tombstone (hard-delete removes the row), so PUT needs no purge.

Auth-hash schemes (users.hash_algo)

Three 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 (previous 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, current default) : 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).

The AES key (cryptoKey) is ALWAYS hex(PBKDF2) regardless of algo — only the verifier string changes, so entries stay decryptable and switching schemes never re-encrypts data.

Adoption is new-registration + master-pw-change only — existing accounts stay on their algo until they rotate (no forced login-path migration; not SameText(algo, LEGACY) no longer signals migration, so sha256/v2 accounts are left alone). Client picks the verifier formula from the algo returned by /login/challenge, cached in state.hashAlgo (also carried in the quick-unlock / PIN cold-start blobs and the /recovery-key/redeem response). Unknown/empty hashAlgo → key hex → correct for every pre-decoupling account, which is what makes the rollout safe. Central client helper: verifierFromKeyHex(keyHex, algo).

PIN unlock

Optional shortcut unlock with a 412 digit PIN, complementary to Quick Unlock. Three modes (state.unlockMode, synced via settings_json) :

  • pw — master password only (legacy, default)
  • pin — PIN unlocks the vault on this device
  • both — master password first, then PIN verified before access

Storage : PM.PinUnlock.pas writes a DPAPI blob to %LOCALAPPDATA%\PMServer\pin-unlock.bin. Bridge cmds: pin/store, pin/get, pin/clear, pin/status (mirror Quick Unlock exactly, separate file so both features coexist).

Crypto wrap: wrapKey = PBKDF2(pin, salt, 100k iter) — 100k instead of 600k because PIN entropy is low (~1340 bits), more iterations mostly slow down honest users. Vault key bytes are AES-GCM(wrapKey, key_raw) inside the blob. Successful PIN → unwrap → import as state.cryptoKey → fresh /login with verifier = hex(rawKey) (same pattern as Quick Unlock cold-start).

Anti-brute-force : each failed PIN attempt increments attempts in the blob and rewrites it via pin/store. Past 5 fails → pin/clear → user falls back to master pw. Successful unlock resets the counter to 0.

both mode : doLogin saves the typed PIN on window._pinAfterMaster, runs the regular master-pw flow, then enterApp calls verifyPinAfterMasterUnlock(pin) BEFORE flipping to the app shell. PIN mismatch → lockVault() + "Wrong PIN" hint. Quick Unlock cold-start bypasses the PIN check (the device is already trusted).

Sensitive actions (askReauth paths: export, change master pw, recovery code, etc.) ALWAYS require master pw — PIN never substitutes.

Master pw rotation clears the PIN blob (same reason as Quick Unlock : stored wrapped key + server verifier drift). User re-sets PIN from Settings after rotation.

Quick Unlock

DPAPI blob à %LOCALAPPDATA%\PMServer\quickunlock.bin (tied to Windows user, pas à l'emplacement de l'exe — survit au déplacement).

Cold-start restore : tryQuickUnlock ne check pas localStorage comme source of truth (WebView2 user-data folder est relatif à l'exe → move = localStorage perdu). Le fichier DPAPI seul gouverne. Si présent :

  • Décrypte blob → restore key
  • Re-login fresh via /login avec verifier = bytesToHex(rawKey) (le token stocké dans le blob peut être expiré ; on en récupère un nouveau à chaque cold-start)
  • Resync localStorage à '1' pour que Settings affiche "Disable"

Recovery code (refactor 2026-05)

  • 5 uses par code (compteur remaining_uses dans recovery_keys)
  • Décrémenté à chaque redeem. Si tombe à 0 → row deletée
  • Row deletée aussi sur successful change-master-password
  • Workflow attendu : recover → set new master pw (consomme le code) → regenerate un nouveau code

State justRecovered : flag JS posé après redeem, cleared sur

  • successful master pw change (le code est consumed)
  • lockVault (lock = perte de contexte recovery)
  • doLogin success (login normal avec pw = sortie du mode recovery)

En mode recovery, le modal "Change master password" masque le champ "Current master password" ET retire son required (sinon HTML5 form validation bloque silencieusement le submit). Le verifier "current" est dérivé du state.cryptoKey en mémoire (qu'on vient de recover).

Autofill pipeline

Workflow utilisateur (important)

L'utilisateur doit cliquer le champ cible AVANT de presser le hotkey. Le code ne fait plus de "click au centre de la fenêtre" pour deviner le champ — ça détruisait le focus sur les forms qui n'étaient pas centrés (Gitea login, etc.).

  • Ctrl+Shift+L (full) : click sur username field → hotkey → user+Tab+pwd
  • Ctrl+Shift+P (password only) : click sur password field → hotkey → pwd seul

Pipeline Delphi

  1. RegisterHotKey Ctrl+Shift+L/P sur message-only window
  2. WM_HOTKEY → capture GetForegroundWindow + titre, fire callback
  3. JS reçoit Bridge.onAutofillRequest(title, kind) → strip browser suffix (- Google Chrome, etc.) → matching state.entries.site
  4. Single match → Bridge.executeAutofill(user, pwd) direct Multi-match → Bridge.focusApp() + picker modal → user pick → execute Note : closeAutofillPicker(false) quand user pick (skip cancel cmd qui clearrait FAutofillTargetHWND)
  5. Delphi ExecuteAutofill :
    • Si app foreground → SW_MINIMIZE (laisse la place au target)
    • ForceForegroundWindow(target) avec AttachThreadInput + poll jusqu'à GetForegroundWindow == target (max 600ms)
    • WaitForModifierRelease (sinon Ctrl+Shift restent down → toutes les frappes deviennent Ctrl+Shift+X)
    • Sleep(120) settle
    • Pas de click parasite — le user a déjà focusé le bon champ
    • Ctrl+A + Del avant chaque champ (clear contenu existant)
    • SendInput : Ctrl+A+Del → username → 200ms → Tab → 200ms → Ctrl+A+Del → password

Matching titre → entry

  • Strip suffixe browser via BROWSER_SUFFIX_RE (Chrome, Firefox, Edge, Brave, Opera, Vivaldi, Safari, Tor, Arc) avant toute comparaison
  • Score = max sur 3 champs : entry.title (display name, score 1500+), entry.site hostname full (1000+), SLD (500+)
  • Pour SLD courts (x, qq), match avec word boundaries
  • entry.title priorisé → si utilisateur stocke site = "git.example.com" (hostname), mettre aussi title = "Gitea" (brand) couvre le browser title sans ambiguïté

Contraintes connues autofill

  • UIPI : SendInput vers process élevé bloqué. Notepad++ admin, regedit → autofill silencieusement no-op. Tester avec apps non-élevées.
  • Chrome password manager : si Chrome a des credentials sauvegardés pour le domaine, il peut racer avec notre SendInput et écraser notre username pendant le Tab. Workaround : user désactive Chrome PM par site (clic-droit password field → "Never save passwords for this site")
  • Gitea-like forms : tabindex explicite + autocomplete="current-password" → Chrome PM s'active. Désactiver pour ce domaine ou utiliser Ctrl+Shift+P
  • Title-based matching : si l'utilisateur stocke site = "git.ai-agents4you.com" (hostname) mais le titre browser n'a que la marque ("Gitea"), pas de match. Convention : préférer site = "Gitea" (brand) pour les sites brandés. URL hostname-only fonctionne pour les sites dont le titre contient le hostname (rare).

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.
  • TMS render-time vs DOM-attachment : pattern courant — fonctions qui rendent un widget (soTagsField, soTotpField) appellent un helper (renderSoChips, startTotpTick) qui fait $('#id') sur un élément pas encore dans le DOM. Solution : render inline OU setTimeout(fn, 0) pour deferrer jusqu'au tick suivant.
  • HTML5 required + display:none : un input required caché bloque silencieusement le form submit. Toggle aussi .required = false quand on cache (cf. modal recovery).
  • WebView2 user-data folder est relatif à l'exe : déplacer l'exe = nouveau folder = localStorage/sessionStorage perdu. Pour ce qui doit survivre au move, stocker via Delphi (DPAPI, fichiers user).
  • Port éphémère = nouvelle origin = localStorage wipé chaque launch : cause distincte du point précédent (l'exe ne bouge pas, mais le port change). Mêmes conséquences : pour tout pref qui doit survivre au reboot, passer par PM.UserPrefs via Bridge.getPref/setPref. Le fix port-fixe est possible (port=N dans config.txt) mais on perd l'aléatoire qui complique le scan externe.
  • Modifier les Enable* props TMS en OnInitialized, pas FormCreate.

Conventions code (héritage sessions précédentes)

  • Pas de commentaires multi-lignes pour chaque fix : préférer noms de variables/fonctions descriptifs (OwnFormHwnd, EnsureTargetThreadHasKeyboardFocus, FocusSettleDelayMs)
  • Migrations DB : toujours via AddColumnIfMissing (idempotent)
  • Nouveau setting UI : ajouter à SYNCED_SETTING_KEYS + handler dans loadServerSettings switch case + listener change qui appelle saveServerSettings()
  • Tout new bridge cmd : handler dans UMainForm.HandleBridgeCommand, log via LogLine pour traçabilité

Audit / docs

  • security-issues.md à la racine : audit sécu legacy (api.php)
  • Ce CLAUDE.md = source de vérité pour l'architecture courante

Git

  • Pas de remote configuré (dev local)
  • vault.db, *.db-shm, *.db-wal, delphi-backend/Win32/, *.dcu, assets.res gitignored
  • Messages commit : type(scope): description style conventional