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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user