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
+42 -15
View File
@@ -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