Files
Password-Manager/CODE_AUDIT.md
T
r-zakarya 6556ce8dea feat(crypto): encrypt site/title/tags at rest too (CODE_AUDIT §1.3)
Extends the username-at-rest scheme to site, title and tags — the last
searchable metadata still stored cleartext. Same design: dedicated
<f>_enc/<f>_iv columns (AES-GCM under the vault key), decrypted at load into
e.<f>, so client-side search/sort/render/favicon/autofill-match are unchanged.
Full-strength random-IV AES-GCM (no searchable encryption) because search is
client-side.

Generalized the helpers over ENCRYPTED_META_FIELDS = [username, site, title,
tags]:
- withEncryptedUsername → withEncryptedMeta (encrypts all four, blanks
  cleartext) — wraps every POST/PUT body.
- decryptEntryUsernames → decryptEntryMeta (decrypts all four at load).
- migrateUsernamesAtRest → migrateMetadataAtRest (sweeps any field still
  cleartext, live + trash).
- doChangeMasterPassword re-encrypts all four under the new key.

Server (Entries + Auth + Database):
- Columns site_enc/iv, title_enc/iv, tags_enc/iv; GET emits them (new
  AddNullableField helper); POST/PUT/bulk read+persist (BindNullable helper);
  rotation UPDATE re-encrypts them.
- Removed the server "Site required" validation (site='' when encrypted — the
  client enforces it) at POST/PUT/bulk.
- ?q= server search neutralized (site+username ciphertext → LIKE useless; the
  frontend never sends ?search=).

Tests: merge assertions updated to decrypt site (encrypted on import). 65/65.

username was runtime-validated earlier; site/title/tags NOT yet compiled/
runtime-tested (Delphi) — large multi-handler change. Rebuild BuildAssets +
PMServer, then create/edit/dup/move/tag/import/rotate and verify the DB shows
no cleartext site/title/tags (and the app still renders/searches).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 11:25:13 +01:00

367 lines
19 KiB
Markdown

