Files
Password-Manager/js/tests/harness.js
T
r-zakarya 2bd0fcfbf8 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>
2026-07-05 14:13:36 +01:00

151 lines
6.1 KiB
JavaScript

// ============================================================
// Test harness — load js/app.js into a sandboxed VM context
// ============================================================
//
// app.js is a ~12k-line browser monofile with no module exports. It runs
// only one top-level statement (a DOMContentLoaded listener); everything
// else is function/const declarations. We load it in a node:vm context with
// browser globals stubbed out so init() never fires, then reach the internals
// we want to test through an appended export epilogue.
//
// Why the epilogue: in vm.runInContext, top-level `function`/`var` declarations
// attach to the context's global object, but top-level `const`/`let` (like
// `state`, `API`, `HASH_ALGO_V2`) do NOT. So we append a line that copies the
// symbols we care about onto globalThis.__test, giving tests a stable handle.
//
// Reassignable seams for the merge tests: `api`, `loadEntries`, `loadFolders`,
// `encryptImportEntry`, etc. are `function` declarations → global properties,
// so a test can override `ctx.api = fake` and the free-variable lookup inside
// applyRemoteSnapshot will pick up the fake. `state` is a `const` (lexical),
// so it can't be replaced — but it CAN be mutated, and applyRemoteSnapshot
// closes over that same object, so mutating ctx.__test.state is visible to it.
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { webcrypto } = require('node:crypto');
const APP_JS = path.join(__dirname, '..', 'app.js');
// In-memory Storage stub (Web Storage API surface used by app.js).
function makeStorage() {
const m = new Map();
return {
getItem: (k) => (m.has(k) ? m.get(k) : null),
setItem: (k, v) => { m.set(k, String(v)); },
removeItem: (k) => { m.delete(k); },
clear: () => m.clear(),
key: (i) => Array.from(m.keys())[i] ?? null,
get length() { return m.size; },
};
}
// Minimal no-throw DOM/window stubs. app.js only *executes* one DOM call at
// load (document.addEventListener for DOMContentLoaded) — everything else is
// inside functions we don't call. So these just have to exist and not throw.
function makeDomStubs() {
const noop = () => {};
const elStub = new Proxy({}, {
get: (_t, prop) => {
if (prop === 'style') return {};
if (prop === 'classList') return { add: noop, remove: noop, toggle: noop, contains: () => false };
if (prop === 'addEventListener' || prop === 'removeEventListener') return noop;
if (prop === 'appendChild' || prop === 'append' || prop === 'remove') return noop;
if (prop === 'setAttribute' || prop === 'removeAttribute') return noop;
if (prop === 'querySelector') return () => null;
if (prop === 'querySelectorAll') return () => [];
return undefined;
},
set: () => true,
});
const document = {
addEventListener: noop,
removeEventListener: noop,
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
createElement: () => elStub,
body: elStub,
documentElement: elStub,
};
return { document, elStub, noop };
}
// Build a fresh sandbox + load app.js into it. Returns the contextified
// sandbox; test internals live on ctx.__test.
function loadApp(overrides = {}) {
const { document, noop } = makeDomStubs();
const sandbox = {
crypto: webcrypto,
TextEncoder,
TextDecoder,
btoa,
atob,
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
Date,
JSON,
Math,
Promise,
URL,
URLSearchParams,
// Browser-ish globals used at load time
location: { pathname: '/index.html', href: 'http://127.0.0.1/index.html', search: '', hash: '' },
history: { replaceState: noop, pushState: noop },
navigator: { clipboard: { writeText: async () => {}, readText: async () => '' }, userAgent: 'node-test' },
localStorage: makeStorage(),
sessionStorage: makeStorage(),
document,
fetch: async () => { throw new Error('fetch not stubbed'); },
// Some code paths reference matchMedia / requestAnimationFrame
matchMedia: () => ({ matches: false, addEventListener: noop, addListener: noop }),
requestAnimationFrame: (cb) => setTimeout(cb, 0),
...overrides,
};
// window / self / globalThis self-reference (app.js reads window.location etc.)
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
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
// couple of function-decl seams for convenience. Kept in one place so the
// list of "what tests can touch" is explicit.
src += `
;globalThis.__test = {
state,
// crypto
bytesToHex, hexToBytes: (typeof hexToBytes !== 'undefined' ? hexToBytes : undefined),
verifierFromKeyHex, deriveKeyAndVerifier, computeVerifier,
deriveKeyBytes, isDecoupledVerifierAlgo,
encryptPwd, decryptPwd, sha256Hex,
HASH_ALGO_V2, HASH_ALGO_ARGON2, AUTH_VERIFIER_DOMAIN, ARGON2_DEFAULT_PARAMS,
NobleArgon2: (typeof NobleArgon2 !== 'undefined' ? NobleArgon2 : undefined),
// csv
parseCSV, findColumn, parseEntriesFromCSV,
// strength
computeStrength: (typeof computeStrength !== 'undefined' ? computeStrength : undefined),
// merge (async, coupled — tests stub the io seams below)
applyRemoteSnapshot, buildSyncSnapshot,
};
`;
vm.runInContext(src, sandbox, { filename: 'app.js' });
return sandbox;
}
module.exports = { loadApp, makeStorage };