carlos / contra Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Sharing, in the browser modules.
2//
3// The Go suite proves the API stores and returns the right rows. This proves
4// the thing that actually matters to a person: an owner seals a vault keyset
5// with the browser's own code, and a DIFFERENT account opens it with theirs.
6//
7// The network is a fake, because none of what is being tested is the network.
8
9import { test } from "node:test";
10import assert from "node:assert/strict";
11import { fileURLToPath } from "node:url";
12import { dirname, join } from "node:path";
13
14const here = dirname(fileURLToPath(import.meta.url));
15const mod = (name) => join(here, "..", "internal", "contra", "static", "crypto", name);
16
17const { fromHex, randomBytes, toHex } = await import(mod("bytes.js"));
18const { newP256 } = await import(mod("p256.js"));
19const { publicKeyFor } = await import(mod("p256math.js"));
20const { concat } = await import(mod("bytes.js"));
21const sharing = await import(mod("sharing.js"));
22
23// A fake network that records the one POST sharing makes.
24function recorder() {
25 const posts = [];
26 return {
27 posts,
28 async get() { throw new Error("not used"); },
29 async post(path, body) { posts.push({ path, body }); return { epoch: 2 }; },
30 };
31}
32
33async function anAccount() {
34 const key = await newP256();
35 return {
36 id: randomBytes(16),
37 scalar: key.scalar,
38 publicKey: key.publicKey,
39 };
40}
41
42test("an owner seals a keyset that a different account can open", async () => {
43 const owner = await anAccount();
44 const guest = await anAccount();
45 const net = recorder();
46
47 const vault = {
48 id: randomBytes(16),
49 idHex: "",
50 kVault: randomBytes(32),
51 name: "Household",
52 epoch: 1,
53 owned: true,
54 };
55 vault.idHex = toHex(vault.id);
56
57 await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey));
58
59 assert.equal(net.posts.length, 1);
60 const body = net.posts[0].body;
61 assert.equal(net.posts[0].path, "/api/members");
62 assert.equal(body.account_id_hex, toHex(guest.id));
63
64 // The recipient opens it with their own scalar — which is the whole claim.
65 const opened = await sharing.openSharedKeyset(
66 guest.scalar, guest.publicKey, guest.id, vault.id,
67 {
68 ephemeral_public_key_hex: body.ephemeral_public_key_hex,
69 keyset: body.sealed,
70 });
71 assert.equal(opened.k_vault, toHex(vault.kVault), "the member got the vault key");
72 assert.equal(opened.name, "Household", "the member got the vault name");
73});
74
75test("nobody else can open a member's wrap", async () => {
76 const owner = await anAccount();
77 const guest = await anAccount();
78 const stranger = await anAccount();
79 const net = recorder();
80
81 const vault = {
82 id: randomBytes(16), kVault: randomBytes(32),
83 name: "Household", epoch: 1, owned: true,
84 };
85 vault.idHex = toHex(vault.id);
86 await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey));
87 const body = net.posts[0].body;
88 const row = {
89 ephemeral_public_key_hex: body.ephemeral_public_key_hex,
90 keyset: body.sealed,
91 };
92
93 await assert.rejects(
94 () => sharing.openSharedKeyset(
95 stranger.scalar, stranger.publicKey, stranger.id, vault.id, row),
96 "a stranger opened a wrap sealed to somebody else");
97
98 // And the member cannot have their own wrap replayed at another vault, or
99 // under another member's name — the AAD names both.
100 await assert.rejects(
101 () => sharing.openSharedKeyset(
102 guest.scalar, guest.publicKey, guest.id, randomBytes(16), row),
103 "a member's wrap opened against a different vault");
104 await assert.rejects(
105 () => sharing.openSharedKeyset(
106 guest.scalar, guest.publicKey, randomBytes(16), vault.id, row),
107 "a member's wrap opened under a different member's id");
108});
109
110// The drop-box private half is deliberately not shared: a member reads and
111// writes items without being able to open captures addressed to the vault.
112test("a member wrap carries the vault key and not the drop box", async () => {
113 const guest = await anAccount();
114 const net = recorder();
115 const vault = {
116 id: randomBytes(16), kVault: randomBytes(32), name: "Household",
117 epoch: 1, owned: true,
118 dropBoxPrivate: randomBytes(32),
119 };
120 vault.idHex = toHex(vault.id);
121
122 await sharing.share(net, vault, toHex(guest.id), toHex(guest.publicKey));
123 const body = net.posts[0].body;
124 const opened = await sharing.openSharedKeyset(
125 guest.scalar, guest.publicKey, guest.id, vault.id,
126 { ephemeral_public_key_hex: body.ephemeral_public_key_hex, keyset: body.sealed });
127
128 assert.equal(opened.drop_box_private, undefined,
129 "the drop-box private half was shared with a member");
130});
131
132// Only the owner. A member holding K_vault could otherwise compose a rotation
133// and evict the owner from their own vault.
134test("a member cannot share or rotate", async () => {
135 const guest = await anAccount();
136 const net = recorder();
137 const notMine = {
138 id: randomBytes(16), kVault: randomBytes(32), name: "Theirs",
139 epoch: 1, owned: false,
140 };
141 notMine.idHex = toHex(notMine.id);
142
143 await assert.rejects(
144 () => sharing.share(net, notMine, toHex(guest.id), toHex(guest.publicKey)),
145 /only the owner/);
146 await assert.rejects(
147 () => sharing.rotate(net, {}, notMine, [], [], randomBytes(32)),
148 /only the owner/);
149 assert.equal(net.posts.length, 0, "a refused call still reached the network");
150});
151
152// Rotation re-seals every item under the new key and bumps every version, so
153// the old key opens nothing that remains.
154test("rotation re-seals every item under the new key", async () => {
155 const owner = await anAccount();
156 const guest = await anAccount();
157 const net = recorder();
158
159 const vault = {
160 id: randomBytes(16), kVault: randomBytes(32), name: "Household",
161 epoch: 1, owned: true,
162 };
163 vault.idHex = toHex(vault.id);
164 const opened = { rk: randomBytes(32), accountId: owner.id };
165
166 const items = [
167 { id: randomBytes(16), idHex: "", version: 3, fields: { title: "One" } },
168 { id: randomBytes(16), idHex: "", version: 1, fields: { title: "Two" } },
169 ];
170 for (const it of items) it.idHex = toHex(it.id);
171
172 const newKVault = randomBytes(32);
173 const after = await sharing.rotate(
174 net, opened, vault, items,
175 [{ account_id_hex: toHex(guest.id), public_key_hex: toHex(guest.publicKey) }],
176 newKVault);
177
178 const body = net.posts[0].body;
179 assert.equal(net.posts[0].path, "/api/vault/rotate");
180 assert.equal(body.expected_epoch, 1, "the rotation did not carry its CAS token");
181 assert.equal(body.items.length, 2, "not every item was re-sealed");
182 assert.equal(body.items[0].version, 4, "the version did not bump");
183 assert.equal(body.items[1].version, 2);
184 assert.equal(body.members.length, 1);
185 assert.equal(after.epoch, 2);
186
187 // The retained member's new wrap opens, and carries the NEW key.
188 const m = body.members[0];
189 const got = await sharing.openSharedKeyset(
190 guest.scalar, guest.publicKey, guest.id, vault.id,
191 {
192 ephemeral_public_key_hex: m.ephemeral_public_key_hex,
193 keyset: { nonce_hex: m.nonce_hex, ciphertext_hex: m.ciphertext_hex },
194 });
195 assert.equal(got.k_vault, toHex(newKVault), "a retained member got the old key");
196});
197