// Crypto round-trip + verifier derivation tests. // // These lock down the invariants the CODE_AUDIT flagged as highest-risk for // silent regression: the AES key must ALWAYS be raw PBKDF2 bytes (so entries // stay decryptable across auth-scheme changes), and the transmitted verifier // must be decoupled from that key under the v2 scheme. const test = require('node:test'); const assert = require('node:assert/strict'); const { pbkdf2Sync, createHash } = require('node:crypto'); const { loadApp } = require('./harness.js'); const ctx = loadApp(); const T = ctx.__test; // Reference PBKDF2 computed independently via Node (NOT via app.js) so the // vectors actually cross-check rather than being self-referential. app.js // feeds the salt STRING's UTF-8 bytes to PBKDF2 (salt = enc.encode(saltHex)), // so the Node reference must do the same. function refKeyHex(pwd, saltHex, iters) { return pbkdf2Sync(pwd, Buffer.from(saltHex, 'utf8'), iters, 32, 'sha256').toString('hex'); } function refSha256Hex(str) { return createHash('sha256').update(str, 'utf8').digest('hex'); } test('bytesToHex: lowercase, zero-padded, round-trips known bytes', () => { assert.equal(T.bytesToHex(new Uint8Array([0, 1, 15, 16, 255])), '00010f10ff'); assert.equal(T.bytesToHex(new Uint8Array([])), ''); }); test('deriveKeyAndVerifier: AES key is raw PBKDF2 output (matches Node reference)', async () => { const pwd = 'correct horse battery staple'; const saltHex = 'a1b2c3d4e5f6'; const iters = 600000; const expectKeyHex = refKeyHex(pwd, saltHex, iters); const { cryptoKey } = await T.deriveKeyAndVerifier(pwd, saltHex, iters, T.HASH_ALGO_V2); const raw = await ctx.crypto.subtle.exportKey('raw', cryptoKey); assert.equal(T.bytesToHex(new Uint8Array(raw)), expectKeyHex, 'AES key must equal hex(PBKDF2) regardless of hash_algo'); }); test('deriveKeyAndVerifier: legacy algo verifier IS the key hex', async () => { const pwd = 'hunter2'; const saltHex = 'deadbeef'; const iters = 100000; const expectKeyHex = refKeyHex(pwd, saltHex, iters); // Any non-v2 label → verifier verbatim = key hex (pre-decoupling accounts). const { verifier } = await T.deriveKeyAndVerifier(pwd, saltHex, iters, 'pbkdf2-sha256'); assert.equal(verifier, expectKeyHex); // Unknown/empty algo must also fall through to key hex (safe rollout path). const empty = await T.deriveKeyAndVerifier(pwd, saltHex, iters, ''); assert.equal(empty.verifier, expectKeyHex); }); test('deriveKeyAndVerifier: v2 verifier is decoupled SHA-256(keyHex + domain)', async () => { const pwd = 'hunter2'; const saltHex = 'deadbeef'; const iters = 100000; const keyHex = refKeyHex(pwd, saltHex, iters); const expectVerifier = refSha256Hex(keyHex + T.AUTH_VERIFIER_DOMAIN); const { verifier } = await T.deriveKeyAndVerifier(pwd, saltHex, iters, T.HASH_ALGO_V2); assert.equal(verifier, expectVerifier); // The whole point of v2: the transmitted verifier must NOT be the key. assert.notEqual(verifier, keyHex, 'v2 verifier must not leak the AES key'); }); test('verifierFromKeyHex: pure mapping matches deriveKeyAndVerifier', async () => { const keyHex = 'ab'.repeat(32); assert.equal(await T.verifierFromKeyHex(keyHex, 'anything-legacy'), keyHex); assert.equal(await T.verifierFromKeyHex(keyHex, T.HASH_ALGO_V2), refSha256Hex(keyHex + T.AUTH_VERIFIER_DOMAIN)); }); test('deriveKeyAndVerifier: default iterations = 100000 when falsy', async () => { const pwd = 'x'; const saltHex = 'salt'; const withDefault = await T.deriveKeyAndVerifier(pwd, saltHex, 0, 'pbkdf2-sha256'); assert.equal(withDefault.verifier, refKeyHex(pwd, saltHex, 100000)); }); test('encryptPwd/decryptPwd: round-trips arbitrary strings under the vault key', async () => { // encryptPwd/decryptPwd read state.cryptoKey — set it to a derived key. const { cryptoKey } = await T.deriveKeyAndVerifier('master', 'saltsalt', 100000, T.HASH_ALGO_V2); T.state.cryptoKey = cryptoKey; for (const plain of ['', 'a', 'password123!', 'emoji 🔐 unicode ✓', 'x'.repeat(5000)]) { const { encrypted, iv } = await T.encryptPwd(plain); assert.equal(await T.decryptPwd(encrypted, iv), plain, `round-trip failed for len ${plain.length}`); } }); test('encryptPwd: fresh random IV per call (no IV reuse)', async () => { const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2); T.state.cryptoKey = cryptoKey; const a = await T.encryptPwd('same-plaintext'); const b = await T.encryptPwd('same-plaintext'); assert.notEqual(a.iv, b.iv, 'IVs must differ'); assert.notEqual(a.encrypted, b.encrypted, 'ciphertext must differ for reused plaintext'); }); test('decryptPwd: tampered ciphertext returns "[ERROR]" (AEAD integrity)', async () => { const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2); T.state.cryptoKey = cryptoKey; const { encrypted, iv } = await T.encryptPwd('secret'); // Flip a byte in the ciphertext. const bytes = Uint8Array.from(atob(encrypted), c => c.charCodeAt(0)); bytes[0] ^= 0xff; const tampered = btoa(String.fromCharCode(...bytes)); assert.equal(await T.decryptPwd(tampered, iv), '[ERROR]'); // Wrong IV also fails closed. assert.equal(await T.decryptPwd(encrypted, btoa('bad-iv-1234')), '[ERROR]'); }); test('decryptPwd: wrong key returns "[ERROR]" (not garbage plaintext)', async () => { const k1 = await T.deriveKeyAndVerifier('pw-one', 'salt', 100000, T.HASH_ALGO_V2); T.state.cryptoKey = k1.cryptoKey; const { encrypted, iv } = await T.encryptPwd('top secret'); const k2 = await T.deriveKeyAndVerifier('pw-two', 'salt', 100000, T.HASH_ALGO_V2); T.state.cryptoKey = k2.cryptoKey; assert.equal(await T.decryptPwd(encrypted, iv), '[ERROR]'); });