// Tests for the browser crypto module, run in Node (Node 22 has Web Crypto). // node test/crypto.test.mjs import assert from "node:assert/strict"; import { generateKey, importKey, encrypt, decrypt, encryptBytes, decryptBytes, b64urlToBytes, bytesToB64url, randomSecret, deriveInvite, bindMac, verifyBindMac, wrapGroupKey, unwrapGroupKey, generateUserKeypair, wrapThreadKey, unwrapThreadKey, mintBrandingKey, deriveBrandingKey, BRANDING_KEY_CHARS, } from "../web/static/crypto.js"; let failures = 0; async function test(name, fn) { try { await fn(); console.log("PASS " + name); } catch (e) { console.log("FAIL " + name + " — " + (e && e.message)); failures++; } } await test("encrypt → decrypt round-trips (incl. unicode)", async () => { const { key } = await generateKey(); const msg = { name: "Sophie", text: "héllo 🌍", ts: 1234567890 }; const out = await decrypt(key, await encrypt(key, msg)); assert.deepEqual(out, msg); }); await test("a different key cannot decrypt", async () => { const { key } = await generateKey(); const { key: other } = await generateKey(); const blob = await encrypt(key, { text: "secret" }); await assert.rejects(() => decrypt(other, blob)); }); await test("ciphertext is not plaintext", async () => { const { key } = await generateKey(); const blob = await encrypt(key, { text: "hello world" }); assert.ok(!blob.includes("hello"), "blob must not contain plaintext"); }); await test("each encryption uses a fresh IV (different ciphertext)", async () => { const { key } = await generateKey(); const a = await encrypt(key, { text: "same" }); const b = await encrypt(key, { text: "same" }); assert.notEqual(a, b); }); await test("b64url is URL-safe and round-trips", async () => { const bytes = new Uint8Array([0, 1, 2, 250, 251, 255, 62, 63]); const s = bytesToB64url(bytes); assert.ok(!/[+/=]/.test(s), "must not contain + / ="); assert.deepEqual(Array.from(b64urlToBytes(s)), Array.from(bytes)); }); await test("two clients sharing the exported key interoperate", async () => { const { b64 } = await generateKey(); const sender = await importKey(b64); const receiver = await importKey(b64); const out = await decrypt(receiver, await encrypt(sender, { text: "shared" })); assert.equal(out.text, "shared"); }); await test("encryptBytes → decryptBytes round-trips binary data", async () => { const { key } = await generateKey(); const original = new Uint8Array([0, 1, 2, 3, 250, 251, 255, 128, 64]); const enc = await encryptBytes(key, original); assert.ok(enc.length > original.length, "ciphertext carries iv + tag overhead"); const dec = await decryptBytes(key, enc); assert.deepEqual(Array.from(dec), Array.from(original)); }); await test("encryptBytes output is not the plaintext", async () => { const { key } = await generateKey(); const original = new Uint8Array([10, 20, 30, 40]); const enc = await encryptBytes(key, original); // The first 12 bytes are the IV; the rest must differ from the plaintext. assert.notDeepEqual(Array.from(enc.slice(12)), Array.from(original)); }); await test("invite: same secret derives the same id (stable lookup)", async () => { const T = randomSecret(); const a = await deriveInvite(T); const b = await deriveInvite(T); assert.equal(a.id, b.id); }); await test("invite: a different secret derives a different id", async () => { const a = await deriveInvite(randomSecret()); const b = await deriveInvite(randomSecret()); assert.notEqual(a.id, b.id); }); await test("invite: wrap → unwrap recovers the group key; wrong secret can't", async () => { const { b64: groupKey } = await generateKey(); const T = randomSecret(); const { wrapKey } = await deriveInvite(T); const wrapped = await wrapGroupKey(groupKey, wrapKey); assert.ok(!wrapped.includes(groupKey), "wrapped must not contain the raw key"); assert.equal(await unwrapGroupKey(wrapped, wrapKey), groupKey); const { wrapKey: other } = await deriveInvite(randomSecret()); await assert.rejects(() => unwrapGroupKey(wrapped, other)); }); await test("user keypair: thread key wraps to a pubkey and unwraps with its privkey", async () => { const me = await generateUserKeypair(); const { b64: threadKey } = await generateKey(); const wrapped = await wrapThreadKey(threadKey, me.publicKey); assert.ok(!wrapped.includes(threadKey), "wrapped must not contain the raw key"); assert.equal(await unwrapThreadKey(wrapped, me.privateKey), threadKey); }); await test("user keypair: another person's privkey cannot unwrap", async () => { const me = await generateUserKeypair(); const other = await generateUserKeypair(); const { b64: threadKey } = await generateKey(); const wrapped = await wrapThreadKey(threadKey, me.publicKey); await assert.rejects(() => unwrapThreadKey(wrapped, other.privateKey)); }); await test("user keypair: each wrap uses a fresh ephemeral key (different blob)", async () => { const me = await generateUserKeypair(); const { b64: threadKey } = await generateKey(); const a = await wrapThreadKey(threadKey, me.publicKey); const b = await wrapThreadKey(threadKey, me.publicKey); assert.notEqual(a, b); // …but both still recover the same key. assert.equal(await unwrapThreadKey(a, me.privateKey), await unwrapThreadKey(b, me.privateKey)); }); await test("user keypair: a group of members each unwrap the same thread key", async () => { const members = await Promise.all([0, 1, 2].map(() => generateUserKeypair())); const { b64: threadKey } = await generateKey(); for (const m of members) { const wrapped = await wrapThreadKey(threadKey, m.publicKey); assert.equal(await unwrapThreadKey(wrapped, m.privateKey), threadKey); } }); await test("branding key: mint shape + derive round-trips + wrong key fails", async () => { const b = mintBrandingKey(); assert.match(b, new RegExp(`^[a-z2-7]{${BRANDING_KEY_CHARS}}$`)); const salt = bytesToB64url(crypto.getRandomValues(new Uint8Array(16))); const key = await deriveBrandingKey(b, salt); const blob = await encrypt(key, { name: "Family Chat", description: "Our cosy corner" }); const opened = await decrypt(await deriveBrandingKey(b, salt), blob); assert.equal(opened.name, "Family Chat"); // A different key (or a different salt) must not open it. let leaked = false; try { await decrypt(await deriveBrandingKey(mintBrandingKey(), salt), blob); leaked = true; } catch {} assert.equal(leaked, false); }); // PINNED cross-platform vector — not a snapshot. This blob was produced by // THIS code (PBKDF2-SHA256, 600k iterations, AES-GCM-256, iv||ct b64url); a // sealed instance's blobs live on servers indefinitely, so any KDF/param // change silently bricks them. If this test fails, you changed the derivation // — don't re-pin; add a versioned migration instead. The Swift CommonCrypto // implementation must decrypt this same vector when native rendering lands. await test("branding key: pinned KDF vector decrypts", async () => { const b = "k3m7p2qx4f"; const salt = "AAECAwQFBgcICQoLDA0ODw"; // bytes 0x00..0x0f const blob = "uu9h74mrboFkgWSzYhnd67QKxlD7k9t6nJ79flw9UKkdkTMIZRTtxOcBW-ivDIckbudRf9uB64AXiGB4qcPfOA"; const opened = await decrypt(await deriveBrandingKey(b, salt), blob); assert.deepEqual(opened, { name: "Vector Pin", emoji: "🔏" }); }); await test("bindKey is deterministic from T and domain-separated", async () => { const T = randomSecret(); const a = await deriveInvite(T); const b = await deriveInvite(T); assert.equal(a.bindKey, b.bindKey, "same T → same bindKey"); const c = await deriveInvite(randomSecret()); assert.notEqual(a.bindKey, c.bindKey, "different T → different bindKey"); // bindKey must not equal the other derivations (domain separation). assert.notEqual(a.bindKey, a.claimSecret); }); await test("bindMac is over canonical key BYTES (re-encoding the string still verifies)", async () => { const { bindKey } = await deriveInvite(randomSecret()); const { publicKey: pub } = await generateUserKeypair(); const mac = await bindMac(bindKey, pub); // A server that re-encodes the SAME key bytes (add '=' padding, or the // standard +/ alphabet) changes the string but not the key — verification // must still hold, so it can't cry wolf on an honest member. (b64urlToBytes // tolerates both, and the MAC is over the decoded bytes.) const reencoded = pub + "="; // a tolerated padding variant of the same bytes assert.equal(await verifyBindMac(bindKey, reencoded, mac), true, "re-encoded key still verifies"); }); await test("bindMac verifies for the right key+pubkey and fails otherwise", async () => { const { bindKey } = await deriveInvite(randomSecret()); const { publicKey: pub } = await generateUserKeypair(); const mac = await bindMac(bindKey, pub); assert.equal(await verifyBindMac(bindKey, pub, mac), true, "correct MAC verifies"); // Wrong (substituted) public key must NOT verify — the whole point. const { publicKey: evilPub } = await generateUserKeypair(); assert.equal(await verifyBindMac(bindKey, evilPub, mac), false, "substituted key rejected"); // Wrong bind key (a different invite's T) must not verify. const { bindKey: otherKey } = await deriveInvite(randomSecret()); assert.equal(await verifyBindMac(otherKey, pub, mac), false, "wrong bindKey rejected"); // Malformed inputs return false, never throw. assert.equal(await verifyBindMac(bindKey, pub, "not-base64!!"), false); assert.equal(await verifyBindMac("", pub, mac), false); assert.equal(await verifyBindMac(bindKey, pub, ""), false); }); console.log(failures === 0 ? "\nALL PASSED" : `\n${failures} FAILED`); process.exit(failures === 0 ? 0 : 1);