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 2a8c829..92446f1 100644 Binary files a/delphi-backend/assets/assets.res and b/delphi-backend/assets/assets.res differ 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 });