// Wire-format + determinism tests for the password vault. // Runs offline — no chain, no IPC, no filesystem beyond a tmp path. import { test } from "node:test"; import assert from "node:assert/strict"; import { mkdtempSync, existsSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { bip39ToSeed, seedToPurposeRoot, derivePassword, createVault, unlockVault, saveVault, vaultExists, newEntry, resolvePassword, listMetadata, bytesToHex, } from "../src/lib/password-vault.js"; const tmp = mkdtempSync(join(tmpdir(), "sm-pv-")); const vaultPath = join(tmp, "passwords.vault"); // A fixed test seed (32 random bytes hex) — real vaults use HKDF from a // BIP39 seed; this is just deterministic input for the derivePassword tests. const TEST_ROOT = "9a3f6d21b4e0c8127f4d5a6789bcdef012345678901234567890abcdef123456"; test("derivePassword — same inputs → same output", async () => { const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 }); const b = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 }); assert.equal(a, b); }); test("derivePassword — different domain → different password", async () => { const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 }); const b = await derivePassword(TEST_ROOT, { domain: "other.com", username: "alice", version: 1 }); assert.notEqual(a, b); }); test("derivePassword — different version → different password (rotation)", async () => { const a = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 }); const b = await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 2 }); assert.notEqual(a, b); }); test("derivePassword — different root → different password", async () => { const OTHER = "1111111111111111111111111111111111111111111111111111111111111111"; const a = await derivePassword(TEST_ROOT, { domain: "example.com" }); const b = await derivePassword(OTHER, { domain: "example.com" }); assert.notEqual(a, b); }); test("derivePassword — length + charset rules honored", async () => { const p = await derivePassword(TEST_ROOT, { domain: "e.com", rules: { length: 24, upper: true, lower: true, digits: true, symbols: true } }); assert.equal(p.length, 24); assert.match(p, /[a-z]/); assert.match(p, /[A-Z]/); assert.match(p, /[0-9]/); assert.match(p, /[^A-Za-z0-9]/); }); test("derivePassword — digits-only rules produce digits-only password", async () => { const p = await derivePassword(TEST_ROOT, { domain: "e.com", rules: { length: 12, digits: true, upper: false, lower: false, symbols: false } }); assert.equal(p.length, 12); assert.match(p, /^[0-9]+$/); }); test("bip39ToSeed → matches BIP39 test vector", async () => { // BIP39 official vector — mnemonic + empty passphrase. // https://github.com/trezor/python-mnemonic/blob/master/vectors.json const mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"; const seed = await bip39ToSeed(mnemonic, ""); const expected = "5eb00bbddcf069084889a8ab9155568165f5c453ccb85e70811aaed6f6da5fc19a5ac40b389cd370d086206dec8aa6c43daea6690f20ad3d8d48b2d2ce9e38e4"; assert.equal(bytesToHex(seed), expected); }); test("seedToPurposeRoot — deterministic, different purpose → different root", async () => { const seed = new Uint8Array(64).fill(0x42); const r1 = await seedToPurposeRoot(seed, "passwords/0"); const r2 = await seedToPurposeRoot(seed, "passwords/0"); const rMsg = await seedToPurposeRoot(seed, "messenger/0"); assert.deepEqual(r1, r2); assert.notDeepEqual(r1, rMsg); }); test("createVault + unlockVault round-trip with the right master password", async () => { const path = join(tmp, "roundtrip.vault"); const rootHex = TEST_ROOT; await createVault(path, "correct-horse-battery-staple", rootHex); assert.ok(vaultExists(path)); const st = await unlockVault(path, "correct-horse-battery-staple"); assert.equal(st.purposeRoot, rootHex); assert.deepEqual(st.entries, []); }); test("unlockVault — wrong master password → throws", async () => { const path = join(tmp, "wrongpw.vault"); await createVault(path, "right", TEST_ROOT); await assert.rejects(unlockVault(path, "wrong"), /bad master password/); }); test("newEntry + saveVault + unlockVault preserves entries; resolvePassword works", async () => { const path = join(tmp, "entries.vault"); const st = await createVault(path, "pw", TEST_ROOT); const e1 = newEntry({ domain: "example.com", username: "alice" }); const e2 = newEntry({ domain: "bank.com", username: "bob", literal: "hunter2!!" }); st.entries.push(e1, e2); await saveVault(path, st); const st2 = await unlockVault(path, "pw"); assert.equal(st2.entries.length, 2); assert.equal(await resolvePassword(st2, e1.id), await derivePassword(TEST_ROOT, { domain: "example.com", username: "alice", version: 1 })); assert.equal(await resolvePassword(st2, e2.id), "hunter2!!"); }); test("listMetadata — never leaks passwords", () => { const st = { entries: [ { id: "1", domain: "a.com", username: "a", literal: "secret1", addedAt: "" }, { id: "2", domain: "b.com", username: "b", generated: { version: 1, rules: {} }, addedAt: "" }, ]}; const meta = listMetadata(st); for (const m of meta) { assert.equal(m.literal, undefined); assert.equal(m.generated, undefined); assert.ok(m.kind === "literal" || m.kind === "generated"); } });