feat(crypto): adopt Argon2id (argon2id-v2) on register + master-pw change

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 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-05 14:52:11 +01:00
parent 2bd0fcfbf8
commit 5e88ad33d1
9 changed files with 258 additions and 56 deletions
+22
View File
@@ -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 });