// Items, against a fake network and a fake store. // // #12's discipline says storage and network are passed in, never reached for, // so that unit tests hand in fakes. This is what that buys: the whole item // lifecycle exercised with no socket, no browser, and no server — and every // byte the "server" received available to assert on. // // The assertion that matters most is the negative one: nothing the fake // network was handed contains any plaintext. import { test } from "node:test"; import assert from "node:assert/strict"; import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; const here = dirname(fileURLToPath(import.meta.url)); const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); const { fromHex, toHex } = await import(mod("bytes.js")); const { items, openItem, sealItem, FIELDS } = await import(mod("items.js")); const { accountKeys } = await import(mod("vaultkeys.js")); // fakeNet records everything sent, and answers from what it holds. function fakeNet() { const rows = new Map(); const sent = []; return { sent, rows, async get(path) { sent.push({ method: "GET", path }); return { items: [...rows.values()] }; }, async post(path, body) { sent.push({ method: "POST", path, body }); if (path === "/api/items") { rows.set(body.item_id_hex, { item_id_hex: body.item_id_hex, version: body.version, nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, }); return {}; } if (path === "/api/items/delete") { rows.delete(body.item_id_hex); return null; } throw new Error("unexpected POST " + path); }, async put(path, body) { sent.push({ method: "PUT", path, body }); rows.set(body.item_id_hex, { item_id_hex: body.item_id_hex, version: body.version, nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, }); return {}; }, }; } const vault = { id: fromHex("d7331f78b34eb0cc299252e2624070db"), idHex: "d7331f78b34eb0cc299252e2624070db", kVault: fromHex("576894008c1a94f71704db728d37bfa9f0023cca4fd8d2ae48f6af81a4b959a5"), }; const CANARIES = { title: "CANARY-title-Jamaica-Bakery-8f3a1c", url: "https://CANARY-host-4d2e9b.example.invalid/login", username: "CANARY-user-4d2e9b@example.invalid", password: "CANARY-password-Q7wz-2m4K-9xLp", notes: "CANARY-notes-the-spare-key-is-under-the-pot", }; test("an item round-trips through create, list and update", async () => { const net = fakeNet(); const store = items(net, vault); const created = await store.create(CANARIES); assert.equal(created.version, 1); let list = await store.list(); assert.equal(list.length, 1); assert.deepEqual(list[0].fields, CANARIES); const edited = { ...CANARIES, username: "CANARY-user-changed@example.invalid" }; await store.update(list[0], edited); list = await store.list(); assert.equal(list.length, 1); assert.equal(list[0].version, 2, "an edit must bump the version, which is in the AAD"); assert.deepEqual(list[0].fields, edited); await store.remove(list[0]); assert.equal((await store.list()).length, 0); }); // The one that matters: everything the network was handed is opaque. test("nothing readable reaches the network", async () => { const net = fakeNet(); const store = items(net, vault); const created = await store.create(CANARIES); await store.update(created, { ...CANARIES, title: "CANARY-title-second-1a7c5e" }); const wire = JSON.stringify(net.sent); for (const [field, value] of Object.entries(CANARIES)) { assert.ok(!wire.includes(value), `${field} reached the network in plaintext`); } assert.ok(!wire.includes("CANARY-title-second-1a7c5e"), "the edit reached the network"); assert.ok(!wire.includes(toHex(vault.kVault)), "K_vault reached the network"); }); // Sizes are quantised, so the bucket cannot be read for item-length // fingerprints. A short item and a long one must be indistinguishable by // length until they cross a bucket boundary. test("item ciphertexts are quantised into 256-byte buckets", async () => { const net = fakeNet(); const store = items(net, vault); await store.create({ title: "a" }); await store.create({ title: "a".repeat(120), notes: "b".repeat(60) }); const lengths = new Set(); for (const s of net.sent) { if (!s.body || !s.body.ciphertext_hex) continue; const bytes = s.body.ciphertext_hex.length / 2; assert.equal((bytes - 16) % 256, 0, `ciphertext is ${bytes} bytes, not bucket-aligned`); lengths.add(bytes); } assert.equal(lengths.size, 1, "two very different items produced two different lengths"); }); // The AAD binds the vault, the item and the version, so a ciphertext moved to // another slot does not open. test("an item does not open in another slot", async () => { const itemId = fromHex("4580c14ff986936588b14e66e247978d"); const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, CANARIES); assert.deepEqual(await openItem(vault.id, vault.kVault, itemId, 1, sealed), { ...CANARIES, }); const otherItem = fromHex("4580c14ff986936588b14e66e247978e"); const otherVault = fromHex("d7331f78b34eb0cc299252e2624070dc"); await assert.rejects(() => openItem(vault.id, vault.kVault, otherItem, 1, sealed), undefined, "moved to another item id and still opened"); await assert.rejects(() => openItem(otherVault, vault.kVault, itemId, 1, sealed), undefined, "moved to another vault and still opened"); await assert.rejects(() => openItem(vault.id, vault.kVault, itemId, 2, sealed), undefined, "rolled to another version and still opened"); }); // Fields absent from an item are still present in its body, so the ciphertext // does not publish which fields somebody filled in. test("every field is present whether or not it was filled in", async () => { const itemId = fromHex("4580c14ff986936588b14e66e247978d"); const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, { title: "only" }); const opened = await openItem(vault.id, vault.kVault, itemId, 1, sealed); assert.deepEqual(Object.keys(opened).sort(), [...FIELDS].sort()); }); // The Account Key cache is per-device and is one named thing, not // localStorage calls scattered through the app. test("the Account Key cache is storage handed in, not reached for", () => { const backing = new Map(); const storage = { getItem: (k) => backing.get(k) ?? null, setItem: (k, v) => backing.set(k, v), removeItem: (k) => backing.delete(k), }; const keys = accountKeys(storage); const id = "b22c6a5eae1be9d6a773f474fd0532b0"; assert.equal(keys.get(id), null); const accountKey = fromHex("f60fb281e8c72f6725add4cbd40e7b08"); keys.set(id, accountKey); assert.equal(toHex(keys.get(id)), toHex(accountKey)); // Nothing global was touched. assert.equal(backing.size, 1); keys.forget(id); assert.equal(keys.get(id), null); });