feat(crypto): Argon2id KDF foundation (vendored, not yet adopted)

Phase 1 of CODE_AUDIT §1.2 — additive, no live account uses Argon2id yet.

- Vendor @noble/hashes@2.2.0 argon2id as js/argon2.js (esbuild IIFE exposing
  globalThis.NobleArgon2). Pure-JS, not WASM: CSP is script-src 'self' with no
  wasm-unsafe-eval, so WASM would require weakening it. Verified against the
  RFC 9106 §5.3 test vector. Server needs zero Argon2 (zero-knowledge: it only
  ever SHA256-wraps the client verifier).
- app.js: deriveKeyBytes(pwd, salt, algo, iters, argonParams) branches Argon2id
  vs PBKDF2; deriveKeyAndVerifier refactored around it. New markers
  HASH_ALGO_ARGON2='argon2id-v2' + ARGON2_DEFAULT_PARAMS (OWASP m=19MiB,t=2,p=1,
  ~0.65s/unlock). isDecoupledVerifierAlgo() generalises the decoupled-verifier
  rule to any '-v2' scheme so argon2id-v2 inherits it. AES key is still ALWAYS
  the raw KDF output → entries decryptable, legacy accounts untouched.
- index.html loads js/argon2.js before app.js; added to BuildAssets whitelist;
  test harness loads it into the sandbox first.
- Tests: +5 (RFC 9106 vector via vendored bundle, argon2 branch derives Argon2
  key not PBKDF2, decoupled verifier, AES round-trip under Argon2 key). 40/40.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-07-05 14:13:36 +01:00
parent d9397881dc
commit 2bd0fcfbf8
6 changed files with 1071 additions and 14 deletions
+61
View File
@@ -69,6 +69,67 @@ test('deriveKeyAndVerifier: v2 verifier is decoupled SHA-256(keyHex + domain)',
assert.notEqual(verifier, keyHex, 'v2 verifier must not leak the AES key');
});
// ---- Argon2id KDF (js/argon2.js — vendored @noble/hashes) -------------------
test('vendored argon2id matches the RFC 9106 §5.3 test vector', () => {
// Independent cross-check that the bundled library is correct (not just
// self-consistent). Same vector used to validate the vendored bundle.
const out = T.NobleArgon2.argon2id(
new Uint8Array(32).fill(1), new Uint8Array(16).fill(2),
{ t: 3, m: 32, p: 4, dkLen: 32,
key: new Uint8Array(8).fill(3),
personalization: new Uint8Array(12).fill(4), version: 0x13 });
assert.equal(T.bytesToHex(out),
'0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659');
});
test('isDecoupledVerifierAlgo: all -v2 markers decouple, legacy does not', () => {
assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_V2), true);
assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_ARGON2), true);
assert.equal(T.isDecoupledVerifierAlgo('pbkdf2-sha256'), false);
assert.equal(T.isDecoupledVerifierAlgo('pbkdf2'), false);
assert.equal(T.isDecoupledVerifierAlgo(''), false);
});
test('deriveKeyAndVerifier: argon2id branch derives an Argon2 key, NOT PBKDF2', async () => {
const pwd = 'correct horse battery staple';
const saltHex = 'a1b2c3d4e5f6a1b2';
// The raw key bytes must equal argon2id(pwd, saltHex-utf8, params) — cross
// checked against the vendored lib directly (small params for test speed).
const params = { m: 256, t: 1, p: 1 };
const expectKey = T.bytesToHex(T.NobleArgon2.argon2id(
new TextEncoder().encode(pwd), new TextEncoder().encode(saltHex),
{ t: params.t, m: params.m, p: params.p, dkLen: 32, version: 0x13 }));
const { cryptoKey, verifier } = await T.deriveKeyAndVerifier(
pwd, saltHex, 0, T.HASH_ALGO_ARGON2, params);
const raw = await ctx.crypto.subtle.exportKey('raw', cryptoKey);
assert.equal(T.bytesToHex(new Uint8Array(raw)), expectKey, 'AES key must be the Argon2id output');
// Differs from what PBKDF2 would give for the same pw/salt (different KDF).
assert.notEqual(T.bytesToHex(new Uint8Array(raw)), refKeyHex(pwd, saltHex, 100000));
// argon2id-v2 is a '-v2' scheme → verifier is decoupled SHA-256, not the key.
assert.equal(verifier, refSha256Hex(expectKey + T.AUTH_VERIFIER_DOMAIN));
assert.notEqual(verifier, expectKey, 'argon2id-v2 verifier must not leak the key');
});
test('deriveKeyAndVerifier: argon2id uses ARGON2_DEFAULT_PARAMS when none passed', async () => {
// OWASP baseline is the default (m=19456, t=2, p=1). Just assert the
// default object is what we expect; deriving at 19 MiB is left to one
// explicit round-trip below to keep the suite fast.
assert.deepEqual({ ...T.ARGON2_DEFAULT_PARAMS }, { m: 19456, t: 2, p: 1 });
});
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 });
T.state.cryptoKey = cryptoKey;
const { encrypted, iv } = await T.encryptPwd('argon-secret 🔐');
assert.equal(await T.decryptPwd(encrypted, iv), 'argon-secret 🔐');
});
test('verifierFromKeyHex: pure mapping matches deriveKeyAndVerifier', async () => {
const keyHex = 'ab'.repeat(32);
assert.equal(await T.verifierFromKeyHex(keyHex, 'anything-legacy'), keyHex);
+9 -1
View File
@@ -113,6 +113,12 @@ function loadApp(overrides = {}) {
vm.createContext(sandbox);
// Load the vendored Argon2 bundle first (index.html loads it before
// app.js). It assigns globalThis.NobleArgon2 — needed by the argon2id
// KDF branch in deriveKeyBytes.
const ARGON2_JS = path.join(__dirname, '..', 'argon2.js');
vm.runInContext(fs.readFileSync(ARGON2_JS, 'utf8'), sandbox, { filename: 'argon2.js' });
let src = fs.readFileSync(APP_JS, 'utf8');
// Export epilogue — surface the lexical (const) symbols we test, plus a
@@ -124,8 +130,10 @@ function loadApp(overrides = {}) {
// crypto
bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined),
verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier,
deriveKeyBytes, isDecoupledVerifierAlgo,
encryptPwd, decryptPwd, sha256Hex,
HASH_ALGO_V2, AUTH_VERIFIER_DOMAIN,
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
// csv
parseCSV, findColumn, parseEntriesFromCSV,
// strength