# Audit de code — PMServer (Password Manager)
> Analyse statique + revue architecture au 2026-07-03.
> Portée : crypto, auth, HTTP lockdown, sync, frontend, robustesse.
> Basé sur la lecture du code, pas sur un pentest dynamique.
Légende sévérité : 🔴 critique · 🟠 important · 🟡 moyen · 🔵 mineur / cosmétique
---
## 0. Résumé exécutif
Le produit est **solide pour un gestionnaire perso offline** : chiffrement
client-side AES-GCM, zero-knowledge login (le serveur ne voit jamais le
master pw), défense en profondeur sur le serveur loopback (token + PID
check + CSP stricte), DPAPI pour la persistance device-bound. La majorité
des "bugs" trouvés cette session ont été corrigés.
Les points qui méritent attention avant de considérer le produit "durci" :
1. 🔴 **Le verifier envoyé au serveur EST la clé de chiffrement** (en hex).
2. 🟠 **Sync WebDAV sans concurrence atomique** → lost update possible.
3. 🟠 **Arbitrage tombstone sensible au décalage d'horloge** entre devices.
4. 🟠 **PBKDF2-SHA256** (GPU-friendly) au lieu d'Argon2id.
5. 🟡 **`app.js` monofichier ~14k lignes** + aucun test automatisé.
---
## 1. Sécurité
### 1.1 🔴 Verifier = matériel de clé (couplage clé/authentifiant)
`deriveKeyAndVerifier()` ([js/app.js](js/app.js)) dérive **un seul** output
PBKDF2 et l'utilise pour DEUX rôles :
- `cryptoKey` = les 32 octets bruts importés en AES-GCM (chiffre les entries)
- `verifier` = **ces mêmes 32 octets en hex**, envoyés au serveur à `/login`
Le serveur stocke `SHA-256(verifier)`, donc une fuite de la **DB** ne donne
pas la clé (préimage SHA-256). MAIS :
- Le `verifier` transite en clair vers `/login` (loopback HTTP). Toute
fuite du **corps de requête** (log, proxy de debug, extension, futur
bug XSS contournant la CSP) expose **directement la clé du vault**.
- Conceptuellement, l'authentifiant et le secret de chiffrement ne
devraient jamais être le même matériel.
**Recommandation** : dériver le verifier dans un **domaine séparé**
p.ex. `verifier = HKDF(keyBytes, info="auth")` ou une 2ᵉ dérivation
PBKDF2 avec un `info`/salt distinct. Ainsi le verifier transmis n'est
pas la clé. Migration possible sans re-chiffrer les entries (seul le
verifier stocké côté serveur change).
### 1.2 🟠 PBKDF2-SHA256 vs Argon2id — **corrigé (2026-07-05)**
PBKDF2-SHA256 (600k) reste **GPU/ASIC-friendly** → un master pw faible
tombe vite sur matériel dédié si la DB fuit.
**Fait** : nouveau scheme `argon2id-v2` (Argon2id memory-hard + verifier
décplé). Détails dans le CLAUDE.md « Auth-hash schemes ».
- **JS pur** (`js/argon2.js`, bundle `@noble/hashes@2.2.0`), **pas WASM** :
la CSP `script-src 'self'` n'accorde pas `wasm-unsafe-eval`. Vérifié
contre le vecteur RFC 9106 §5.3 (test unitaire).
- **Zéro Argon2 côté Delphi** (l'audit se trompait sur ce point) : le
serveur ne dérive jamais la clé, il stocke/échoie juste les params
(`argon2_m/t/p`) et SHA256-wrappe le verifier comme tout `-v2`.
- Params OWASP `m=19 MiB, t=2, p=1` (~0.65 s/unlock), stockés par compte.
- **Adoption** : register + change-master-pw (nouveau défaut). Comptes
existants restent PBKDF2 jusqu'à rotation. Passer à Argon2id re-chiffre
tout le vault (la clé dérivée change) — c'est le flow rotation existant.
- Tests : 8 tests crypto Argon2 (vecteur RFC, branche KDF, contrat de
params register↔login, sensibilité aux params). 42/42.
**Validé runtime** : un compte ayant tourné sa master pw affiche
`hash_algo=argon2id-v2` (m=19456, t=2, p=1) et se reconnecte/déchiffre.
**Async (2026-07-05)** : `deriveKeyBytes` utilise `argon2idAsync` (bundle
re-vendé pour l'exposer) — cède la main à l'event loop pour que le spinner
s'anime au lieu de figer ~0.65 s. Parité sync/async testée sur le vecteur
RFC 9106. **Plus rien d'ouvert sur §1.2.**
### 1.3 🟡 Métadonnées en clair
Documenté mais à rappeler pour un futur modèle de menace :
- `entry_attachments` : `filename`, `mime`, `size_bytes` **non chiffrés**
- `users.avatar_b64` : image **non chiffrée** (cosmétique, assumé)
- `vault_entries` : ~~`username`, `site`, `title`, `tags`~~ **chiffrés
(2026-07-09)** ; `folder`, `kind`, `template` encore en clair.
**`username` + `site` + `title` + `tags` chiffrés au repos (✅ 2026-07-09)** :
colonnes `<f>_enc/<f>_iv` (AES-GCM sous la clé du vault). Clé de l'approche :
recherche/tri sont **côté client** → on déchiffre au `loadEntries` en mémoire,
donc AES-GCM plein (IV aléatoire), pas de searchable-encryption. Choke-point
`withEncryptedMeta` sur tous les writes ; `decryptEntryMeta` au load ; migration
`migrateMetadataAtRest` (live + corbeille) ; rotation re-chiffre les 4.
Validation serveur « Site required » retirée + `?q=` neutralisé (LIKE inutile
sur ciphertext). Détails CLAUDE.md « Entry payload ». **`username` validé
runtime le 2026-07-09** (0 en clair après migration). **`site`/`title`/`tags`
NON encore compilés/testés runtime** — même patron, gros changement
multi-handlers, rebuild + test soigneux requis. Résiduel : `folder`, `kind`,
`template`, métadonnées d'attachments, nombre de lignes, timestamps.
### 1.4 🟡 Snapshot de sync = tout le vault en clair sous le sync password
`buildSyncSnapshot()` déchiffre chaque entry puis re-chiffre le tout sous
`syncEncPwd`. Si l'utilisateur choisit un sync password faible, le fichier
WebDAV distant devient le maillon faible (le master pw fort ne protège
plus rien sur le remote). **Recommandation** : imposer une politique de
force minimale sur le sync password (déjà ≥ 6 chars — trop peu ; viser
≥ 12 + zxcvbn).
### 1.5 🔵 Points positifs (à conserver)
- ✅ AES-GCM 256 (AEAD, intégrité incluse) — pas de mode ECB/CBC nu
- ✅ IV aléatoire par blob (`crypto.getRandomValues(12)`) — pas de réutilisation
- ✅ Zero-knowledge : le master pw plaintext ne quitte jamais le client
- ✅ CSP stricte (`default-src 'self'; img-src 'self' data:`) — bloque
l'exfiltration même en cas d'injection
- ✅ Serveur loopback + token + PID-on-socket check (defense in depth)
- ✅ DevTools / menu contextuel Edge désactivés
- ✅ Rendu via `textContent` / `el()` (pas d'`innerHTML` sur données user)
→ surface XSS minimale
- ✅ Clipboard sécurisé (exclusion clipboard history + auto-clear 30s)
- ✅ Recovery code à usage limité (5), consommé, supprimé après rotation
---
## 2. Bugs / risques latents (non encore observés)
### 2.1 🟠 Sync : lost update (pas de concurrence atomique)
`runSyncNow()` fait `GET` → merge → `PUT`. Deux devices qui syncent
**en même temps** sur le même fichier WebDAV : le 2ᵉ `PUT` écrase le 1ᵉʳ
sans détecter le conflit → perte de la fenêtre de merge de l'un des deux.
**Recommandation** : concurrence optimiste via **ETag / If-Match** WebDAV.
Récupérer l'ETag au `GET`, l'envoyer en `If-Match` au `PUT` ; si 412
Precondition Failed → re-pull + re-merge + re-push. La plupart des serveurs
WebDAV (Nextcloud, Apache mod_dav) supportent les ETags.
### 2.2 🟠 Arbitrage tombstone sensible à l'horloge
### 2.2 🟠 Arbitrage tombstone sensible à l'horloge — **corrigé (2026-07-04)**
`applyRemoteSnapshot()` compare `entry.updated_at > tombstone.deleted_at`
pour décider résurrection vs suppression.
**Bug réel trouvé** (pire que décrit) : deux sources d'horloge coexistaient
**sur un même device**. `created_at`/`updated_at` des entries étaient écrits
via Delphi `FormatDateTime(..., Now)` = **heure locale**, tandis que
`deleted_at` (soft-delete + tombstones) venait de SQLite
`CURRENT_TIMESTAMP` / `datetime('now')` = **UTC**. L'arbitrage comparait donc
un `updated_at` local à un `deleted_at` UTC → décalage = l'offset UTC de la
machine, **même en solo mono-device** (pas seulement entre devices décalés).
**Fix** : helper `NowUTC` / `NowUTCStr` dans `PM.Database`, substitué à
`FormatDateTime(..., Now)` à tous les sites d'écriture d'entries/attachments
([PM.Handler.Entries.pas](delphi-backend/Handlers/PM.Handler.Entries.pas)
create/update/bulk, [PM.Handler.Attachments.pas](delphi-backend/Handlers/PM.Handler.Attachments.pas)).
Tout est désormais UTC (les paths SQLite l'étaient déjà). Côté JS aucun
changement nécessaire : `buildSyncSnapshot` utilise `toISOString()` (UTC) et
l'arbitrage compare deux valeurs du **même** fuseau → `Date.parse` applique
le même offset local aux deux, ordre relatif préservé.
**Rows existantes** : décision « laisser se soigner » — les anciennes lignes
gardent leur `updated_at` local jusqu'à leur prochain edit (qui le réécrit en
UTC). Exposition étroite pour un vault solo ; pas de migration destructive
(un shift par l'offset courant serait irréversible et approximatif : DST /
changement de fuseau historique).
**Reste (optionnel)** : compteur logique (Lamport) pour les cas d'égalité
stricte ; incohérence de **format** pré-existante (serveur `'yyyy-mm-dd
hh:nn:ss'` vs export JS `toISOString()` avec `T…Z`) sur les rares rows sans
`created_at`.
### 2.3 🟡 `catch (_) {}` silencieux en cascade
Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`,
restauration d'attachments, création de folders. Une erreur réseau/serveur
transitoire est avalée → l'entry/folder/attachment est silencieusement
sauté. Le garde-fou "failed count → abort push" atténue la perte côté
entries, mais **pas** côté attachments ni folders.
**Recommandation** : logger chaque `catch` (au moins `console.warn` +
compteur), et étendre le garde-fou "n échecs → warn utilisateur" aux
attachments.
### 2.4 🟡 Pas de pagination serveur sur `GET /entries`
Tout le vault est chargé + déchiffré à chaque unlock. Sur un gros vault
(5k-10k entries), `buildSyncSnapshot` (déchiffre chaque entry + fetch
attachments un par un) et `computeHealthCache` deviennent lents (O(n)
séquentiel, `await` en boucle). Acceptable pour usage perso (< 500
entries), problématique au-delà.
**Recommandation** : batcher les déchiffrements (Promise.all par lots),
et si besoin paginer côté serveur pour la vue grille.
### 2.5 🟡 500 au lieu de 401 sur session expirée
`Authenticate` écrit 401 puis `raise ESessionRejected` → le `try/except`
global de `PM.HTTPServer` réécrit un **500**. Pré-existant, tous les
handlers concernés. Cosmétique (le client voit un non-2xx) mais brouille
le debug.
**Recommandation** : attraper `ESessionRejected` spécifiquement dans le
dispatcher et ne pas réécrire la réponse déjà envoyée.
### 2.6 🔵 CSV re-import crée des doublons
Documenté/assumé (les lignes CSV sans uuid mint un nouvel uuid à chaque
import). Le dedup uuid ne couvre que le JSON. Acceptable si documenté à
l'utilisateur.
### 2.7 🔵 `navigator.clipboard` fallback échoue silencieusement si caché
Quand le Bridge Delphi est absent, le fallback `navigator.clipboard.
writeText` échoue si le document n'a pas le focus (WebView caché). Cas
rare (le Bridge est quasi toujours actif en prod) mais le `catch (_) {}`
masque l'échec → l'utilisateur croit avoir copié.
---
## 3. Qualité de code / maintenabilité
### 3.1 🟡 `app.js` monofichier ~14 000 lignes
Tout le frontend en un seul fichier. Difficile à naviguer, à tester, et
un edit malencontreux casse tout le parse (déjà arrivé cette session : une
déclaration de fonction supprimée → app entièrement morte, découvert
seulement au runtime).
**En cours (2026-07-05)** : découpage incrémental en fichiers classic-script
chargés dans l'ordre via `<script>` séparés — **pas de bundler ES/esbuild**
(les classic scripts partagent l'environnement lexical global, donc zéro
réécriture des call-sites, risque quasi nul vs conversion en modules ES).
-`js/app.crypto.js` extrait (KDF, verifier, AES-GCM) — byte-for-byte
identique, 42 tests verts, pas de `const` dupliqué. Chargé AVANT app.js.
-`js/app.sync.js` extrait (WebDAV, snapshot, merge `applyRemoteSnapshot`)
— byte-for-byte identique, couvert par les tests merge, chargé APRÈS app.js
(effet de bord top-level `Bridge.onWebdavResult`).
-`js/app.import.js` extrait (export container + parsing CSV/JSON +
`doImport`/`doExport`) — byte-for-byte identique, couvert par les 14 tests
CSV, chargé AVANT app.js (pures déclarations). Pattern + règles d'ordre
documentés dans CLAUDE.md « Découpage frontend ».
-`js/app.totp.js` extrait (TOTP RFC 6238 + crypto TOTP/custom-fields) —
byte-for-byte identique, chargé AVANT app.js. **+13 tests TOTP (vecteurs
RFC 6238)** ajoutés en même temps → extraction *et* nouvelle couverture.
-`js/app.backup.js` extrait (auto-backup planifié) — byte-for-byte
identique, chargé AVANT app.js (pures déclarations).
-`js/app.favicon.js` extrait (fetch/cache favicons) — chargé AVANT app.js.
**+7 tests `faviconHost`** (extraction hostname → décide quel domaine part
vers DDG). A révélé un commentaire faux (IPs « valides » alors qu'elles
sont rejetées par la règle TLD `[a-z]{2,}`) — corrigé.
-`js/app.health.js` extrait (vault health dashboard) — byte-for-byte
identique, chargé AVANT app.js (`auditCache`/`auditFilter` viennent avec,
résolus cross-fichier).
-`js/app.overlays.js` extrait (quick search + cheatsheet + password
history) — quicksearch était entrelacé avec ces 2 overlays, donc extraits
ensemble en un bloc contigu byte-for-byte. Chargé AVANT app.js.
- `app.js` : 11 936 → **9 170 lignes** (8 modules sortis, ~2 770 lignes).
- Suite de tests : 42 → **62 tests**.
- Reste : sections très couplées au state/DOM (slideover, settings, folders,
attachments, autofill, PIN/recovery/quick-unlock) — rendement/risque faible,
à faire au fil de l'eau. Le socle §3.1 (pattern + modules à risque isolés +
filet de tests) est en place.
-`node --check` en pré-étape de `BuildAssets.ps1` : **déjà fait** (cf. §3.2).
### 3.2 🟡 Aucun test automatisé — **partiellement adressé (2026-07-04)**
Toute la validation était manuelle (TEST_PLAN.md, TEST_REGRESSION.md). Les
zones à haut risque de régression (crypto round-trip, merge de sync,
arbitrage tombstone) sont exactement celles qui bénéficient d'un filet
unitaire.
**Fait** : suite `js/tests/` (35 tests, Node `node:test`, zéro dépendance,
~1.7 s) — cf. [js/tests/README.md](js/tests/README.md). Harness `node:vm`
qui charge `app.js` (monofichier) avec globals navigateur stubbés :
- `crypto.test.js``deriveKeyAndVerifier` (clé AES == PBKDF2 brut,
cross-checké vs `pbkdf2Sync` Node), découplage verifier legacy vs `-v2`,
`encryptPwd`/`decryptPwd` round-trip, unicité IV, tamper/mauvaise clé → `[ERROR]`.
- `csv.test.js``parseCSV`, `findColumn`, `parseEntriesFromCSV` (Bitwarden/
KeePass, classification note vs login).
- `merge.test.js``applyRemoteSnapshot` : add/update/skip (LWW), tombstone
delete, arbitrage résurrection (2 branches `NaN`), veto tombstone local,
merge additif de folders. Seule `api()` est stubbée (fake server mémoire) ;
`loadEntries`/`encryptImportEntry` tournent en vrai.
Intégré comme **gate de build** dans `BuildAssets.ps1` (après `node --check`,
bypass `PM_SKIP_TESTS=1`).
**Reste à faire** :
- `isSoDirty` (couplé DOM/`soState` — nécessite plus de stubbing).
- Tests Delphi (DUnitX) sur les handlers critiques (bulk-import + tombstone
purge, rotation master pw).
### 3.3 🔵 Duplication de constantes cross-langage
`600000`, formats de timestamp, noms de colonnes, shapes JSON sont
dupliqués entre JS et Delphi. Un changement d'un côté sans l'autre =
bug silencieux (déjà vu avec les champs oubliés dans la rotation/duplicate,
cf. la checklist "Entry payload" de CLAUDE.md).
---
## 4. Améliorations UX (rapides)
- 🔵 **Force du sync password** : afficher un indicateur zxcvbn au réglage,
refuser < 12 chars.
- 🔵 **Indicateur de force du master pw** à l'enregistrement (zxcvbn).
- 🔵 **Feedback d'échec sync partiel** : "3 attachments non synchronisés"
au lieu d'un silence.
- 🔵 **Combobox custom fields** : navigation clavier (flèches ↑↓ + Enter)
dans le menu déroulant, pas seulement souris.
- 🔵 **Auto-lock** : afficher le temps restant avant lock dans un coin.
- ~~🔵 **Avatar** : l'inclure dans le sync snapshot + auto-backup~~ — ✅ fait :
`avatar_b64` dans `buildSyncSnapshot` + payload auto-backup ; restauré
additivement dans `applyRemoteSnapshot` (adopté seulement si le device n'a
pas d'avatar, jamais écrasé). +2 tests merge.
---
## 5. Fonctionnalités futures (par valeur / effort)
| Feature | Valeur | Effort | Note |
|---|---|---|---|
| ~~**Argon2id** (KDF memory-hard)~~ ✅ | 🔴 Haute | ~~Moyen~~ | Fait : `argon2id-v2`, JS pur (noble), zéro Delphi. Cf. §1.2 |
| **ETag/If-Match sur sync** | 🟠 Haute | Faible | Évite les lost updates multi-device |
| **Extension navigateur** | 🟠 Haute | Élevé | Autofill in-page, feature #1 demandée |
| **Windows Hello (biométrie)** | 🟠 Moyenne | Moyen | `Windows.Security.Credentials` — déverrouillage biométrique |
| **Tests automatisés** | 🟠 Haute | Moyen | Filet de sécurité contre les régressions |
| **Import KeePass XML / 1PUX** | 🟡 Moyenne | Moyen | Complète l'écosystème d'import |
| **Password strength par entry** | 🟡 Moyenne | Faible | zxcvbn dans le slideover + vault health |
| **Verifier découplé de la clé** | 🔴 Haute | Faible | Cf. §1.1 — fix de sécurité prioritaire |
| **i18n (FR/EN propre)** | 🔵 Basse | Moyen | Actuellement FR/EN mélangés dans l'UI |
| **Timestamps UTC + Lamport** | 🟠 Moyenne | Moyen | Fiabilise l'arbitrage sync (§2.2) |
| **Emergency access / partage** | 🔵 Basse | Élevé | Hors scope "perso offline" |
---
## 6. Plan d'action recommandé (ordre)
1. **`node --check` dans `BuildAssets.cmd`** — 5 min, évite les JS cassés en prod.
2. **Découpler verifier ↔ clé** (§1.1) — fix sécu prioritaire, effort faible.
3. **ETag/If-Match sur sync** (§2.1) — évite la perte de données multi-device.
4. ~~**Timestamps UTC partout** (§2.2)~~ — ✅ fait (`NowUTCStr`, going-forward ; rows existantes self-heal).
5. ~~**Tests unitaires crypto + merge** (§3.2)~~ — ✅ fait (35 tests, `js/tests/`, gate de build).
6. ~~**Argon2id** (§1.2)~~ — ✅ fait (`argon2id-v2`, JS pur, adoption register+rotation).
7. Découpage `app.js` en modules (§3.1) — maintenabilité long terme.
---
## 7. Verdict
Pour un usage **personnel, local, mono-utilisateur**, le produit est
**sûr et fonctionnel** aujourd'hui. Les défenses en profondeur (loopback,
token, PID check, CSP, zero-knowledge, AES-GCM) sont bien pensées et
au-dessus de la moyenne des projets perso.
Les axes de durcissement (verifier découplé, Argon2id, sync atomique,
timestamps UTC) deviennent importants **dès qu'on vise le multi-device
sérieux ou un modèle de menace où la DB / le fichier de sync peut fuiter**.
Aucun de ces points n'est un trou béant immédiat en usage loopback solo,
mais §1.1 (verifier=clé) est celui que je corrigerais en premier car il
est peu coûteux et supprime un couplage dangereux.