d9397881dc
Two CODE_AUDIT items in one session. §3.2 — Frontend regression net (js/tests/, 35 tests, node:test, zero deps): - harness.js loads app.js (monofile, no exports) into a node:vm with browser globals stubbed, surfacing internals via an export epilogue. - crypto: deriveKeyAndVerifier (AES key == raw PBKDF2, cross-checked vs Node pbkdf2Sync), legacy-vs-v2 verifier decoupling, encrypt/decrypt round-trip, IV uniqueness, AEAD tamper/wrong-key. - csv: parseCSV tokenizer, findColumn heuristics, Bitwarden/KeePass mapping. - merge: applyRemoteSnapshot add/update/skip (LWW), tombstone delete, resurrection arbitration (both NaN branches), local-tombstone veto, additive folder merge. Only api() is stubbed; loadEntries/encryptImportEntry run for real. - Wired as a build gate in BuildAssets.ps1 (after node --check, bypass PM_SKIP_TESTS=1). §2.2 — Unify timestamps on UTC: - Entry created_at/updated_at were written via Delphi FormatDateTime(Now) = LOCAL, while deleted_at/tombstones use SQLite CURRENT_TIMESTAMP = UTC. The tombstone-resurrection arbitration compared the two zones, skewing by the machine's UTC offset even single-device. - Add NowUTC/NowUTCStr to PM.Database, swap in at every entry/attachment write site (Entries create/update/bulk, Attachments POST echo). - No JS change needed: arbitration now compares same-zone values. - Existing rows self-heal on next edit (no destructive migration). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
130 lines
5.8 KiB
JavaScript
130 lines
5.8 KiB
JavaScript
// 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]');
|
|
});
|