carlos / contra Public

Clone
git clone https://amadan.net/carlos/contra

Plain git — no account needed to clone.

Download

Download this file

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
11import { test } from "node:test";
12import assert from "node:assert/strict";
13import { fileURLToPath } from "node:url";
14import { dirname, join } from "node:path";
15
16const here = dirname(fileURLToPath(import.meta.url));
17const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name);
18
19const { fromHex, toHex } = await import(mod("bytes.js"));
20const { items, openItem, sealItem, FIELDS } = await import(mod("items.js"));
21const { accountKeys } = await import(mod("vaultkeys.js"));
22
23// fakeNet records everything sent, and answers from what it holds.
24function 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
60const vault = {
61 id: fromHex("d7331f78b34eb0cc299252e2624070db"),
62 idHex: "d7331f78b34eb0cc299252e2624070db",
63 kVault: fromHex("576894008c1a94f71704db728d37bfa9f0023cca4fd8d2ae48f6af81a4b959a5"),
64};
65
66const 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
74test("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.
98test("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.
115test("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.
134test("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.
154test("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.
163test("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