| 1 | // Items, against a fake network and a fake store. |
| 2 | // |
| 3 | // #12's discipline says storage and network are passed in, never reached for, |
| 4 | // so that unit tests hand in fakes. This is what that buys: the whole item |
| 5 | // lifecycle exercised with no socket, no browser, and no server — and every |
| 6 | // byte the "server" received available to assert on. |
| 7 | // |
| 8 | // The assertion that matters most is the negative one: nothing the fake |
| 9 | // network was handed contains any plaintext. |
| 10 | |
| 11 | import { test } from "node:test"; |
| 12 | import assert from "node:assert/strict"; |
| 13 | import { fileURLToPath } from "node:url"; |
| 14 | import { dirname, join } from "node:path"; |
| 15 | |
| 16 | const here = dirname(fileURLToPath(import.meta.url)); |
| 17 | const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name); |
| 18 | |
| 19 | const { fromHex, toHex } = await import(mod("bytes.js")); |
| 20 | const { items, openItem, sealItem, FIELDS } = await import(mod("items.js")); |
| 21 | const { accountKeys } = await import(mod("vaultkeys.js")); |
| 22 | |
| 23 | // fakeNet records everything sent, and answers from what it holds. |
| 24 | function fakeNet() { |
| 25 | const rows = new Map(); |
| 26 | const sent = []; |
| 27 | return { |
| 28 | sent, |
| 29 | rows, |
| 30 | async get(path) { |
| 31 | sent.push({ method: "GET", path }); |
| 32 | return { items: [...rows.values()] }; |
| 33 | }, |
| 34 | async post(path, body) { |
| 35 | sent.push({ method: "POST", path, body }); |
| 36 | if (path === "/api/items") { |
| 37 | rows.set(body.item_id_hex, { |
| 38 | item_id_hex: body.item_id_hex, version: body.version, |
| 39 | nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, |
| 40 | }); |
| 41 | return {}; |
| 42 | } |
| 43 | if (path === "/api/items/delete") { |
| 44 | rows.delete(body.item_id_hex); |
| 45 | return null; |
| 46 | } |
| 47 | throw new Error("unexpected POST " + path); |
| 48 | }, |
| 49 | async put(path, body) { |
| 50 | sent.push({ method: "PUT", path, body }); |
| 51 | rows.set(body.item_id_hex, { |
| 52 | item_id_hex: body.item_id_hex, version: body.version, |
| 53 | nonce_hex: body.nonce_hex, ciphertext_hex: body.ciphertext_hex, |
| 54 | }); |
| 55 | return {}; |
| 56 | }, |
| 57 | }; |
| 58 | } |
| 59 | |
| 60 | const vault = { |
| 61 | id: fromHex("d7331f78b34eb0cc299252e2624070db"), |
| 62 | idHex: "d7331f78b34eb0cc299252e2624070db", |
| 63 | kVault: fromHex("576894008c1a94f71704db728d37bfa9f0023cca4fd8d2ae48f6af81a4b959a5"), |
| 64 | }; |
| 65 | |
| 66 | const CANARIES = { |
| 67 | title: "CANARY-title-Jamaica-Bakery-8f3a1c", |
| 68 | url: "https://CANARY-host-4d2e9b.example.invalid/login", |
| 69 | username: "CANARY-user-4d2e9b@example.invalid", |
| 70 | password: "CANARY-password-Q7wz-2m4K-9xLp", |
| 71 | notes: "CANARY-notes-the-spare-key-is-under-the-pot", |
| 72 | }; |
| 73 | |
| 74 | test("an item round-trips through create, list and update", async () => { |
| 75 | const net = fakeNet(); |
| 76 | const store = items(net, vault); |
| 77 | |
| 78 | const created = await store.create(CANARIES); |
| 79 | assert.equal(created.version, 1); |
| 80 | |
| 81 | let list = await store.list(); |
| 82 | assert.equal(list.length, 1); |
| 83 | assert.deepEqual(list[0].fields, CANARIES); |
| 84 | |
| 85 | const edited = { ...CANARIES, username: "CANARY-user-changed@example.invalid" }; |
| 86 | await store.update(list[0], edited); |
| 87 | |
| 88 | list = await store.list(); |
| 89 | assert.equal(list.length, 1); |
| 90 | assert.equal(list[0].version, 2, "an edit must bump the version, which is in the AAD"); |
| 91 | assert.deepEqual(list[0].fields, edited); |
| 92 | |
| 93 | await store.remove(list[0]); |
| 94 | assert.equal((await store.list()).length, 0); |
| 95 | }); |
| 96 | |
| 97 | // The one that matters: everything the network was handed is opaque. |
| 98 | test("nothing readable reaches the network", async () => { |
| 99 | const net = fakeNet(); |
| 100 | const store = items(net, vault); |
| 101 | const created = await store.create(CANARIES); |
| 102 | await store.update(created, { ...CANARIES, title: "CANARY-title-second-1a7c5e" }); |
| 103 | |
| 104 | const wire = JSON.stringify(net.sent); |
| 105 | for (const [field, value] of Object.entries(CANARIES)) { |
| 106 | assert.ok(!wire.includes(value), `${field} reached the network in plaintext`); |
| 107 | } |
| 108 | assert.ok(!wire.includes("CANARY-title-second-1a7c5e"), "the edit reached the network"); |
| 109 | assert.ok(!wire.includes(toHex(vault.kVault)), "K_vault reached the network"); |
| 110 | }); |
| 111 | |
| 112 | // Sizes are quantised, so the bucket cannot be read for item-length |
| 113 | // fingerprints. A short item and a long one must be indistinguishable by |
| 114 | // length until they cross a bucket boundary. |
| 115 | test("item ciphertexts are quantised into 256-byte buckets", async () => { |
| 116 | const net = fakeNet(); |
| 117 | const store = items(net, vault); |
| 118 | |
| 119 | await store.create({ title: "a" }); |
| 120 | await store.create({ title: "a".repeat(120), notes: "b".repeat(60) }); |
| 121 | |
| 122 | const lengths = new Set(); |
| 123 | for (const s of net.sent) { |
| 124 | if (!s.body || !s.body.ciphertext_hex) continue; |
| 125 | const bytes = s.body.ciphertext_hex.length / 2; |
| 126 | assert.equal((bytes - 16) % 256, 0, `ciphertext is ${bytes} bytes, not bucket-aligned`); |
| 127 | lengths.add(bytes); |
| 128 | } |
| 129 | assert.equal(lengths.size, 1, "two very different items produced two different lengths"); |
| 130 | }); |
| 131 | |
| 132 | // The AAD binds the vault, the item and the version, so a ciphertext moved to |
| 133 | // another slot does not open. |
| 134 | test("an item does not open in another slot", async () => { |
| 135 | const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| 136 | const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, CANARIES); |
| 137 | |
| 138 | assert.deepEqual(await openItem(vault.id, vault.kVault, itemId, 1, sealed), { |
| 139 | ...CANARIES, |
| 140 | }); |
| 141 | |
| 142 | const otherItem = fromHex("4580c14ff986936588b14e66e247978e"); |
| 143 | const otherVault = fromHex("d7331f78b34eb0cc299252e2624070dc"); |
| 144 | await assert.rejects(() => openItem(vault.id, vault.kVault, otherItem, 1, sealed), |
| 145 | undefined, "moved to another item id and still opened"); |
| 146 | await assert.rejects(() => openItem(otherVault, vault.kVault, itemId, 1, sealed), |
| 147 | undefined, "moved to another vault and still opened"); |
| 148 | await assert.rejects(() => openItem(vault.id, vault.kVault, itemId, 2, sealed), |
| 149 | undefined, "rolled to another version and still opened"); |
| 150 | }); |
| 151 | |
| 152 | // Fields absent from an item are still present in its body, so the ciphertext |
| 153 | // does not publish which fields somebody filled in. |
| 154 | test("every field is present whether or not it was filled in", async () => { |
| 155 | const itemId = fromHex("4580c14ff986936588b14e66e247978d"); |
| 156 | const sealed = await sealItem(vault.id, vault.kVault, itemId, 1, { title: "only" }); |
| 157 | const opened = await openItem(vault.id, vault.kVault, itemId, 1, sealed); |
| 158 | assert.deepEqual(Object.keys(opened).sort(), [...FIELDS].sort()); |
| 159 | }); |
| 160 | |
| 161 | // The Account Key cache is per-device and is one named thing, not |
| 162 | // localStorage calls scattered through the app. |
| 163 | test("the Account Key cache is storage handed in, not reached for", () => { |
| 164 | const backing = new Map(); |
| 165 | const storage = { |
| 166 | getItem: (k) => backing.get(k) ?? null, |
| 167 | setItem: (k, v) => backing.set(k, v), |
| 168 | removeItem: (k) => backing.delete(k), |
| 169 | }; |
| 170 | const keys = accountKeys(storage); |
| 171 | const id = "b22c6a5eae1be9d6a773f474fd0532b0"; |
| 172 | |
| 173 | assert.equal(keys.get(id), null); |
| 174 | const accountKey = fromHex("f60fb281e8c72f6725add4cbd40e7b08"); |
| 175 | keys.set(id, accountKey); |
| 176 | assert.equal(toHex(keys.get(id)), toHex(accountKey)); |
| 177 | |
| 178 | // Nothing global was touched. |
| 179 | assert.equal(backing.size, 1); |
| 180 | keys.forget(id); |
| 181 | assert.equal(keys.get(id), null); |
| 182 | }); |
| 183 | |