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
+51 -21
View File
@@ -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' }),
+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 });