fix(kdf): disable the data-loss KDF auto-migration path (audit 6c-1)

/migrate-kdf changed the AES key (100k->600k) but only re-encrypted
encrypted_password, leaving totp_secret/custom_fields/*_enc + attachments
under the old key = permanent data loss. Both live accounts are already at
600k so it never fired, but the path was armed and auto-triggered by the
server's login signal.

Removed the two triggers (doLogin + unlock) and the now-dead runKdfMigration.
Legacy <600k accounts upgrade safely via Change Master Password instead
(re-encrypts everything + moves to Argon2id). The Delphi endpoint is left
inert: without the client orchestrating the key swap it can't corrupt
anything, so no untested server change. Closes 6c-4 too (unreachable now).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-20 05:20:07 +01:00
parent 0404fc65a4
commit 71f45eb8e1
2 changed files with 539 additions and 123 deletions
+533
View File
@@ -182,6 +182,13 @@ 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`.
> ⚠️ **MAJ 2026-07-16 — « Tout est désormais UTC » était inexact.** La passe de
> substitution a couvert les sites d'écriture *d'entries/attachments*, mais a
> raté **`PM.Session.CreateSession`** (`FormatDateTime(..., IncHour(Now, 24))`,
> heure locale), qui reste à ce jour le dernier `FormatDateTime(..., Now)` du
> backend. Conséquence réelle sur les fuseaux négatifs. Détail + correctif en
> **§6c-2**.
### 2.3 🟡 `catch (_) {}` silencieux en cascade
Nombreux `catch (_) {}` dans `applyRemoteSnapshot`, `buildSyncSnapshot`,
@@ -410,6 +417,521 @@ invisibles, perf et code mort. Rien de bloquant ; classé par type.
---
## 6c. Revue backend Delphi (2026-07-16) — correctifs proposés, NON appliqués
Passe manuelle sur les ~10 500 lignes Delphi (`Source/` + `Handlers/` + `.dpr`),
axée crypto/auth/session/HTTP/SQL. Les correctifs ci-dessous sont **écrits mais
pas appliqués** — chacun est autonome.
**Sain, vérifié (pour ne pas re-chasser)** : aucune injection SQL (les seuls SQL
concaténés sont `PRAGMA table_info(...)` / `ALTER TABLE` dans `PM.Database`, sur
constantes internes) ; autorisation scopée par `user_id` **partout** sans
exception ; le `SET` dynamique de [Folders.pas:199](delphi-backend/Handlers/PM.Handler.Folders.pas:199)
est assemblé de littéraux fixes, pas d'input ; les routes non authentifiées sont
exactement les bonnes (register, login, challenge, ping, redeem, stub passkey) ;
`ValidateConnectingProcess` échoue **fermé** si le lookup PID rate ; primitives
crypto correctes + auto-testées à l'init. La fuite d'énumération de comptes de
`/login/challenge` est **documentée et assumée** ([Auth.pas:1189](delphi-backend/Handlers/PM.Handler.Auth.pas:1189))
→ pas comptée comme finding.
---
### 6c-1. 🔴 `/migrate-kdf` ne re-chiffre que 1 blob sur 8 — perte de données
> ✅ **RÉSOLU 2026-07-16 — chemin désactivé (option ultra).** Les 2 call-sites
> qui déclenchaient l'auto-migration au login/reauth
> ([app.js:1246, 1443](js/app.js)) sont retirés + la fonction morte
> `runKdfMigration` supprimée. Sans le client qui orchestre le swap de clé,
> l'endpoint serveur ne peut plus corrompre (il ne fait qu'un UPDATE de ce
> qu'on lui envoie) → laissé inerte, non touché. **DB vérifiée avant fix : les 2
> comptes sont déjà à 600k**, le chemin n'a donc jamais mordu. Un compte legacy
> <600k s'upgrade désormais via **Change Master Password** (qui re-chiffre les
> 8 blobs + attachments correctement et passe à Argon2id). §6c-4 (brick d'un
> compte -v2 par ce même endpoint) devient également inatteignable.
**C'est le seul vrai bug de perte de données de cette passe.**
La migration KDF passe les itérations 100k → 600k, donc **change la clé AES
dérivée** — puis [app.js:1035](js/app.js:1035) bascule `state.cryptoKey = newKey`.
Or [`runKdfMigration`](js/app.js:961) ne re-chiffre que `encrypted_password`/`iv`,
et [`HandleMigrateKdf`](delphi-backend/Handlers/PM.Handler.Auth.pas:795) n'UPDATE
que ces deux colonnes.
Restent chiffrés sous l'**ancienne** clé, donc définitivement illisibles :
`totp_secret`, `custom_fields`, `username_enc`, `site_enc`, `title_enc`,
`tags_enc`, `template_enc` (+ leurs IV), et les attachments.
Le contraste est net avec [`doChangeMasterPassword`](js/app.js:6106) /
[`HandleChangeMasterPassword`](delphi-backend/Handlers/PM.Handler.Auth.pas:1038),
qui affrontent **exactement la même situation** (changement de clé) et traitent
correctement les 8 colonnes. C'est une nouvelle instance de la classe de bug
décrite en **§3.3** — et la checklist « Entry payload » du CLAUDE.md cite
`doChangeMasterPassword` mais **pas** `runKdfMigration` : voilà par où c'est passé.
**Symptôme** : sournois, pas bruyant. [`decryptEntryMeta`](js/app.js:1502) ignore
silencieusement les champs qui ne déchiffrent pas (`if (v !== '[ERROR]') e[f] = v`)
→ titres/usernames deviennent `undefined` à l'écran, puis le premier save réécrit
ce vide par-dessus l'ancien ciphertext. Là c'est irréversible.
**Portée** : uniquement les comptes à `kdf_iterations < 600000`. Les inscriptions
partent directement à 600k ([app.js:1283](js/app.js:1283)) → legacy uniquement.
**Non vérifié** s'il en reste : aucun `vault.db` sur la machine de revue, pas de
CLI sqlite3. À trancher par `SELECT username, kdf_iterations, hash_algo FROM users;`
— si tout est à 600k, le bug n'a jamais mordu, mais le chemin reste **armé** et
auto-déclenché par signal serveur.
#### Correctif recommandé — extraire un helper partagé
Recopier la boucle de `doChangeMasterPassword` dans `runKdfMigration` serait
reproduire la cause (duplication, §3.3). La boucle
[app.js:6115-6179](js/app.js:6115) est **déjà exactement** la logique voulue :
l'extraire et l'appeler des deux côtés.
```js
// js/app.js — nouveau helper, unique source de vérité du re-chiffrement.
// Prend la clé courante (oldKey) et la cible (newKey), retourne le payload
// serveur. N'écrit RIEN dans state : l'appelant décide quand basculer.
async function reEncryptEntriesForNewKey(oldKey, newKey, onProgress) {
const out = [];
let done = 0;
for (const e of state.entries) {
done++;
if (onProgress) onProgress(done, state.entries.length);
state.cryptoKey = oldKey;
const plain = await decryptPwd(e.encrypted_password, e.iv);
if (plain === '[ERROR]') throw new Error('Could not decrypt entry id=' + e.id);
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
let totpEnc = '', totpIv = '';
if (e.totp_secret && e.totp_iv) {
state.cryptoKey = oldKey;
const plainTotp = await decryptTotpSecret(e.totp_secret, e.totp_iv);
state.cryptoKey = newKey;
if (plainTotp !== '[ERROR]') {
const t = await encryptPwd(plainTotp);
totpEnc = t.encrypted; totpIv = t.iv;
}
}
let cfEnc = '', cfIv = '';
if (e.custom_fields && e.custom_fields_iv) {
state.cryptoKey = oldKey;
const plainCf = await decryptCustomFields(e.custom_fields, e.custom_fields_iv);
state.cryptoKey = newKey;
if (plainCf.length > 0) {
const c = await encryptCustomFields(plainCf);
cfEnc = c.encrypted; cfIv = c.iv;
}
}
state.cryptoKey = newKey;
const meta = {};
for (const f of ENCRYPTED_META_FIELDS) {
if (e[f]) {
const c = await encryptPwd(e[f]);
meta[f + '_enc'] = c.encrypted;
meta[f + '_iv'] = c.iv;
} else {
meta[f + '_enc'] = '';
meta[f + '_iv'] = '';
}
}
out.push({
id: e.id,
encrypted_password: re.encrypted, iv: re.iv,
totp_secret: totpEnc, totp_iv: totpIv,
custom_fields: cfEnc, custom_fields_iv: cfIv,
...meta,
});
} finally {
state.cryptoKey = oldKey; // restauré tant que le serveur n'a pas confirmé
}
}
return out;
}
```
`doChangeMasterPassword` remplace sa boucle par un appel ; `runKdfMigration`
remplace la sienne :
```js
// js/app.js — dans runKdfMigration, branche `if (kdfChange)`
newKey = await deriveKey(masterPwd, state.salt, toIters);
newCiphertexts = await reEncryptEntriesForNewKey(state.cryptoKey, newKey);
```
⚠️ **Les attachments restent à traiter** : `doChangeMasterPassword` les gère dans
une boucle séparée *après* le round-trip serveur (GET blob → décrypt oldKey →
encrypt newKey → `PUT /attachments/:id`). `runKdfMigration` ne l'a pas du tout.
Même extraction à faire (`reEncryptAttachmentsForNewKey(oldKey, newKey)`), appelée
des deux côtés après le commit.
#### Côté Delphi — aligner l'UPDATE sur celui de la rotation
```pascal
// PM.Handler.Auth.pas — HandleMigrateKdf, remplace l'UPDATE de la ligne ~795.
// Colonnes identiques à HandleChangeMasterPassword (ligne ~1039) : c'est le
// même événement (la clé change), donc le même jeu de blobs doit voyager.
LQ.SQL.Text :=
'UPDATE vault_entries SET ' +
' encrypted_password = :ep, iv = :iv, ' +
' totp_secret = :ts, totp_iv = :tiv, ' +
' custom_fields = :cf, custom_fields_iv = :cfiv, ' +
' username_enc = :uenc, username_iv = :uiv, ' +
' site_enc = :senc, site_iv = :siv, ' +
' title_enc = :tenc, title_iv = :tiv2, ' +
' tags_enc = :genc, tags_iv = :giv, ' +
' template_enc = :tplenc, template_iv = :tpliv, ' +
' updated_at = CURRENT_TIMESTAMP ' +
'WHERE id = :id AND user_id = :uid';
```
…avec la même lecture de champs + binding que
[Auth.pas:1051-1077](delphi-backend/Handlers/PM.Handler.Auth.pas:1051).
#### Alternative stratégique (plus propre, plus de risque)
Un « migrate-kdf » **est** une rotation de master pw vers le même mot de passe.
Faire déléguer `runKdfMigration` à `/change-master-password` (nouveau salt +
`argon2id-v2`) supprimerait ce chemin dupliqué *et* ferait basculer les comptes
legacy sur Argon2id d'un coup — ce que §1.2 attend de toute façon. Contrepartie :
`HandleChangeMasterPassword` fait `DeleteAllUserSessions` + `CreateSession`
([Auth.pas:1161](delphi-backend/Handlers/PM.Handler.Auth.pas:1161)) et le CLAUDE.md
impose le wipe du blob PIN → une migration silencieuse de fond deviendrait
visible. À arbitrer ; le correctif chirurgical ci-dessus est le chemin bas risque.
#### Test de non-régression suggéré
La suite `js/tests/` couvre déjà le round-trip crypto. Ajouter : *entry avec TOTP
+ custom_fields + méta chiffrée → `reEncryptEntriesForNewKey` → tout déchiffre
sous newKey*. C'est le filet qui aurait attrapé ça.
---
### 6c-2. 🟠 Expiry de session écrite en local, lue en UTC (corrige §2.2)
[`CreateSession`](delphi-backend/Source/PM.Session.pas:155) écrit
`FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(Now, 24))` — heure **locale**.
C'est le dernier `FormatDateTime(..., Now)` du backend et il contredit la règle
du CLAUDE.md (« Timestamps = UTC partout … JAMAIS `FormatDateTime(..., Now)` »).
`NowUTCStr` existe et est utilisé partout ailleurs.
Trois lecteurs, deux bases de temps :
| Lecteur | Base | Cohérent ? |
|---|---|---|
| [`Authenticate`](delphi-backend/Source/PM.Session.pas:89) | `Now` (local) | oui |
| [`RequireCSRF`](delphi-backend/Source/PM.Session.pas:122) | `datetime('now')` (UTC) | **non** |
| [`CleanupExpired`](delphi-backend/Source/PM.Database.pas:469) | `datetime('now')` (UTC) | **non** |
**Pourquoi ça ne s'est jamais vu** : en UTC+1/+2 le décalage va dans le sens
inoffensif — `Authenticate` coupe à 24 h avant que la fenêtre CSRF (26 h) ne
serve. En **UTC-5** la fenêtre CSRF tombe à **19 h** : entre 19 h et 24 h la
session s'authentifie normalement, les GET passent, et **toute écriture répond
403 « Invalid CSRF token »**. Scénario plausible : « Start with Windows » + quick
unlock, machine laissée allumée plus d'une journée.
```pascal
// PM.Session.pas — CreateSession (~ligne 155)
// UTC comme partout ailleurs : RequireCSRF et CleanupExpired comparent contre
// SQLite datetime('now') qui EST UTC.
LExpires := FormatDateTime('yyyy-mm-dd hh:nn:ss', IncHour(NowUTC, 24));
```
```pascal
// PM.Session.pas — Authenticate (~ligne 89)
// Doit suivre : expires_at est désormais UTC.
if (LExpires <> 0) and (LExpires < NowUTC) then
```
`PM.Database` (qui expose `NowUTC`) est déjà dans le `uses` de l'interface,
`System.DateUtils` (pour `IncHour`) déjà dans celui de l'implémentation → aucun
`uses` à toucher. Les trois lecteurs s'alignent alors sur UTC.
**Note migration** : les sessions déjà en base gardent leur `expires_at` local.
Sans importance — elles expirent sous 24 h, et `CleanupExpired` les purge au
prochain démarrage.
---
### 6c-3. 🟡 Le CSRF est validé contre la mauvaise session
[`RequireCSRF`](delphi-backend/Source/PM.Session.pas:120) cherche le
`csrf_token` par `user_id ORDER BY created_at DESC LIMIT 1` — la session la plus
récente, **pas celle réellement présentée**. Deux conséquences :
- `created_at` a une résolution d'**une seconde** : deux sessions créées dans la
même seconde donnent une égalité que SQLite tranche arbitrairement → il peut
lire le token de l'ancienne → **403 fantôme**.
- Conceptuellement, le contrôle devient « connais-tu *un* CSRF valide pour cet
utilisateur » au lieu de « celui lié à ta session ».
`Authenticate` a **déjà** localisé la bonne ligne par `token_hash` : lire
`csrf_token` depuis cette ligne-là est plus simple *et* correct. La signature ne
change pas → aucun call-site à toucher.
```pascal
// PM.Session.pas — RequireCSRF
var
LSubmitted, LStored, LTokenHash: string;
LQ: TFDQuery;
begin
if SameText(ARequest.Command, 'GET') then Exit;
LSubmitted := ARequest.RawHeaders.Values['X-CSRF-Token'];
if LSubmitted = '' then
begin
TJSONHelper.SendError(AResponse, 403, 'Missing CSRF token');
raise ESessionRejected.Create('missing csrf');
end;
// Lier le CSRF à la session PRÉSENTÉE, pas à la plus récente du user.
// AUserId reste en filtre (defense in depth).
LTokenHash := SHA256Hex(ExtractBearerToken(ARequest));
DB.Lock;
try
LQ := TFDQuery.Create(nil);
try
LQ.Connection := DB.Connection;
LQ.SQL.Text :=
'SELECT csrf_token FROM sessions ' +
'WHERE token_hash = :th AND user_id = :uid';
LQ.ParamByName('th').AsString := LTokenHash;
LQ.ParamByName('uid').AsInteger := AUserId;
LQ.Open;
// ... suite inchangée
```
L'expiry n'a plus à être re-testée ici : `Authenticate` tourne toujours avant et
l'a déjà fait (et après **6c-2** les deux sont sur la même base de temps).
---
### 6c-4. 🟡 `/migrate-kdf` rétrograderait un compte `-v2` (verrouillage définitif)
Le garde d'idempotence ([Auth.pas:722](delphi-backend/Handlers/PM.Handler.Auth.pas:722))
ne court-circuite que si l'algo vaut `pbkdf2-sha256`. Un compte `argon2id-v2` ou
`pbkdf2-sha256-v2` **passe au travers** et se fait réécrire en
`HASH_ALGO_CURRENT` ([Auth.pas:763](delphi-backend/Handlers/PM.Handler.Auth.pas:763)
et [785](delphi-backend/Handlers/PM.Handler.Auth.pas:785)) → la formule de
verifier change → **lockout définitif**.
Non atteignable aujourd'hui : le register envoie `kdfIterations: 600000`
([app.js:1296](js/app.js:1296)) donc le login ne signale jamais de migration pour
un compte v2 — et le commentaire [app.js:1016](js/app.js:1016) montre que le cas a
été raisonné. Mais l'endpoint est authentifié, CSRF'é et **armé** ; le garde coûte
4 lignes et rend la protection locale au lieu de dépendre de l'appelant.
```pascal
// PM.Handler.Auth.pas — HandleMigrateKdf, juste avant le garde d'idempotence
// existant (~ligne 722).
// Les schemes -v2 ont un verifier découplé : migrate-kdf ne sait pas les
// réécrire sans changer la formule → refuser au lieu de bricker le compte.
if LAlgo.EndsWith('-v2', True) then
begin
TJSONHelper.SendOK(AResponse, 'Already at target');
Exit;
end;
```
---
### 6c-5. 🔵 Fallback silencieux sur params Argon2 invalides
[Auth.pas:319](delphi-backend/Handlers/PM.Handler.Auth.pas:319) (register) et
[Auth.pas:994](delphi-backend/Handlers/PM.Handler.Auth.pas:994) (rotation) :
```pascal
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then ... else
LRegAlgo := HASH_ALGO_DECOUPLED; // ← fallback muet
```
Un client qui déclare `argon2id-v2` avec des params hors bornes est stocké en
**PBKDF2** sans un mot, alors qu'il a dérivé sa clé en **Argon2id** → au login
suivant `/login/challenge` renvoie `pbkdf2-sha256-v2`, le client dérive en PBKDF2,
verifier différent → **compte brické**. Le JS envoie toujours
`ARGON2_DEFAULT_PARAMS` (valides) donc ce n'est pas live ; échouer fort coûte
5 lignes.
```pascal
// Aux DEUX endroits : un client qui DEMANDE Argon2id avec des params hors
// bornes doit être refusé, pas silencieusement rétrogradé en PBKDF2.
if SameText(LReqAlgo, HASH_ALGO_ARGON2) and (not LArgon.Valid) then
begin
TJSONHelper.SendError(AResponse, 400, 'Invalid argon2 params');
Exit;
end;
```
---
### 6c-6. 🔵 Le port « aléatoire » dépend d'un `Randomize` qui n'est pas le nôtre
[PM.HTTPServer.pas:136](delphi-backend/Source/PM.HTTPServer.pas:136) utilise
`Random()`, et le projet n'appelle **jamais** `Randomize` (vérifié : 0 occurrence
dans tout le `.dpr` + `.pas`). `RandSeed` vaut 0 par défaut → séquence
déterministe.
Empiriquement le port **varie** bien (le CLAUDE.md documente le wipe de
`localStorage` à chaque lancement, ce qui le prouve) → une unité RTL/Indy sème
`RandSeed` en initialisation. Ça marche donc **par accident** : dépendance
implicite, sur ce que le design traite comme une mesure de sécurité, alors que
`RandomBytes` (CNG) est déjà utilisé **6 lignes plus haut** pour le token.
```pascal
// PM.HTTPServer.pas — Start (~ligne 136)
// CSPRNG (CNG) plutôt que Random() : ne dépend pas d'un Randomize appelé par
// une unité tierce. 49152..65535 = 16384 valeurs, et 65536 mod 16384 = 0
// → pas de biais modulo.
if LRequestedPort = 0 then
begin
var LRnd := PM.Crypto.RandomBytes(2);
LRequestedPort := EphemeralPortMin +
(((LRnd[0] shl 8) or LRnd[1]) mod (EphemeralPortMax - EphemeralPortMin + 1));
end;
```
---
### 6c-7. 🔵 Comparaison du token d'accès : sous-chaîne + non constant-time
[PM.HTTPServer.pas:258](delphi-backend/Source/PM.HTTPServer.pas:258) :
```pascal
if (CookieHeader <> '') and (Pos(CookieName + '=' + FAccessToken, CookieHeader) > 0)
```
`Pos` matche **n'importe où** dans le header et accepte les **préfixes** :
`pm_token=<token>junk` passe, et un cookie nommé `xpm_token=<token>` aussi. Ni ça
ni le `=` de la ligne 262 ne sont constant-time, alors que `ConstantTimeEquals`
existe et sert déjà pour le CSRF. Impact faible derrière le PID check (il faut
déjà connaître le token), mais c'est une incohérence gratuite.
```pascal
// PM.HTTPServer.pas — parse réel du header Cookie plutôt qu'un Pos() sur la
// chaîne brute, + comparaison constant-time (comme RequireCSRF).
function TryGetCookie(const AHeader, AName: string; out AValue: string): Boolean;
var
LPairs: TArray<string>;
LPair, LName: string;
LEq: Integer;
begin
Result := False;
AValue := '';
LPairs := AHeader.Split([';']);
for LPair in LPairs do
begin
LEq := Pos('=', LPair);
if LEq <= 0 then Continue;
LName := Trim(Copy(LPair, 1, LEq - 1));
if SameText(LName, AName) then
begin
AValue := Trim(Copy(LPair, LEq + 1, MaxInt));
Exit(True);
end;
end;
end;
```
```pascal
// ValidateAccessToken — remplace les deux comparaisons
if TryGetCookie(CookieHeader, CookieName, LCookieVal) and
ConstantTimeEquals(LCookieVal, FAccessToken) then
Exit(True);
QueryToken := ARequest.Params.Values[QueryParamName];
if (QueryToken <> '') and ConstantTimeEquals(QueryToken, FAccessToken) then
begin
// ... Set-Cookie inchangé
```
(`PM.Crypto` est déjà dans le `uses` de `PM.HTTPServer` → rien à ajouter.)
---
### 6c-8. 🔵 Fuite mémoire sur chemin d'erreur
[Attachments.pas:292](delphi-backend/Handlers/PM.Handler.Attachments.pas:292) :
`LArr := TJSONArray.Create` est créé **avant** le `try/finally` → une exception
dans le bloc DB le fuit. `SendJSON` ne le libère que sur le chemin nominal.
```pascal
// PM.Handler.Attachments.pas — HandleListAllAttachments
LArr := TJSONArray.Create;
try
DB.Lock;
try
// ... remplissage inchangé
finally
DB.Unlock;
end;
except
LArr.Free;
raise;
end;
TJSONHelper.SendJSON(AResponse, LArr); // libère LArr (AOwnsObj=True)
```
---
### 6c-9. ❓ `PRAGMA foreign_keys` — à vérifier, non tranché
`PRAGMA foreign_keys` n'est défini **nulle part**. Le schéma s'appuie pourtant sur
`ON DELETE CASCADE` : [Entries.pas:704](delphi-backend/Handlers/PM.Handler.Entries.pas:704)
(delete permanent) ne nettoie **ni** `entry_attachments` **ni**
`entries_password_history` à la main, et le CLAUDE.md affirme « FK cascade on user
+ entry delete ».
SQLite met `foreign_keys` à **OFF** par défaut. Le paramètre `ForeignKeys` de
FireDAC est *censé* défauter à `On` (ce qui sauverait la mise), mais **je n'ai pas
pu le confirmer** : les DCU ne sont pas greppables et il n'y avait pas de base pour
tester. Si c'était Off, chaque suppression définitive laisserait des blobs chiffrés
orphelins — exactement ce que `CompactIfBloated` tente de rattraper.
Deux façons de lever le doute :
```sql
-- Sur un vault.db réel, après avoir supprimé définitivement une entry
-- qui avait des attachments :
SELECT COUNT(*) FROM entry_attachments a
WHERE NOT EXISTS (SELECT 1 FROM vault_entries e WHERE e.id = a.entry_id);
-- > 0 → la cascade ne s'applique pas.
```
```pascal
// PM.Database.pas — TPMDatabase.Create, avec les autres Params (~ligne 82).
// Explicite = plus de dépendance à un défaut FireDAC non documenté chez nous.
FConn.Params.Values['ForeignKeys'] := 'On';
```
Le second est correct **dans les deux cas** (no-op si c'était déjà le défaut) →
à appliquer sans attendre la vérification.
---
### Ordre d'application suggéré
| # | Sévérité | Effort | Note |
|---|---|---|---|
| **6c-1** | 🔴 | Élevé | Le seul à pouvoir détruire des données. Faire l'extraction + les tests. |
| **6c-2** | 🟠 | Trivial | 2 lignes. Ferme aussi le trou de §2.2. |
| **6c-3** | 🟡 | Faible | Signature inchangée, aucun call-site touché. |
| **6c-9** | ❓ | Trivial | 1 ligne, correcte dans tous les cas. |
| **6c-4** | 🟡 | Trivial | 4 lignes, garde local. |
| **6c-5** | 🔵 | Trivial | 5 lignes × 2 endroits. |
| **6c-6/7/8** | 🔵 | Faible | Cohérence / hygiène, aucune urgence. |
Rappel build : tout edit JS impose `delphi-backend\assets\BuildAssets.cmd` **puis**
le build Delphi, sinon l'exe garde les anciens assets.
---
## 7. Verdict
Pour un usage **personnel, local, mono-utilisateur**, le produit est
@@ -425,3 +947,14 @@ verifier découplé (§1.1), Argon2id (§1.2), métadonnées chiffrées au repos
navigateur, Windows Hello, import KeePass, i18n). Le produit est passé de
« sûr pour du solo loopback » à « durci pour un modèle de menace DB/sync qui
fuit ».
**MAJ 2026-07-16** : la revue backend Delphi (**§6c**) ne remet pas en cause ce
verdict sur le **durcissement** — aucune injection, autorisation scopée partout,
routes non authentifiées correctes, crypto saine. Elle sort en revanche un
**🔴 de perte de données** resté invisible parce qu'il ne vit sur aucun des axes
suivis : `/migrate-kdf` change la clé AES mais ne re-chiffre qu'un blob sur huit
(**§6c-1**) — nouvelle instance de la classe §3.3, sur un call-site que la
checklist « Entry payload » du CLAUDE.md ne liste pas. Elle corrige aussi une
affirmation de **§2.2** (« Tout est désormais UTC ») : `PM.Session` avait été
raté par la substitution (**§6c-2**). À traiter avant de considérer §2.2 comme
clos.
+6 -123
View File
@@ -941,122 +941,6 @@ async function hibpCheckAllEntries() {
render();
}
// ============================================================
// KDF MIGRATION (PBKDF2 100k → 600k re-encryption)
// ============================================================
//
// When the server signals kdfMigration in /login or /reauth, we transparently
// re-encrypt every entry with a stronger key (600k PBKDF2 iterations) and
// commit the new ciphertext + the new server-side hash in one atomic
// /migrate-kdf request. If anything fails, the user stays on the legacy
// config and the migration retries at next login. The entries currently
// loaded in state.entries are encrypted with the OLD key (state.cryptoKey).
//
// Threading: runs in the background after enterApp completes. Locking the
// vault during migration is safe — we just lose the in-flight transition
// and the server's atomic rollback means nothing persisted.
let kdfMigrationInProgress = false;
async function runKdfMigration(masterPwd, fromIters, toIters) {
if (kdfMigrationInProgress) return; // dedupe concurrent calls
if (!state.entries || !state.cryptoKey) return;
kdfMigrationInProgress = true;
try {
// Two distinct migration scenarios:
// A. fromIters !== toIters: KDF iteration count is changing, so
// the AES key is changing. We re-encrypt every entry with the
// new key + fresh IVs, swap state.cryptoKey at the end.
// B. fromIters === toIters: same KDF, only the server-side hash
// format is being upgraded (legacy "pbkdf2" raw → "pbkdf2-sha256"
// wrapped). No entry re-encryption needed — just trigger the
// endpoint so the server rewrites the user row.
const kdfChange = fromIters !== toIters;
let newKey, newCiphertexts;
if (kdfChange) {
newKey = await deriveKey(masterPwd, state.salt, toIters);
// Re-encrypt every entry. Each entry gets a fresh random IV
// under the new key — never reuse the old IV with the new key
// (would be pointless but also a small information leak via IV
// reuse patterns).
newCiphertexts = [];
for (const entry of state.entries) {
const plain = await decryptPwd(entry.encrypted_password, entry.iv);
if (plain === '[ERROR]') {
// One decrypt failure aborts the whole migration —
// better to stay on the legacy config than commit
// partial state.
throw new Error('Could not decrypt entry id=' + entry.id);
}
const tmpKey = state.cryptoKey;
try {
state.cryptoKey = newKey;
const re = await encryptPwd(plain);
newCiphertexts.push({
id: entry.id,
encrypted_password: re.encrypted,
iv: re.iv,
});
} finally {
state.cryptoKey = tmpKey; // restore for any concurrent read
}
}
} else {
// Hash-format-only upgrade — server still wants an entries
// array (it's an idempotent transactional update), just empty.
newCiphertexts = [];
}
// Zero-knowledge: compute BOTH verifiers locally. oldVerifier proves
// the user knows the master pw under the current (legacy) iters;
// newVerifier is what the server will SHA-256-wrap to be the new
// stored hash after migration. Master pw never leaves the browser.
// Only non-v2 accounts ever reach the KDF migration (v2 accounts are
// 600k + decoupled → never signalled). Under a non-v2 algo the
// verifier is the key hex, so both derivations round-trip exactly as
// before; passing state.hashAlgo keeps it explicit.
const oldVerifier = await computeVerifier(masterPwd, state.salt, fromIters, state.hashAlgo, state.argon2Params);
const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo, state.argon2Params);
await api('/migrate-kdf', {
method: 'POST',
headers: authHeaders({ 'Content-Type': 'application/json' }),
body: JSON.stringify({
oldVerifier: oldVerifier,
newVerifier: newVerifier,
entries: newCiphertexts,
}),
});
if (kdfChange) {
// Swap to the new AES key + update cached ciphertexts.
state.cryptoKey = newKey;
state.kdfIterations = toIters;
sessionStorage.setItem('kdfIterations', String(toIters));
await persistCryptoKey();
for (let i = 0; i < state.entries.length; i++) {
const nc = newCiphertexts[i];
state.entries[i].encrypted_password = nc.encrypted_password;
state.entries[i].iv = nc.iv;
}
toast('Vault security upgraded (' + fromIters.toLocaleString() +
' → ' + toIters.toLocaleString() + ' KDF iterations)');
} else {
// Format-only upgrade is silent — the user didn't perceive a
// weakness change, and nothing visible in the UI changed.
// (A subtle "Auth format upgraded" toast felt noisy.)
}
} catch (err) {
// Silent retry on next login — the migration is idempotent and
// safe to abandon (server rolled back).
console.warn('KDF migration aborted:', err);
} finally {
kdfMigrationInProgress = false;
}
}
// ============================================================
// ACCOUNT LOCKOUT UI
// ============================================================
@@ -1242,10 +1126,11 @@ async function doLogin(e) {
await persistCryptoKey();
toast('Welcome back, ' + u);
await enterApp();
// Trigger KDF migration AFTER entries are loaded into state.
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
// KDF auto-migration (100k→600k) removed 2026-07-16: it changed the AES
// key but only re-encrypted encrypted_password, corrupting the 7 other
// per-entry blobs + attachments (§6c-1, a data-loss footgun). Legacy
// <600k accounts upgrade safely via Change Master Password instead
// (that path re-encrypts everything and moves to Argon2id).
} catch (err) {
// 429 with retry_after = account lockout. Show countdown in the
// auth hint instead of a generic error toast, and keep the login
@@ -1440,9 +1325,7 @@ async function doUnlock(p) {
$('#authHint').textContent = '';
toast('Unlocked');
await enterApp();
if (r.kdfMigration && r.kdfMigration.target) {
runKdfMigration(p, r.kdfIterations, r.kdfMigration.target);
}
// KDF auto-migration removed — see the note in the login path (§6c-1).
return true;
} catch (err) {
// Account lockout (too many wrong master pw attempts): show