Files
r-zakarya dd86b2bd23 perf(crypto): derive Argon2id via argon2idAsync (unfreeze unlock UI)
deriveKeyBytes now calls NobleArgon2.argon2idAsync instead of the sync
argon2id, so it yields to the event loop periodically and the busy/unlock
spinner keeps animating instead of freezing ~0.65 s during login, register,
and master-pw rotation. Same result (both RFC-9106-verified); all callers
already await deriveKeyBytes so no call-site changes.

- Re-vendored js/argon2.js to export argon2idAsync alongside argon2id
  (re-bundled from @noble/hashes@2.2.0; both variants pass the RFC 9106 §5.3
  vector). 27KB → 29KB.
- Added a sync/async parity test. 63/63 green.
- Closes the last open item of CODE_AUDIT §1.2.

NOTE: argon2.js grew — run BuildAssets to re-embed it before the next
Delphi build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 19:41:59 +01:00

173 lines
7.8 KiB
JavaScript

// ============================================================
// app.crypto.js — CRYPTO module (extracted from app.js, §3.1)
// ============================================================
//
// Loaded as a classic <script> BEFORE js/app.js (after js/argon2.js).
// Classic scripts share one global lexical environment, so the consts and
// functions declared here are visible to app.js exactly as when this lived
// inline in the monofile — no import/export, no bundler. Function bodies
// reference `state` (declared in app.js) and `NobleArgon2` (js/argon2.js);
// those resolve at call time (post-DOMContentLoaded), never at load time.
//
// Split rule: this file must load before app.js and must NOT redeclare any
// of app.js's top-level consts (a duplicate `const` across classic scripts
// throws "already declared"). See CLAUDE.md build pipeline notes.
//
// CRYPTO (preserved from legacy app.js — DO NOT TOUCH)
async function deriveKey(pwd, saltHex, iterations) {
// Iterations parameter is the per-user value returned by the server in
// the /login response (legacy users = 100000, modern = 600000). Falling
// back to 100000 keeps backwards compatibility with old code paths that
// didn't pass the value, but every new caller should pass it explicitly.
iterations = iterations || 100000;
const enc = new TextEncoder();
const km = await crypto.subtle.importKey('raw', enc.encode(pwd), 'PBKDF2', false, ['deriveKey']);
// saltHex is the same string that PHP/Delphi passed to PBKDF2 — use its bytes.
const sb = enc.encode(saltHex);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: sb, iterations: iterations, hash: 'SHA-256' },
km,
{ name: 'AES-GCM', length: 256 },
true, ['encrypt', 'decrypt']
);
}
// ---- Zero-knowledge auth helpers --------------------------------
//
// Single PBKDF2 → both outputs at once:
// - cryptoKey: the AES-GCM key used to encrypt entries (= raw PBKDF2 bytes)
// - verifier: the same 32 bytes in hex form, sent to the server in place
// of the plaintext master password. Server then SHA-256-wraps
// it (HASH_ALGO_CURRENT) or compares directly (LEGACY) without
// ever seeing the plaintext.
//
// Doing it together avoids running PBKDF2 twice. computeVerifier() is for
// places that only need the hex (re-auth, current-pw verification on change,
// etc.) and skips the AES-GCM importKey work.
function bytesToHex(arr) {
if (arr instanceof ArrayBuffer) arr = new Uint8Array(arr);
let hex = '';
for (let i = 0; i < arr.length; i++)
hex += arr[i].toString(16).padStart(2, '0');
return hex;
}
// 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 KDF output regardless of algo, so
// entries stay decryptable and legacy accounts are unaffected.
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 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 (isDecoupledVerifierAlgo(algo)) return await sha256Hex(keyHex + AUTH_VERIFIER_DOMAIN);
return keyHex;
}
// 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.argon2idAsync)
throw new Error('Argon2 library not loaded (js/argon2.js missing?)');
const p = argonParams || ARGON2_DEFAULT_PARAMS;
// Async variant yields to the event loop periodically so the unlock
// spinner keeps animating instead of freezing ~0.65 s. Same result as
// the sync argon2id (both RFC-9106-verified).
return await NobleArgon2.argon2idAsync(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
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, argonParams) {
const r = await deriveKeyAndVerifier(pwd, saltHex, iterations, algo, argonParams);
return r.verifier;
}
async function encryptPwd(plain) {
const iv = crypto.getRandomValues(new Uint8Array(12));
const enc = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv }, state.cryptoKey,
new TextEncoder().encode(plain)
);
return {
encrypted: btoa(String.fromCharCode(...new Uint8Array(enc))),
iv: btoa(String.fromCharCode(...iv)),
};
}
async function decryptPwd(encB64, ivB64) {
try {
const enc = Uint8Array.from(atob(encB64), c => c.charCodeAt(0));
const iv = Uint8Array.from(atob(ivB64), c => c.charCodeAt(0));
const dec = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, state.cryptoKey, enc);
return new TextDecoder().decode(dec);
} catch (e) {
return '[ERROR]';
}
}
async function persistCryptoKey() {
const raw = await crypto.subtle.exportKey('raw', state.cryptoKey);
sessionStorage.setItem('cryptoKey', btoa(String.fromCharCode(...new Uint8Array(raw))));
}
async function restoreCryptoKey() {
const saved = sessionStorage.getItem('cryptoKey');
if (!saved) return false;
try {
const raw = Uint8Array.from(atob(saved), c => c.charCodeAt(0));
state.cryptoKey = await crypto.subtle.importKey(
'raw', raw, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']
);
return true;
} catch (e) {
return false;
}
}