Files
Password-Manager/js/tests/crypto.test.js
T
r-zakarya a42e4b205d feat(entries): encrypt template at rest, guided tour, import fixes, cleanup
Batched session work sharing app.js / index.html / Entries.pas, so it can't
split cleanly without interactive hunk staging.

- feat: encrypt `template` metadata at rest (template_enc/iv, added to
  ENCRYPTED_META_FIELDS). withEncryptedMeta skips an absent template key so
  partial re-ships (add-tag, move-to-folder) don't wipe it via LHasTemplate.
  Cleartext column kept as migration fallback. +3 unit tests.
- feat: first-run guided tour ("How it works") — spotlight + bubble, no GIFs,
  re-launchable from Settings, seen-flag in DPAPI prefs.
- fix(import): preserve original created_at on restore (was stamped to import
  time); restore entry icons on overwrite (PUT ignores icon_b64).
- fix(settings): correct clipboard-privacy copy (already excluded from Win+V);
  PIN text 4-6 -> 4-12; reorder Set-PIN above unlock-method; move tray/startup
  toggles to General; dedicated backup-password button + warning status; tab icons.
- chore: remove dead legacy monolith (app-legacy.js, index-legacy.html,
  style-legacy.css) + unused passkeyBtn stub.
- docs: full-source review (CODE_AUDIT 6b), template + favorite/pinned notes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:32:37 +01:00

