eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// Tests for the home server's browser crypto module, run in Node (Node 22 has
2// Web Crypto). Same harness style as test/crypto.test.mjs.
3// node home/test/crypto.test.mjs
4import assert from "node:assert/strict";
5import {
6 b64urlToBytes,
7 bytesToB64url,
8 kekFromPRF,
9 generateDEK,
10 exportDEK,
11 importDEK,
12 wrapDEK,
13 unwrapDEK,
14 pad,
15 unpad,
16 BLOB_PAD,
17 sealBlob,
18 unpackBlob,
19 openBlob,
20} from "../web/static/crypto.js";
21
22let failures = 0;
23async function test(name, fn) {
24 try {
25 await fn();
26 console.log("PASS " + name);
27 } catch (e) {
28 console.log("FAIL " + name + " — " + (e && e.message));
29 failures++;
30 }
31}
32
33// A stand-in for the PRF extension output (32 random bytes from the
34// authenticator; only ever seen client-side).
35const fakePRF = () => crypto.getRandomValues(new Uint8Array(32));
36
37await test("pad produces a fixed-size buffer and unpad round-trips", async () => {
38 const msg = new TextEncoder().encode("héllo 🌍");
39 const padded = pad(msg);
40 assert.equal(padded.length, BLOB_PAD, "padded size must always be BLOB_PAD");
41 assert.deepEqual(unpad(padded), msg);
42});
43
44await test("pad: empty and boundary (BLOB_PAD-4) payloads round-trip", async () => {
45 for (const n of [0, 1, BLOB_PAD - 4]) {
46 const msg = crypto.getRandomValues(new Uint8Array(n));
47 const padded = pad(msg);
48 assert.equal(padded.length, BLOB_PAD);
49 assert.deepEqual(unpad(padded), msg);
50 }
51});
52
53await test("pad rejects payloads over the fixed size", async () => {
54 assert.throws(() => pad(new Uint8Array(BLOB_PAD - 3)), /too large/);
55 assert.throws(() => pad(new Uint8Array(BLOB_PAD + 1)), /too large/);
56});
57
58await test("padded size is identical regardless of contents (length leaks nothing)", async () => {
59 const short = pad(new TextEncoder().encode("{}"));
60 const long = pad(crypto.getRandomValues(new Uint8Array(8000)));
61 assert.equal(short.length, long.length);
62});
63
64await test("sealBlob → openBlob round-trips via a PRF-derived KEK", async () => {
65 const prf = fakePRF();
66 const kek = await kekFromPRF(prf);
67 const dek = await generateDEK();
68 const obj = { v: 1, servers: [{ url: "https://family.example.com", name: "Family" }] };
69 const sealed = await sealBlob(obj, dek, { "cred-1": await wrapDEK(dek, kek) });
70
71 const { keys } = unpackBlob(sealed);
72 const dek2 = await unwrapDEK(keys["cred-1"], await kekFromPRF(prf));
73 const { obj: out } = await openBlob(sealed, dek2);
74 assert.deepEqual(out, obj);
75});
76
77await test("unwrapDEK with a KEK from the wrong PRF output fails", async () => {
78 const kek = await kekFromPRF(fakePRF());
79 const dek = await generateDEK();
80 const wrapped = await wrapDEK(dek, kek);
81 const wrongKek = await kekFromPRF(fakePRF());
82 await assert.rejects(() => unwrapDEK(wrapped, wrongKek));
83});
84
85await test("envelope wraps the DEK per credential: each of 2 KEKs can open", async () => {
86 const prfA = fakePRF();
87 const prfB = fakePRF();
88 const dek = await generateDEK();
89 const obj = { v: 1, servers: [{ url: "https://test.example.com" }] };
90 const sealed = await sealBlob(obj, dek, {
91 "cred-a": await wrapDEK(dek, await kekFromPRF(prfA)),
92 "cred-b": await wrapDEK(dek, await kekFromPRF(prfB)),
93 });
94
95 const { keys } = unpackBlob(sealed);
96 for (const [credId, prf] of [["cred-a", prfA], ["cred-b", prfB]]) {
97 const unwrapped = await unwrapDEK(keys[credId], await kekFromPRF(prf));
98 const { obj: out } = await openBlob(sealed, unwrapped);
99 assert.deepEqual(out, obj, credId + " must open the blob");
100 }
101 // ...and neither KEK unwraps the OTHER credential's blob copy directly.
102 const kekB = await kekFromPRF(prfB);
103 await assert.rejects(() => unwrapDEK(keys["cred-a"], kekB));
104});
105
106await test("ciphertext hides the plaintext and the DEK survives export/import", async () => {
107 const dek = await generateDEK();
108 const obj = { v: 1, servers: [{ url: "https://secret-instance.example" }] };
109 const sealed = await sealBlob(obj, dek, {});
110 assert.ok(!new TextDecoder("utf-8", { fatal: false }).decode(sealed).includes("secret-instance"));
111
112 const dek2 = await importDEK(await exportDEK(dek)); // the silent-sync cache path
113 const { obj: out } = await openBlob(sealed, dek2);
114 assert.deepEqual(out, obj);
115});
116
117await test("unpackBlob rejects non-envelope bytes", async () => {
118 assert.throws(() => unpackBlob(new TextEncoder().encode("NOPE-not-an-envelope")), /not a home blob/);
119});
120
121await test("b64url is URL-safe and round-trips", async () => {
122 const bytes = crypto.getRandomValues(new Uint8Array(64));
123 const s = bytesToB64url(bytes);
124 assert.ok(!/[+/=]/.test(s));
125 assert.deepEqual(b64urlToBytes(s), bytes);
126});
127
128console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`);
129process.exit(failures === 0 ? 0 : 1);
130