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
+42 -13
View File
@@ -714,46 +714,75 @@ function bytesToHex(arr) {
return hex;
}
// Decoupled-verifier scheme marker + domain separator. When the account's
// hash_algo is HASH_ALGO_V2, the verifier sent to the server is a one-way
// Decoupled-verifier scheme markers + domain separator. When the account's
// hash_algo ends in '-v2', the verifier sent to the server is a one-way
// SHA-256 of the key hex (domain-separated), NOT the key hex itself — so
// intercepting the /login body no longer hands over the AES vault key.
// The AES key (cryptoKey) is ALWAYS the raw PBKDF2 output regardless, so
// The AES key (cryptoKey) is ALWAYS the raw KDF output regardless of algo, so
// entries stay decryptable and legacy accounts are unaffected.
const HASH_ALGO_V2 = 'pbkdf2-sha256-v2';
const HASH_ALGO_V2 = 'pbkdf2-sha256-v2'; // PBKDF2 KDF + decoupled verifier
const HASH_ALGO_ARGON2 = 'argon2id-v2'; // Argon2id KDF + decoupled verifier
const AUTH_VERIFIER_DOMAIN = 'pmserver/auth-verifier/v2';
// OWASP-recommended Argon2id baseline (m = 19 MiB, t = 2, p = 1). Stored
// per-account (like kdfIterations for PBKDF2) so it's tunable later without
// breaking existing accounts. dkLen is fixed at 32 (AES-256 key).
const ARGON2_DEFAULT_PARAMS = { m: 19456, t: 2, p: 1 };
// True for any scheme whose transmitted verifier is decoupled from the key
// (all '-v2' markers: pbkdf2-sha256-v2, argon2id-v2). endsWith keeps it
// future-proof for any later '-v2' KDF.
function isDecoupledVerifierAlgo(algo) {
return typeof algo === 'string' && algo.endsWith('-v2');
}
async function sha256Hex(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return bytesToHex(new Uint8Array(buf));
}
// Map the raw PBKDF2 key hex → the verifier to transmit, per account algo.
// v2 → domain-separated SHA-256 (decoupled from the key). Anything else →
// the key hex verbatim (legacy behaviour, unchanged for existing accounts).
// Map the raw KDF key hex → the verifier to transmit, per account algo.
// Decoupled ('-v2') → domain-separated SHA-256. Anything else → the key hex
// verbatim (legacy behaviour, unchanged for existing pre-v2 accounts).
async function verifierFromKeyHex(keyHex, algo) {
if (algo === HASH_ALGO_V2) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN);
if (isDecoupledVerifierAlgo(algo)) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN);
return keyHex;
}
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo) {
iterations = iterations || 100000;
// Derive the 32 raw key bytes from the master password, per account KDF.
// Argon2id (memory-hard) for argon2id-* accounts, else PBKDF2-SHA256. Both
// feed the salt HEX STRING's UTF-8 bytes as the salt (historical quirk kept
// identical across KDFs so a given pw+salt maps to one deterministic key).
async function deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams) {
const enc = new TextEncoder();
if (algo === HASH_ALGO_ARGON2) {
if (typeof NobleArgon2 === 'undefined' || !NobleArgon2 || !NobleArgon2.argon2id)
throw new Error('Argon2 library not loaded (js/argon2.js missing?)');
const p = argonParams || ARGON2_DEFAULT_PARAMS;
return NobleArgon2.argon2id(enc.encode(pwd), enc.encode(saltHex),
{ t: p.t, m: p.m, p: p.p, dkLen: 32, version: 0x13 });
}
// PBKDF2-SHA256 (default / legacy).
iterations = iterations || 100000;
const km = await crypto.subtle.importKey(
'raw', enc.encode(pwd), 'PBKDF2', false, ['deriveBits']);
const bits = await crypto.subtle.deriveBits(
{ name: 'PBKDF2', salt: enc.encode(saltHex),
iterations: iterations, hash: 'SHA-256' },
km, 256); // 256 bits = 32 bytes — matches PBKDF2_SHA256_Hex output
const keyBytes = new Uint8Array(bits);
return new Uint8Array(bits);
}
async function deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams) {
const keyBytes = await deriveKeyBytes(pwd, saltHex, algo, iterations, argonParams);
const cryptoKey = await crypto.subtle.importKey(
'raw', keyBytes, { name: 'AES-GCM' }, true, ['encrypt', 'decrypt']);
const verifier = await verifierFromKeyHex(bytesToHex(keyBytes), algo);
return { cryptoKey, verifier };
}
async function computeVerifier(pwd, saltHex, iterations, algo) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo);
async function computeVerifier(pwd, saltHex, iterations, algo, argonParams) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams);
return r.verifier;
}