266 lines
13 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');
});
// ---- Argon2id KDF (js/argon2.js — vendored @noble/hashes) -------------------
test('vendored argon2id matches the RFC 9106 §5.3 test vector', () => {
// Independent cross-check that the bundled library is correct (not just
// self-consistent). Same vector used to validate the vendored bundle.
const out = T.NobleArgon2.argon2id(
new Uint8Array(32).fill(1), new Uint8Array(16).fill(2),
{ t: 3, m: 32, p: 4, dkLen: 32,
key: new Uint8Array(8).fill(3),
personalization: new Uint8Array(12).fill(4), version: 0x13 });
assert.equal(T.bytesToHex(out),
'0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659');
});
test('argon2idAsync matches argon2id (deriveKeyBytes uses the async variant)', async () => {
// deriveKeyBytes now calls argon2idAsync (yields to the event loop so the
// unlock spinner animates). It must produce the exact same key as the sync
// path — assert parity on the RFC vector.
const args = [new Uint8Array(32).fill(1), new Uint8Array(16).fill(2),
{ t: 3, m: 32, p: 4, dkLen: 32, key: new Uint8Array(8).fill(3),
personalization: new Uint8Array(12).fill(4), version: 0x13 }];
const sync = T.NobleArgon2.argon2id(...args);
const async_ = await T.NobleArgon2.argon2idAsync(...args);
assert.equal(T.bytesToHex(async_), T.bytesToHex(sync));
assert.equal(T.bytesToHex(async_),
'0d640df58d78766c08c037a34a8b53c9d01ef0452d75b65eb52520e96b01e659');
});
test('isDecoupledVerifierAlgo: all -v2 markers decouple, legacy does not', () => {
assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_V2), true);
assert.equal(T.isDecoupledVerifierAlgo(T.HASH_ALGO_ARGON2), true);
assert.equal(T.isDecoupledVerifierAlgo('pbkdf2-sha256'), false);
assert.equal(T.isDecoupledVerifierAlgo('pbkdf2'), false);
assert.equal(T.isDecoupledVerifierAlgo(''), false);
});
test('deriveKeyAndVerifier: argon2id branch derives an Argon2 key, NOT PBKDF2', async () => {
const pwd = 'correct horse battery staple';
const saltHex = 'a1b2c3d4e5f6a1b2';
// The raw key bytes must equal argon2id(pwd, saltHex-utf8, params) — cross
// checked against the vendored lib directly (small params for test speed).
const params = { m: 256, t: 1, p: 1 };
const expectKey = T.bytesToHex(T.NobleArgon2.argon2id(
new TextEncoder().encode(pwd), new TextEncoder().encode(saltHex),
{ t: params.t, m: params.m, p: params.p, dkLen: 32, version: 0x13 }));
const { cryptoKey, verifier } = await T.deriveKeyAndVerifier(
pwd, saltHex, 0, T.HASH_ALGO_ARGON2, params);
const raw = await ctx.crypto.subtle.exportKey('raw', cryptoKey);
assert.equal(T.bytesToHex(new Uint8Array(raw)), expectKey, 'AES key must be the Argon2id output');
// Differs from what PBKDF2 would give for the same pw/salt (different KDF).
assert.notEqual(T.bytesToHex(new Uint8Array(raw)), refKeyHex(pwd, saltHex, 100000));
// argon2id-v2 is a '-v2' scheme → verifier is decoupled SHA-256, not the key.
assert.equal(verifier, refSha256Hex(expectKey + T.AUTH_VERIFIER_DOMAIN));
assert.notEqual(verifier, expectKey, 'argon2id-v2 verifier must not leak the key');
});
test('deriveKeyAndVerifier: argon2id uses ARGON2_DEFAULT_PARAMS when none passed', async () => {
// OWASP baseline is the default (m=19456, t=2, p=1). Just assert the
// default object is what we expect; deriving at 19 MiB is left to one
// explicit round-trip below to keep the suite fast.
assert.deepEqual({ ...T.ARGON2_DEFAULT_PARAMS }, { m: 19456, t: 2, p: 1 });
});
test('argon2 param contract: identical params → identical key+verifier (register↔login)', async () => {
// Register derives with params P; login re-derives with the params the
// challenge echoes back. If those match, the key + verifier must match
// exactly — otherwise the user could register but never log in.
const pwd = 'pw', salt = 'saltsaltsalt', params = { m: 512, t: 2, p: 1 };
const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, params);
const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { ...params });
const ra = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', a.cryptoKey));
const rb = new Uint8Array(await ctx.crypto.subtle.exportKey('raw', b.cryptoKey));
assert.equal(T.bytesToHex(ra), T.bytesToHex(rb));
assert.equal(a.verifier, b.verifier);
});
test('argon2 param sensitivity: differing params → different key (transmission matters)', async () => {
// If the server drops/garbles the echoed params, the client derives a
// different key → login fails closed rather than silently mis-deriving.
const pwd = 'pw', salt = 'saltsaltsalt';
const a = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 2, p: 1 });
const b = await T.deriveKeyAndVerifier(pwd, salt, 0, T.HASH_ALGO_ARGON2, { m: 512, t: 3, p: 1 });
assert.notEqual(a.verifier, b.verifier);
});
test('encrypt/decrypt round-trips under an Argon2id-derived key', async () => {
const { cryptoKey } = await T.deriveKeyAndVerifier(
'master', 'saltsaltsalt', 0, T.HASH_ALGO_ARGON2, { m: 512, t: 1, p: 1 });
T.state.cryptoKey = cryptoKey;
const { encrypted, iv } = await T.encryptPwd('argon-secret 🔐');
assert.equal(await T.decryptPwd(encrypted, iv), 'argon-secret 🔐');
});
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]');
});
// --- Metadata-at-rest (§1.3): template encrypted like username/site/... ---
test('withEncryptedMeta: template is encrypted at rest + round-trips', async () => {
assert.ok(T.ENCRYPTED_META_FIELDS.includes('template'),
'template must be an encrypted meta field');
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
const body = await T.withEncryptedMeta({
username: 'u', site: 's', title: 't', tags: '', template: 'credit-card',
});
assert.equal(body.template, '', 'cleartext template blanked on write');
assert.ok(body.template_enc && body.template_iv, 'ciphertext written');
assert.notEqual(body.template_enc, 'credit-card', 'not stored in cleartext');
// GET returns the ciphertext (cleartext column blanked) → decrypt restores it.
const row = { template: '', template_enc: body.template_enc, template_iv: body.template_iv };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'credit-card');
});
test('withEncryptedMeta: omitting template preserves it (partial re-ship guard)', async () => {
const { cryptoKey } = await T.deriveKeyAndVerifier('m', 's', 100000, T.HASH_ALGO_V2);
T.state.cryptoKey = cryptoKey;
// add-tag / move-to-folder bodies carry no `template` key. The choke point
// must NOT synthesise template_enc='' — that would make the server wipe the
// stored template (LHasTemplate fires on the present-but-empty key).
const body = await T.withEncryptedMeta({ site: 's', title: 't', username: 'u', tags: 'x' });
assert.ok(!('template' in body), 'no cleartext template key added');
assert.ok(!('template_enc' in body), 'no ciphertext template key added');
});
test('decryptEntryMeta: un-migrated row keeps its cleartext template', async () => {
// Row predates encryption: cleartext `template` present, no template_enc.
const row = { template: 'ssh-key' };
await T.decryptEntryMeta([row]);
assert.equal(row.template, 'ssh-key', 'cleartext preserved until migration');
});