eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the browser crypto module, run in Node (Node 22 has Web Crypto).
2// node test/crypto.test.mjs
3import assert from "node:assert/strict";
4import {
5 generateKey,
6 importKey,
7 encrypt,
8 decrypt,
9 encryptBytes,
10 decryptBytes,
11 b64urlToBytes,
12 bytesToB64url,
13 randomSecret,
14 deriveInvite,
15 bindMac,
16 verifyBindMac,
17 wrapGroupKey,
18 unwrapGroupKey,
19 generateUserKeypair,
20 wrapThreadKey,
21 unwrapThreadKey,
22 mintBrandingKey,
23 deriveBrandingKey,
24 BRANDING_KEY_CHARS,
25} from "../web/static/crypto.js";
26
27let failures = 0;
28async function test(name, fn) {
29 try {
30 await fn();
31 console.log("PASS " + name);
32 } catch (e) {
33 console.log("FAIL " + name + " β€” " + (e && e.message));
34 failures++;
35 }
36}
37
38await test("encrypt β†’ decrypt round-trips (incl. unicode)", async () => {
39 const { key } = await generateKey();
40 const msg = { name: "Sophie", text: "héllo 🌍", ts: 1234567890 };
41 const out = await decrypt(key, await encrypt(key, msg));
42 assert.deepEqual(out, msg);
43});
44
45await test("a different key cannot decrypt", async () => {
46 const { key } = await generateKey();
47 const { key: other } = await generateKey();
48 const blob = await encrypt(key, { text: "secret" });
49 await assert.rejects(() => decrypt(other, blob));
50});
51
52await test("ciphertext is not plaintext", async () => {
53 const { key } = await generateKey();
54 const blob = await encrypt(key, { text: "hello world" });
55 assert.ok(!blob.includes("hello"), "blob must not contain plaintext");
56});
57
58await test("each encryption uses a fresh IV (different ciphertext)", async () => {
59 const { key } = await generateKey();
60 const a = await encrypt(key, { text: "same" });
61 const b = await encrypt(key, { text: "same" });
62 assert.notEqual(a, b);
63});
64
65await test("b64url is URL-safe and round-trips", async () => {
66 const bytes = new Uint8Array([0, 1, 2, 250, 251, 255, 62, 63]);
67 const s = bytesToB64url(bytes);
68 assert.ok(!/[+/=]/.test(s), "must not contain + / =");
69 assert.deepEqual(Array.from(b64urlToBytes(s)), Array.from(bytes));
70});
71
72await test("two clients sharing the exported key interoperate", async () => {
73 const { b64 } = await generateKey();
74 const sender = await importKey(b64);
75 const receiver = await importKey(b64);
76 const out = await decrypt(receiver, await encrypt(sender, { text: "shared" }));
77 assert.equal(out.text, "shared");
78});
79
80await test("encryptBytes β†’ decryptBytes round-trips binary data", async () => {
81 const { key } = await generateKey();
82 const original = new Uint8Array([0, 1, 2, 3, 250, 251, 255, 128, 64]);
83 const enc = await encryptBytes(key, original);
84 assert.ok(enc.length > original.length, "ciphertext carries iv + tag overhead");
85 const dec = await decryptBytes(key, enc);
86 assert.deepEqual(Array.from(dec), Array.from(original));
87});
88
89await test("encryptBytes output is not the plaintext", async () => {
90 const { key } = await generateKey();
91 const original = new Uint8Array([10, 20, 30, 40]);
92 const enc = await encryptBytes(key, original);
93 // The first 12 bytes are the IV; the rest must differ from the plaintext.
94 assert.notDeepEqual(Array.from(enc.slice(12)), Array.from(original));
95});
96
97await test("invite: same secret derives the same id (stable lookup)", async () => {
98 const T = randomSecret();
99 const a = await deriveInvite(T);
100 const b = await deriveInvite(T);
101 assert.equal(a.id, b.id);
102});
103
104await test("invite: a different secret derives a different id", async () => {
105 const a = await deriveInvite(randomSecret());
106 const b = await deriveInvite(randomSecret());
107 assert.notEqual(a.id, b.id);
108});
109
110await test("invite: wrap β†’ unwrap recovers the group key; wrong secret can't", async () => {
111 const { b64: groupKey } = await generateKey();
112 const T = randomSecret();
113 const { wrapKey } = await deriveInvite(T);
114 const wrapped = await wrapGroupKey(groupKey, wrapKey);
115 assert.ok(!wrapped.includes(groupKey), "wrapped must not contain the raw key");
116 assert.equal(await unwrapGroupKey(wrapped, wrapKey), groupKey);
117
118 const { wrapKey: other } = await deriveInvite(randomSecret());
119 await assert.rejects(() => unwrapGroupKey(wrapped, other));
120});
121
122await test("user keypair: thread key wraps to a pubkey and unwraps with its privkey", async () => {
123 const me = await generateUserKeypair();
124 const { b64: threadKey } = await generateKey();
125 const wrapped = await wrapThreadKey(threadKey, me.publicKey);
126 assert.ok(!wrapped.includes(threadKey), "wrapped must not contain the raw key");
127 assert.equal(await unwrapThreadKey(wrapped, me.privateKey), threadKey);
128});
129
130await test("user keypair: another person's privkey cannot unwrap", async () => {
131 const me = await generateUserKeypair();
132 const other = await generateUserKeypair();
133 const { b64: threadKey } = await generateKey();
134 const wrapped = await wrapThreadKey(threadKey, me.publicKey);
135 await assert.rejects(() => unwrapThreadKey(wrapped, other.privateKey));
136});
137
138await test("user keypair: each wrap uses a fresh ephemeral key (different blob)", async () => {
139 const me = await generateUserKeypair();
140 const { b64: threadKey } = await generateKey();
141 const a = await wrapThreadKey(threadKey, me.publicKey);
142 const b = await wrapThreadKey(threadKey, me.publicKey);
143 assert.notEqual(a, b);
144 // …but both still recover the same key.
145 assert.equal(await unwrapThreadKey(a, me.privateKey), await unwrapThreadKey(b, me.privateKey));
146});
147
148await test("user keypair: a group of members each unwrap the same thread key", async () => {
149 const members = await Promise.all([0, 1, 2].map(() => generateUserKeypair()));
150 const { b64: threadKey } = await generateKey();
151 for (const m of members) {
152 const wrapped = await wrapThreadKey(threadKey, m.publicKey);
153 assert.equal(await unwrapThreadKey(wrapped, m.privateKey), threadKey);
154 }
155});
156
157
158await test("branding key: mint shape + derive round-trips + wrong key fails", async () => {
159 const b = mintBrandingKey();
160 assert.match(b, new RegExp(`^[a-z2-7]{${BRANDING_KEY_CHARS}}$`));
161 const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16)));
162 const key = await deriveBrandingKey(b, salt);
163 const blob = await encrypt(key, { name: "Family Chat", description: "Our cosy corner" });
164 const opened = await decrypt(await deriveBrandingKey(b, salt), blob);
165 assert.equal(opened.name, "Family Chat");
166 // A different key (or a different salt) must not open it.
167 let leaked = false;
168 try {
169 await decrypt(await deriveBrandingKey(mintBrandingKey(), salt), blob);
170 leaked = true;
171 } catch {}
172 assert.equal(leaked, false);
173});
174
175// PINNED cross-platform vector β€” not a snapshot. This blob was produced by
176// THIS code (PBKDF2-SHA256, 600k iterations, AES-GCM-256, iv||ct b64url); a
177// sealed instance's blobs live on servers indefinitely, so any KDF/param
178// change silently bricks them. If this test fails, you changed the derivation
179// β€” don't re-pin; add a versioned migration instead. The Swift CommonCrypto
180// implementation must decrypt this same vector when native rendering lands.
181await test("branding key: pinned KDF vector decrypts", async () => {
182 const b = "k3m7p2qx4f";
183 const salt = "AAECAwQFBgcICQoLDA0ODw"; // bytes 0x00..0x0f
184 const blob = "uu9h74mrboFkgWSzYhnd67QKxlD7k9t6nJ79flw9UKkdkTMIZRTtxOcBW-ivDIckbudRf9uB64AXiGB4qcPfOA";
185 const opened = await decrypt(await deriveBrandingKey(b, salt), blob);
186 assert.deepEqual(opened, { name: "Vector Pin", emoji: "πŸ”" });
187});
188
189await test("bindKey is deterministic from T and domain-separated", async () => {
190 const T = randomSecret();
191 const a = await deriveInvite(T);
192 const b = await deriveInvite(T);
193 assert.equal(a.bindKey, b.bindKey, "same T β†’ same bindKey");
194 const c = await deriveInvite(randomSecret());
195 assert.notEqual(a.bindKey, c.bindKey, "different T β†’ different bindKey");
196 // bindKey must not equal the other derivations (domain separation).
197 assert.notEqual(a.bindKey, a.claimSecret);
198});
199
200await test("bindMac is over canonical key BYTES (re-encoding the string still verifies)", async () => {
201 const { bindKey } = await deriveInvite(randomSecret());
202 const { publicKey: pub } = await generateUserKeypair();
203 const mac = await bindMac(bindKey, pub);
204 // A server that re-encodes the SAME key bytes (add '=' padding, or the
205 // standard +/ alphabet) changes the string but not the key β€” verification
206 // must still hold, so it can't cry wolf on an honest member. (b64urlToBytes
207 // tolerates both, and the MAC is over the decoded bytes.)
208 const reencoded = pub + "="; // a tolerated padding variant of the same bytes
209 assert.equal(await verifyBindMac(bindKey, reencoded, mac), true, "re-encoded key still verifies");
210});
211
212await test("bindMac verifies for the right key+pubkey and fails otherwise", async () => {
213 const { bindKey } = await deriveInvite(randomSecret());
214 const { publicKey: pub } = await generateUserKeypair();
215 const mac = await bindMac(bindKey, pub);
216 assert.equal(await verifyBindMac(bindKey, pub, mac), true, "correct MAC verifies");
217 // Wrong (substituted) public key must NOT verify β€” the whole point.
218 const { publicKey: evilPub } = await generateUserKeypair();
219 assert.equal(await verifyBindMac(bindKey, evilPub, mac), false, "substituted key rejected");
220 // Wrong bind key (a different invite's T) must not verify.
221 const { bindKey: otherKey } = await deriveInvite(randomSecret());
222 assert.equal(await verifyBindMac(otherKey, pub, mac), false, "wrong bindKey rejected");
223 // Malformed inputs return false, never throw.
224 assert.equal(await verifyBindMac(bindKey, pub, "not-base64!!"), false);
225 assert.equal(await verifyBindMac("", pub, mac), false);
226 assert.equal(await verifyBindMac(bindKey, pub, ""), false);
227});
228
229console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
230process.exit(failures === 0 ? 0 : 1);
231