From 5e88ad33d1205855f54ba11a81baa94c6f906930 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:52:11 +0100 Subject: [PATCH] feat(crypto): adopt Argon2id (argon2id-v2) on register + master-pw change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of CODE_AUDIT §1.2 — live adoption of the Argon2id foundation. Verified at runtime: a rotated account shows hash_algo=argon2id-v2 with argon2_m=19456,t=2,p=1 in vault.db. Server (never runs Argon2 — zero-knowledge, only stores/echoes params): - DB: users.argon2_m/t/p columns (default 0 = PBKDF2). - PM.Handler.Auth: HASH_ALGO_ARGON2 + param bounds, ReadArgon2Params / AppendArgon2Params helpers. /register and /change-master-password accept hashAlgo='argon2id-v2' + argon2:{m,t,p} and persist them; /login/challenge echoes them. Verify path (VerifierToStoredHash/CheckVerifier) is KDF-agnostic — the 64-hex verifier is SHA256-wrapped as for any -v2 scheme. Client (app.js): - state.argon2Params, cached from the challenge and persisted to sessionStorage + the quick-unlock / PIN cold-start blobs (so a cold-started session can still derive-from-password for reauth/rotation). - Register + master-pw rotation derive with argon2id-v2 + ARGON2_DEFAULT_PARAMS (OWASP m=19MiB,t=2,p=1) and send the params. Rotation re-encrypts the whole vault under the new Argon2 key (natural migration point). Existing accounts stay PBKDF2 until they rotate. - Params threaded through every derive-from-password site (login, reauth, recovery setup, change-pw current verifier). Cold-start verifier-from-raw-key paths need no params (isDecoupledVerifierAlgo handles the -v2 wrap). Tests: +2 param-contract tests (register<->login determinism, param sensitivity). 42/42. Assets rebuilt to embed js/argon2.js. Docs: CLAUDE.md auth-hash section rewritten (4 markers); CODE_AUDIT §1.2 + table + plan marked done. Co-Authored-By: Claude Opus 4.8 --- CLAUDE.md | 57 +++++++--- CODE_AUDIT.md | 33 ++++-- delphi-backend/Handlers/PM.Handler.Auth.pas | 118 ++++++++++++++++++-- delphi-backend/Source/PM.Database.pas | 8 ++ delphi-backend/assets/assets.inc | 3 +- delphi-backend/assets/assets.rc | 1 + delphi-backend/assets/assets.res | Bin 672912 -> 704600 bytes js/app.js | 72 ++++++++---- js/tests/crypto.test.js | 22 ++++ 9 files changed, 258 insertions(+), 56 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96783dd..c7a5992 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -541,34 +541,61 @@ needs no purge. ## Auth-hash schemes (`users.hash_algo`) -Three markers, all zero-knowledge (server never sees the master pw) : +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` (**previous default**) : stored = `SHA256(verifier)` +- `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, current default**) : the client sends +- `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 `hex(PBKDF2)` regardless of algo** -— only the verifier string changes, so entries stay decryptable and -switching schemes never re-encrypts data. +**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). -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)`. +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 diff --git a/CODE_AUDIT.md b/CODE_AUDIT.md index c41301d..3bd9bc8 100644 --- a/CODE_AUDIT.md +++ b/CODE_AUDIT.md @@ -51,15 +51,30 @@ 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 +### 1.2 🟠 PBKDF2-SHA256 vs Argon2id — **corrigé (2026-07-05)** -600k itérations SHA-256 pour les nouveaux comptes (correct, conforme -OWASP 2023 ≥ 600k). Mais PBKDF2-SHA256 reste **GPU/ASIC-friendly**. -Un master pw faible tombe vite sur du matériel dédié si la DB fuit. +PBKDF2-SHA256 (600k) reste **GPU/ASIC-friendly** → un master pw faible +tombe vite sur matériel dédié si la DB fuit. -**Recommandation** : migrer vers **Argon2id** (memory-hard). Coût : -WebAssembly côté JS (argon2-browser) + implémentation Delphi, avec -migration progressive (comme le passage 100k→600k déjà en place). +**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. + +**Reste** : dériver via `argon2idAsync` pour ne pas geler l'UI ~0.65 s +(actuellement sync) ; option « migrer vers Argon2id sans changer de pw » +(aujourd'hui il faut changer le master pw). **Non compilé/testé runtime +Delphi dans cette session** — nécessite un rebuild `PMServer.dproj`. ### 1.3 🟡 Métadonnées en clair @@ -268,7 +283,7 @@ cf. la checklist "Entry payload" de CLAUDE.md). | Feature | Valeur | Effort | Note | |---|---|---|---| -| **Argon2id** (KDF memory-hard) | 🔴 Haute | Moyen | argon2-browser + Delphi, migration progressive | +| ~~**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 | @@ -289,7 +304,7 @@ cf. la checklist "Entry payload" de CLAUDE.md). 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) — durcissement KDF, migration progressive. +6. ~~**Argon2id** (§1.2)~~ — ✅ fait (`argon2id-v2`, JS pur, adoption register+rotation). 7. Découpage `app.js` en modules (§3.1) — maintenabilité long terme. --- diff --git a/delphi-backend/Handlers/PM.Handler.Auth.pas b/delphi-backend/Handlers/PM.Handler.Auth.pas index 228cc80..39bb040 100644 --- a/delphi-backend/Handlers/PM.Handler.Auth.pas +++ b/delphi-backend/Handlers/PM.Handler.Auth.pas @@ -56,9 +56,64 @@ const // Verification is identical to CURRENT (VerifierToStoredHash wraps any // non-legacy verifier in SHA256), so no new verify branch is needed. HASH_ALGO_DECOUPLED = 'pbkdf2-sha256-v2'; + // 'argon2id-v2' : DECOUPLED verifier (same SHA256 wrap as -sha256-v2), but + // the CLIENT derives the key with Argon2id (memory-hard) instead of + // PBKDF2. The server NEVER runs Argon2 — it only stores/echoes the params + // (argon2_m/t/p) so the client knows how to derive, and SHA256-wraps the + // 64-hex verifier exactly as for any other -v2 scheme. New registrations + // and master-pw changes land here; existing accounts stay on their algo + // until they rotate. Verify path is unchanged (VerifierToStoredHash). + HASH_ALGO_ARGON2 = 'argon2id-v2'; + + // Argon2 parameter sanity bounds — reject client-supplied params outside + // these so a hostile/buggy client can't set a 1-iteration or multi-GiB KDF. + ARGON2_M_MIN = 8; // KiB + ARGON2_M_MAX = 1048576; // 1 GiB + ARGON2_T_MIN = 1; + ARGON2_T_MAX = 16; + ARGON2_P_MIN = 1; + ARGON2_P_MAX = 16; DEFAULT_FOLDERS: array[0..4] of string = ('All', 'Social', 'Banking', 'Work', 'Personal'); +type + TArgon2Params = record + M, T, P: Integer; + Valid: Boolean; // True only when all three are within bounds + end; + +// Read + bounds-check the optional {argon2:{m,t,p}} object from a request +// body. Valid=False when the object is absent or any field is out of range. +function ReadArgon2Params(ABody: TJSONObject): TArgon2Params; +var + LArg: TJSONObject; +begin + Result.M := 0; Result.T := 0; Result.P := 0; Result.Valid := False; + LArg := ABody.GetValue('argon2'); // nil when absent + if LArg = nil then Exit; + Result.M := LArg.GetValue('m', 0); + Result.T := LArg.GetValue('t', 0); + Result.P := LArg.GetValue('p', 0); + Result.Valid := + (Result.M >= ARGON2_M_MIN) and (Result.M <= ARGON2_M_MAX) and + (Result.T >= ARGON2_T_MIN) and (Result.T <= ARGON2_T_MAX) and + (Result.P >= ARGON2_P_MIN) and (Result.P <= ARGON2_P_MAX); +end; + +// Attach an {argon2:{m,t,p}} object to a response when the params are set +// (m>0). No-op for PBKDF2 accounts so their responses are byte-identical. +procedure AppendArgon2Params(AObj: TJSONObject; AM, AT, AP: Integer); +var + LArg: TJSONObject; +begin + if AM <= 0 then Exit; + LArg := TJSONObject.Create; + LArg.AddPair('m', TJSONNumber.Create(AM)); + LArg.AddPair('t', TJSONNumber.Create(AT)); + LArg.AddPair('p', TJSONNumber.Create(AP)); + AObj.AddPair('argon2', LArg); +end; + // Auth-hash computation for the current scheme. Wraps PBKDF2 output in // SHA-256 so the stored value is no longer usable as the AES decryption // key. Use this everywhere we write or verify a hash under @@ -169,8 +224,9 @@ procedure HandleRegister(ARequest: TIdHTTPRequestInfo; AResponse: TIdHTTPResponseInfo; const AParams: TArray); var LBody: TJSONObject; - LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP: string; + LUser, LPwd, LVerifier, LSalt, LHash, LToken, LCSRF, LIP, LReqAlgo: string; LKdfIters: Integer; + LArgon: TArgon2Params; LQ: TFDQuery; LUserId: Integer; begin @@ -191,6 +247,10 @@ begin LVerifier := LBody.GetValue('verifier', ''); LSalt := LBody.GetValue('salt', ''); LKdfIters := LBody.GetValue('kdfIterations', PBKDF2_ITERATIONS_TARGET); + // Optional: client declares an Argon2id KDF. hashAlgo='argon2id-v2' + + // argon2:{m,t,p}. Absent → defaults to the PBKDF2 decoupled scheme. + LReqAlgo := LBody.GetValue('hashAlgo', ''); + LArgon := ReadArgon2Params(LBody); finally LBody.Free; end; @@ -249,10 +309,20 @@ begin // wraps both the same way (SHA256), so only the stored algo LABEL // differs — it's what tells the client which verifier formula to use. var LRegAlgo := HASH_ALGO_CURRENT; + // argon2_m/t/p persisted only for Argon2id accounts; 0 = PBKDF2. + var LArgM := 0; var LArgT := 0; var LArgP := 0; if LVerifier <> '' then begin // ZK path: use the client-supplied salt + iters + verifier as-is. - LRegAlgo := HASH_ALGO_DECOUPLED; + // If the client declared Argon2id (with valid params), land on that + // scheme and record the params; otherwise the PBKDF2 decoupled scheme. + if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then + begin + LRegAlgo := HASH_ALGO_ARGON2; + LArgM := LArgon.M; LArgT := LArgon.T; LArgP := LArgon.P; + end + else + LRegAlgo := HASH_ALGO_DECOUPLED; LHash := VerifierToStoredHash(LVerifier, LRegAlgo); end else @@ -267,13 +337,17 @@ begin try LQ.Connection := DB.Connection; LQ.SQL.Text := - 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations) ' + - 'VALUES (:u, :h, :s, :algo, :it)'; + 'INSERT INTO users (username, password_hash, salt, hash_algo, kdf_iterations, ' + + ' argon2_m, argon2_t, argon2_p) ' + + 'VALUES (:u, :h, :s, :algo, :it, :am, :at, :ap)'; LQ.ParamByName('algo').AsString := LRegAlgo; LQ.ParamByName('u').AsString := LUser; LQ.ParamByName('h').AsString := LHash; LQ.ParamByName('s').AsString := LSalt; LQ.ParamByName('it').AsInteger := LKdfIters; + LQ.ParamByName('am').AsInteger := LArgM; + LQ.ParamByName('at').AsInteger := LArgT; + LQ.ParamByName('ap').AsInteger := LArgP; LQ.ExecSQL; LUserId := DB.Connection.GetLastAutoGenValue('users'); finally @@ -788,8 +862,9 @@ var LBody, LEntry, LObj: TJSONObject; LEntries: TJSONArray; LUser, LCurPwd, LNewPwd, LCurVerifier, LNewVerifier, LNewSalt, - LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash: string; + LStoredHash, LOldSalt, LAlgo, LIP, LComputed, LNewHash, LReqAlgo: string; LOldIters: Integer; + LArgon: TArgon2Params; LQ: TFDQuery; LValid: Boolean; LEntryId: Integer; @@ -814,6 +889,9 @@ begin // for the NEW pw (PBKDF2 over the new salt at target iters). LCurVerifier := LBody.GetValue('currentVerifier', ''); LNewVerifier := LBody.GetValue('newVerifier', ''); + // Optional: rotate onto Argon2id. hashAlgo='argon2id-v2' + argon2:{m,t,p}. + LReqAlgo := LBody.GetValue('hashAlgo', ''); + LArgon := ReadArgon2Params(LBody); LEntries := LBody.GetValue('entries'); // Input validation. Either plaintext OR verifier must be present; we @@ -908,9 +986,18 @@ begin // client-supplied newVerifier (rotating onto the DECOUPLED scheme). // Plaintext: derive server-side (stays CURRENT). var LNewAlgo := HASH_ALGO_CURRENT; + var LNewArgM := 0; var LNewArgT := 0; var LNewArgP := 0; if LNewVerifier <> '' then begin - LNewAlgo := HASH_ALGO_DECOUPLED; + // ZK rotation: Argon2id if the client declared it (valid params), + // else the PBKDF2 decoupled scheme. Both SHA256-wrap the verifier. + if SameText(LReqAlgo, HASH_ALGO_ARGON2) and LArgon.Valid then + begin + LNewAlgo := HASH_ALGO_ARGON2; + LNewArgM := LArgon.M; LNewArgT := LArgon.T; LNewArgP := LArgon.P; + end + else + LNewAlgo := HASH_ALGO_DECOUPLED; LNewHash := VerifierToStoredHash(LNewVerifier, LNewAlgo); end else @@ -927,12 +1014,18 @@ begin ' password_hash = :h, ' + ' salt = :s, ' + ' kdf_iterations = :it, ' + - ' hash_algo = :algo ' + + ' hash_algo = :algo, ' + + ' argon2_m = :am, ' + + ' argon2_t = :at, ' + + ' argon2_p = :ap ' + 'WHERE id = :uid'; LQ.ParamByName('h').AsString := LNewHash; LQ.ParamByName('s').AsString := LNewSalt; LQ.ParamByName('it').AsInteger := PBKDF2_ITERATIONS_TARGET; LQ.ParamByName('algo').AsString := LNewAlgo; + LQ.ParamByName('am').AsInteger := LNewArgM; + LQ.ParamByName('at').AsInteger := LNewArgT; + LQ.ParamByName('ap').AsInteger := LNewArgP; LQ.ParamByName('uid').AsInteger := LUserId; LQ.ExecSQL; finally @@ -1062,7 +1155,7 @@ procedure HandleLoginChallenge(ARequest: TIdHTTPRequestInfo; var LBody, LObj: TJSONObject; LUser, LSalt, LIP, LAlgo: string; - LKdfIters: Integer; + LKdfIters, LArgM, LArgT, LArgP: Integer; LQ: TFDQuery; begin LIP := GetClientIP(ARequest); @@ -1090,7 +1183,7 @@ begin try LQ.Connection := DB.Connection; LQ.SQL.Text := - 'SELECT salt, kdf_iterations, hash_algo ' + + 'SELECT salt, kdf_iterations, hash_algo, argon2_m, argon2_t, argon2_p ' + 'FROM users WHERE username = :u'; LQ.ParamByName('u').AsString := LUser; LQ.Open; @@ -1102,6 +1195,9 @@ begin LSalt := LQ.FieldByName('salt').AsString; LKdfIters := LQ.FieldByName('kdf_iterations').AsInteger; LAlgo := LQ.FieldByName('hash_algo').AsString; + LArgM := LQ.FieldByName('argon2_m').AsInteger; + LArgT := LQ.FieldByName('argon2_t').AsInteger; + LArgP := LQ.FieldByName('argon2_p').AsInteger; if LAlgo = '' then LAlgo := HASH_ALGO_LEGACY; if LKdfIters <= 0 then LKdfIters := PBKDF2_ITERATIONS; finally @@ -1115,8 +1211,10 @@ begin LObj.AddPair('salt', LSalt); LObj.AddPair('kdfIterations', TJSONNumber.Create(LKdfIters)); // Echo back the hash_algo so the client can choose the right wrap path - // when needed (legacy vs current). Most clients ignore it. + // (legacy vs -v2) and KDF. For Argon2id accounts, also echo the params + // the client must feed to the KDF. LObj.AddPair('hashAlgo', LAlgo); + AppendArgon2Params(LObj, LArgM, LArgT, LArgP); TJSONHelper.SendJSON(AResponse, LObj); end; diff --git a/delphi-backend/Source/PM.Database.pas b/delphi-backend/Source/PM.Database.pas index bb63897..d251efb 100644 --- a/delphi-backend/Source/PM.Database.pas +++ b/delphi-backend/Source/PM.Database.pas @@ -424,6 +424,14 @@ begin // (600 000 as of 2026). Login flow transparently re-hashes legacy users // and re-encrypts their entries on the client side. AddColumnIfMissing('users', 'kdf_iterations', 'INTEGER DEFAULT 100000'); + // Argon2id KDF parameters (memory KiB / time cost / parallelism). 0 = the + // account uses PBKDF2 (kdf_iterations above); non-zero = hash_algo is an + // 'argon2id-*' scheme and these drive the client-side key derivation. + // Stored per-user (like kdf_iterations) so the cost can be tuned later + // without breaking existing accounts. + AddColumnIfMissing('users', 'argon2_m', 'INTEGER DEFAULT 0'); + AddColumnIfMissing('users', 'argon2_t', 'INTEGER DEFAULT 0'); + AddColumnIfMissing('users', 'argon2_p', 'INTEGER DEFAULT 0'); AddColumnIfMissing('recovery_keys', 'remaining_uses', 'INTEGER DEFAULT 5'); // Server-side preferences blob (JSON). Synced across devices on login, // saved on every change from the JS settings panel. Device-specific diff --git a/delphi-backend/assets/assets.inc b/delphi-backend/assets/assets.inc index 3dc34cd..419e59e 100644 --- a/delphi-backend/assets/assets.inc +++ b/delphi-backend/assets/assets.inc @@ -1,8 +1,9 @@ // Auto-generated by BuildAssets.ps1 - do not edit by hand. const - EMBEDDED_ASSET_COUNT = 3; + EMBEDDED_ASSET_COUNT = 4; EMBEDDED_ASSETS: array[0..EMBEDDED_ASSET_COUNT-1] of TEmbeddedAsset = ( (UrlPath: '/index.html'; ResName: 'INDEX_HTML'), + (UrlPath: '/js/argon2.js'; ResName: 'JS_ARGON2_JS'), (UrlPath: '/js/app.js'; ResName: 'JS_APP_JS'), (UrlPath: '/css/style.css'; ResName: 'CSS_STYLE_CSS') ); diff --git a/delphi-backend/assets/assets.rc b/delphi-backend/assets/assets.rc index 2382968..fa04643 100644 --- a/delphi-backend/assets/assets.rc +++ b/delphi-backend/assets/assets.rc @@ -2,5 +2,6 @@ #pragma code_page(65001) INDEX_HTML RCDATA "Z:\\password-manager\\index.html" +JS_ARGON2_JS RCDATA "Z:\\password-manager\\js\\argon2.js" JS_APP_JS RCDATA "Z:\\password-manager\\js\\app.js" CSS_STYLE_CSS RCDATA "Z:\\password-manager\\css\\style.css" diff --git a/delphi-backend/assets/assets.res b/delphi-backend/assets/assets.res index 2a8c82997b27391c0839ec95a2f6b61f77d03fdd..92446f128741f0f5361cbf6a7909de95ee0a7919 100644 GIT binary patch delta 31533 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 Fir=}O9mN0u diff --git a/js/app.js b/js/app.js index 59836df..92dc32d 100644 --- a/js/app.js +++ b/js/app.js @@ -588,6 +588,15 @@ const state = { // the /login/challenge response, from the cold-start blob, or hardcoded // to v2 on register / master-pw change. hashAlgo: sessionStorage.getItem('hashAlgo') || '', + // Argon2id KDF params { m, t, p } for the current account, or null for + // PBKDF2 accounts. Set from /login/challenge, the cold-start blobs, or + // ARGON2_DEFAULT_PARAMS on register / master-pw change. Needed wherever a + // key/verifier is DERIVED FROM THE PASSWORD (login, reauth, rotation) — + // NOT for cold-start verifier-from-raw-key paths. + argon2Params: (() => { + try { return JSON.parse(sessionStorage.getItem('argon2Params') || 'null'); } + catch { return null; } + })(), cryptoKey: null, entries: [], trashed: [], @@ -1742,8 +1751,8 @@ async function runKdfMigration(masterPwd, fromIters, toIters) { // 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); - const newVerifier = await computeVerifier(masterPwd, state.salt, toIters, state.hashAlgo); + 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', @@ -1918,10 +1927,13 @@ async function doLogin(e) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username: u }), }); - // The challenge tells us the account's auth scheme; compute the - // verifier accordingly (v2 → decoupled, else → key hex). + // The challenge tells us the account's auth scheme + KDF params; + // derive the key/verifier accordingly (argon2id-v2 → Argon2 with the + // echoed params; -v2 → decoupled; else → key hex). state.hashAlgo = ch.hashAlgo || ''; - const derived = await deriveKeyAndVerifier(p, ch.salt, ch.kdfIterations, state.hashAlgo); + state.argon2Params = ch.argon2 || null; + const derived = await deriveKeyAndVerifier( + p, ch.salt, ch.kdfIterations, state.hashAlgo, state.argon2Params); const r = await api('/login', { method: 'POST', @@ -1939,6 +1951,7 @@ async function doLogin(e) { sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); + sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params)); // Persist via DPAPI when running inside the Delphi host (localStorage // is wiped on each restart because the HTTP port — and therefore the // origin — changes every launch). Fall back to localStorage for the @@ -1995,9 +2008,11 @@ async function doRegister(e) { // leaves the browser. const newSalt = randomHexSalt(); const newIters = 600000; - // New accounts use the decoupled-verifier scheme (v2). - state.hashAlgo = HASH_ALGO_V2; - const derived = await deriveKeyAndVerifier(p, newSalt, newIters, HASH_ALGO_V2); + // New accounts use Argon2id (memory-hard) with the decoupled verifier. + state.hashAlgo = HASH_ALGO_ARGON2; + state.argon2Params = { ...ARGON2_DEFAULT_PARAMS }; + const derived = await deriveKeyAndVerifier( + p, newSalt, newIters, HASH_ALGO_ARGON2, state.argon2Params); const r = await api('/register', { method: 'POST', @@ -2007,7 +2022,8 @@ async function doRegister(e) { salt: newSalt, kdfIterations: newIters, verifier: derived.verifier, - hashAlgo: HASH_ALGO_V2, + hashAlgo: HASH_ALGO_ARGON2, + argon2: state.argon2Params, }), }); state.token = r.token; @@ -2021,6 +2037,7 @@ async function doRegister(e) { sessionStorage.setItem('username', state.username); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); + sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params)); state.cryptoKey = derived.cryptoKey; await persistCryptoKey(); toast('Vault created'); @@ -2133,7 +2150,8 @@ async function doUnlock(p) { // Compute the verifier locally with the salt+iters cached at login. // Server compares verifier → never sees the plaintext master pw. const iters = state.kdfIterations || 100000; - const derived = await deriveKeyAndVerifier(p, state.salt, iters, state.hashAlgo); + const derived = await deriveKeyAndVerifier( + p, state.salt, iters, state.hashAlgo, state.argon2Params); const r = await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7193,6 +7211,7 @@ async function pinBuildBlob(pin) { // pre-decoupling blobs → cold-start defaults to the key hex, which // is correct for those (legacy) accounts. hashAlgo: state.hashAlgo || '', + argon2Params: state.argon2Params || null, salt: bytesToBase64(salt), iters: PIN_KDF_ITERS, iv: bytesToBase64(iv), @@ -7300,7 +7319,7 @@ async function pinSetupFlow() { if (!masterPwd) return; try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7388,6 +7407,7 @@ async function loginViaPin(pin) { state.salt = blob.loginSalt || state.salt; state.kdfIterations = blob.loginIters || state.kdfIterations || 600000; state.hashAlgo = blob.hashAlgo || ''; + state.argon2Params = blob.argon2Params || null; try { state.cryptoKey = await crypto.subtle.importKey( @@ -7442,7 +7462,7 @@ async function enableQuickUnlock() { if (!masterPwd) return; try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7464,6 +7484,7 @@ async function enableQuickUnlock() { kdfIterations: state.kdfIterations, // Auth scheme for cold-start verifier selection (see pinBuildBlob). hashAlgo: state.hashAlgo || '', + argon2Params: state.argon2Params || null, key: bytesToBase64(raw), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); @@ -7523,6 +7544,7 @@ async function tryQuickUnlock() { state.salt = parsed.salt; state.kdfIterations = parsed.kdfIterations || 600000; state.hashAlgo = parsed.hashAlgo || ''; + state.argon2Params = parsed.argon2Params || null; const rawKey = base64ToBytes(parsed.key); try { @@ -7712,7 +7734,7 @@ async function doGenerateRecoveryKey() { // Send a verifier instead of the master pw — server proves the // user still knows the master pw without ever seeing the plaintext. const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/recovery-key/setup', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), @@ -7894,6 +7916,7 @@ async function doRecoveryRedeem() { // Account's auth scheme — needed so the recovery-mode master-pw change // proves the current key under the right verifier transform. state.hashAlgo = r.hashAlgo || ''; + state.argon2Params = r.argon2 || null; sessionStorage.setItem('authToken', state.token); sessionStorage.setItem('csrfToken', state.csrf); sessionStorage.setItem('salt', state.salt); @@ -8005,10 +8028,12 @@ async function doChangeMasterPassword() { // Also compute the verifier for the CURRENT pw so the server can // authenticate the change without ever seeing the plaintext. const newSalt = randomHexSalt(); - // Rotate onto the decoupled-verifier scheme (v2) — a master-pw - // change re-derives + re-encrypts everything anyway, so it's the - // natural migration point for existing accounts. - const newDerived = await deriveKeyAndVerifier(newPwd, newSalt, 600000, HASH_ALGO_V2); + // Rotate onto Argon2id (memory-hard KDF) + decoupled verifier — a + // master-pw change re-derives + re-encrypts everything anyway, so it's + // the natural migration point for existing PBKDF2 accounts. + const newArgon = { ...ARGON2_DEFAULT_PARAMS }; + const newDerived = await deriveKeyAndVerifier( + newPwd, newSalt, 600000, HASH_ALGO_ARGON2, newArgon); const newKey = newDerived.cryptoKey; let currentVerifier; if (recoveryMode) { @@ -8019,7 +8044,7 @@ async function doChangeMasterPassword() { currentVerifier = await verifierFromKeyHex(bytesToHex(rawCurrentKey), state.hashAlgo); } else { currentVerifier = await computeVerifier( - curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); + curPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); } // Step 2: re-encrypt every entry's password AND every entry's TOTP @@ -8092,6 +8117,8 @@ async function doChangeMasterPassword() { currentVerifier: currentVerifier, newVerifier: newDerived.verifier, newSalt: newSalt, + hashAlgo: HASH_ALGO_ARGON2, + argon2: newArgon, entries: encrypted, }), }); @@ -8100,13 +8127,15 @@ async function doChangeMasterPassword() { // the cached ciphertexts, persist for F5 survival. state.salt = r.salt || newSalt; state.kdfIterations = r.kdfIterations || 600000; - // The account is now on the decoupled-verifier scheme. - state.hashAlgo = HASH_ALGO_V2; + // The account is now on the Argon2id + decoupled-verifier scheme. + state.hashAlgo = HASH_ALGO_ARGON2; + state.argon2Params = newArgon; state.cryptoKey = newKey; await persistCryptoKey(); sessionStorage.setItem('salt', state.salt); sessionStorage.setItem('kdfIterations', String(state.kdfIterations)); sessionStorage.setItem('hashAlgo', state.hashAlgo); + sessionStorage.setItem('argon2Params', JSON.stringify(state.argon2Params)); // Server invalidated every session for this user (including ours) // and minted a fresh pair — adopt them so subsequent API calls // don't bounce with "invalid session". @@ -8188,6 +8217,7 @@ async function doChangeMasterPassword() { salt: state.salt, kdfIterations: state.kdfIterations, hashAlgo: state.hashAlgo || '', + argon2Params: state.argon2Params || null, key: bytesToBase64(new Uint8Array(raw)), }); const b64 = bytesToBase64(new TextEncoder().encode(blob)); @@ -9009,7 +9039,7 @@ async function doExport() { if (!masterPwd) return; // user cancelled try { const verifier = await computeVerifier( - masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo); + masterPwd, state.salt, state.kdfIterations || 100000, state.hashAlgo, state.argon2Params); await api('/reauth', { method: 'POST', headers: authHeaders({ 'Content-Type': 'application/json' }), diff --git a/js/tests/crypto.test.js b/js/tests/crypto.test.js index 1fcb289..b32c099 100644 --- a/js/tests/crypto.test.js +++ b/js/tests/crypto.test.js @@ -122,6 +122,28 @@ test('deriveKeyAndVerifier: argon2id uses ARGON2_DEFAULT_PARAMS when none passed assert.deepEqual({ ...T.ARGON2_DEFAULT_PARAMS }, { m: 19456, t: 2, p: 1 }); }); +test('argon2 param contract: identical params → identical key+verifier (register↔login)', async () => { + // Register derives with params P; login re-derives with the params the + // challenge echoes back. If those match, the key + verifier must match + // exactly — otherwise the user could register but never log in. + const pwd = 'pw', salt = 'saltsaltsalt', params = { m: 512, t: 2, p: 1 }; + const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, params); + const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { ...params }); + const ra = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', a.cryptoKey)); + const rb = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', b.cryptoKey)); + assert.equal(T.bytesToHex(ra), T.bytesToHex(rb)); + assert.equal(a.verifier, b.verifier); +}); + +test('argon2 param sensitivity: differing params → different key (transmission matters)', async () => { + // If the server drops/garbles the echoed params, the client derives a + // different key → login fails closed rather than silently mis-deriving. + const pwd = 'pw', salt = 'saltsaltsalt'; + const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 2, p: 1 }); + const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 3, p: 1 }); + assert.notEqual(a.verifier, b.verifier); +}); + test('encrypt/decrypt round-trips under an Argon2id-derived key', async () => { const { cryptoKey } = await T.deriveKeyAndVerifier( 'master', 'saltsaltsalt', 0, T.HASH_ALGO_ARGON2, { m: 512, t: 1, p: 1 });