import { describe, it, expect, vi } from "vitest"; import { generateECDHKeyPair, exportPublicKey, importPublicKey, generateRoomKey, wrapRoomKey, unwrapRoomKey, computeKeyFingerprint, computeRawKeyFingerprint, roomKeyToBase64, generateIdentityKeyPair, signEphemeralKey, verifyEphemeralKeySignature, importIdentityPublicKey, exportIdentityKeyPair, importIdentityKeyPair, } from "@lib/e2eeCrypto"; vi.mock("@lib/logger", () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), })); describe("e2eeCrypto", () => { // ── module-load WebCrypto guard ──────────────────────────────────────────── describe("WebCrypto availability check", () => { async function importFresh(cryptoStub: unknown) { vi.resetModules(); vi.stubGlobal("crypto", cryptoStub); try { return await import("@lib/e2eeCrypto"); } finally { vi.unstubAllGlobals(); } } it("throws on import when there is no crypto global", async () => { await expect(importFresh(undefined)).rejects.toThrow( "E2EE requires WebCrypto (crypto.subtle). Ensure the app is served over HTTPS or a secure context.", ); }); it("throws on import when crypto exists but crypto.subtle does not", async () => { await expect(importFresh({ getRandomValues: () => undefined })).rejects.toThrow( /E2EE requires WebCrypto/, ); }); it("imports cleanly in a secure context", async () => { const mod = await importFresh(globalThis.crypto); expect(typeof mod.generateECDHKeyPair).toBe("function"); }); }); // ── wrap / unwrap round-trip ─────────────────────────────────────────────── describe("wrapRoomKey / unwrapRoomKey", () => { it("round-trips a room key between two keypairs", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1); const { roomKey: unwrapped, epoch } = await unwrapRoomKey( bob.privateKey, alice.publicKey, encryptedKey, iv, ); expect(unwrapped).toEqual(roomKey); expect(epoch).toBe(1); }); it("produces a different ciphertext each call (fresh IV)", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); const first = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1); const second = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1); // The IVs should differ, making ciphertexts distinct expect(first.iv).not.toBe(second.iv); }); }); // ── tampered ciphertext rejected ────────────────────────────────────────── describe("unwrapRoomKey", () => { it("throws when the ciphertext has been tampered", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 1); // Decode, flip the first ciphertext byte (after the 9-byte header), re-encode const bytes = Uint8Array.from(atob(encryptedKey), (c) => c.charCodeAt(0)); bytes[9] = bytes[9]! ^ 0xff; const tampered = btoa(String.fromCharCode(...bytes)); await expect(unwrapRoomKey(bob.privateKey, alice.publicKey, tampered, iv)).rejects.toThrow(); }); }); // ── epoch binding (OC-0001) ─────────────────────────────────────────────── describe("offer epoch binding", () => { const b64 = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); const fromB64 = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); it("carries the epoch as a 0x01 version byte + u64 big-endian header", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { encryptedKey } = await wrapRoomKey( alice.privateKey, bob.publicKey, generateRoomKey(), 0x0102030405, ); const bytes = fromB64(encryptedKey); expect(Array.from(bytes.subarray(0, 9))).toEqual([1, 0, 0, 0, 1, 2, 3, 4, 5]); // 32-byte key + 16-byte GCM tag after the header expect(bytes.byteLength).toBe(9 + 48); }); it("rejects a blob whose epoch header was edited", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { encryptedKey, iv } = await wrapRoomKey( alice.privateKey, bob.publicKey, generateRoomKey(), 3, ); const bytes = fromB64(encryptedKey); bytes[8] = 9; // epoch 3 -> 9, ciphertext untouched await expect( unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv), ).rejects.toThrow(); }); it("rejects an unknown format version byte", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { encryptedKey, iv } = await wrapRoomKey( alice.privateKey, bob.publicKey, generateRoomKey(), 3, ); const bytes = fromB64(encryptedKey); bytes[0] = 2; await expect( unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv), ).rejects.toThrow(); }); it("rejects a decoded header epoch above Number.MAX_SAFE_INTEGER", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { encryptedKey, iv } = await wrapRoomKey( alice.privateKey, bob.publicKey, generateRoomKey(), 3, ); const bytes = fromB64(encryptedKey); // Overwrite the u64 epoch (bytes 1-8) with 0xFFFFFFFFFFFFFFFF, well // above 2^53-1. The range check runs before decrypt, so the AAD // mismatch this also creates never gets a chance to fire. bytes.set([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], 1); await expect(unwrapRoomKey(bob.privateKey, alice.publicKey, b64(bytes), iv)).rejects.toThrow( "E2EE: offer epoch out of range", ); }); it("rejects an epoch that is negative or not a safe integer", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); await expect( wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), -1), ).rejects.toThrow("E2EE: offer epoch must be a non-negative safe integer"); await expect( wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 2 ** 53), ).rejects.toThrow("E2EE: offer epoch must be a non-negative safe integer"); }); it("accepts epoch 0 (the first epoch) at both ends", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, roomKey, 0); expect(Array.from(fromB64(encryptedKey).subarray(0, 9))).toEqual([1, 0, 0, 0, 0, 0, 0, 0, 0]); const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); expect(unwrapped.roomKey).toEqual(roomKey); expect(unwrapped.epoch).toBe(0); }); it("accepts an epoch of exactly Number.MAX_SAFE_INTEGER", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); const { encryptedKey, iv } = await wrapRoomKey( alice.privateKey, bob.publicKey, roomKey, Number.MAX_SAFE_INTEGER, ); const unwrapped = await unwrapRoomKey(bob.privateKey, alice.publicKey, encryptedKey, iv); expect(unwrapped.roomKey).toEqual(roomKey); expect(unwrapped.epoch).toBe(Number.MAX_SAFE_INTEGER); }); it("rejects a blob truncated below the header length", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 3); // A valid version byte but only 4 of the 9 header bytes. await expect( unwrapRoomKey(bob.privateKey, alice.publicKey, b64(new Uint8Array([1, 0, 0, 0])), iv), ).rejects.toThrow("E2EE: unknown wrapped-key format"); }); it("rejects a non-base64 encrypted key with the base64 error", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const { iv } = await wrapRoomKey(alice.privateKey, bob.publicKey, generateRoomKey(), 3); await expect( unwrapRoomKey(bob.privateKey, alice.publicKey, "!!! not base64 !!!", iv), ).rejects.toThrow("E2EE: invalid base64 input"); }); it("still unwraps a legacy blob (no header, no additional data) and reports epoch null", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); const roomKey = generateRoomKey(); // Build the pre-epoch wire format by hand: ECDH -> HKDF(salt, info) -> // AES-GCM with no additional data, raw ciphertext in encrypted_key. const shared = await crypto.subtle.deriveBits( { name: "ECDH", public: bob.publicKey }, alice.privateKey, 256, ); const hkdf = await crypto.subtle.importKey("raw", shared, "HKDF", false, ["deriveKey"]); const wrapKey = await crypto.subtle.deriveKey( { name: "HKDF", hash: "SHA-256", salt: new TextEncoder().encode("owncord-voice-e2ee-v1"), info: new TextEncoder().encode("room-key-wrap"), }, hkdf, { name: "AES-GCM", length: 256 }, false, ["encrypt"], ); const iv = crypto.getRandomValues(new Uint8Array(12)); const ct = await crypto.subtle.encrypt( { name: "AES-GCM", iv }, wrapKey, roomKey as Uint8Array, ); const result = await unwrapRoomKey( bob.privateKey, alice.publicKey, b64(new Uint8Array(ct)), b64(iv), ); expect(result.roomKey).toEqual(roomKey); expect(result.epoch).toBeNull(); }); }); // ── fingerprint stability ───────────────────────────────────────────────── describe("computeKeyFingerprint", () => { it("returns the same fingerprint for the same public key", async () => { const { publicKey } = await generateECDHKeyPair(); const fp1 = await computeKeyFingerprint(publicKey); const fp2 = await computeKeyFingerprint(publicKey); expect(fp1).toBe(fp2); }); it("returns different fingerprints for different keys", async () => { const { publicKey: keyA } = await generateECDHKeyPair(); const { publicKey: keyB } = await generateECDHKeyPair(); const fpA = await computeKeyFingerprint(keyA); const fpB = await computeKeyFingerprint(keyB); expect(fpA).not.toBe(fpB); }); it("computeRawKeyFingerprint over the exported raw bytes matches computeKeyFingerprint", async () => { const { publicKey } = await generateECDHKeyPair(); const raw = new Uint8Array(await crypto.subtle.exportKey("raw", publicKey)); expect(await computeRawKeyFingerprint(raw)).toBe(await computeKeyFingerprint(publicKey)); }); it("matches a known SHA-256 vector, zero-padded and grouped", async () => { // SHA-256 of bytes 00..09 is 1F825AA2F002…; the 0x02/0x0E/0x0D bytes only // land in the right group if each byte is zero-padded to two hex chars. const fp = await computeRawKeyFingerprint(new Uint8Array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9])); expect(fp).toBe("1F82 5AA2 F002 0EF7 CF91 DFA3 0DA4 668D"); }); it("formats the fingerprint as 8 space-separated 4-char hex groups", async () => { const { publicKey } = await generateECDHKeyPair(); const fp = await computeKeyFingerprint(publicKey); expect(fp).toMatch(/^([0-9A-F]{4} ){7}[0-9A-F]{4}$/); }); }); // ── exportPublicKey / importPublicKey round-trip ────────────────────────── describe("exportPublicKey / importPublicKey", () => { it("round-trips a public key through base64", async () => { const alice = await generateECDHKeyPair(); const bob = await generateECDHKeyPair(); // Export Bob's public key and re-import it const exported = await exportPublicKey(bob.publicKey); expect(() => atob(exported)).not.toThrow(); const reimported = await importPublicKey(exported); // The reimported key must produce the same fingerprint as the original const fpOriginal = await computeKeyFingerprint(bob.publicKey); const fpReimported = await computeKeyFingerprint(reimported); expect(fpOriginal).toBe(fpReimported); // The reimported key must be usable in a wrap/unwrap cycle const roomKey = generateRoomKey(); const { encryptedKey, iv } = await wrapRoomKey(alice.privateKey, reimported, roomKey, 1); const { roomKey: unwrapped } = await unwrapRoomKey( bob.privateKey, alice.publicKey, encryptedKey, iv, ); expect(unwrapped).toEqual(roomKey); }); }); // ── generateRoomKey entropy ─────────────────────────────────────────────── describe("generateRoomKey", () => { it("returns a 32-byte Uint8Array", () => { const key = generateRoomKey(); expect(key).toBeInstanceOf(Uint8Array); expect(key.byteLength).toBe(32); }); it("two calls return different keys", () => { const a = generateRoomKey(); const b = generateRoomKey(); // Compare as base64 strings for a simple deep-inequality check expect(roomKeyToBase64(a)).not.toBe(roomKeyToBase64(b)); }); }); // ── roomKeyToBase64 ─────────────────────────────────────────────────────── describe("roomKeyToBase64", () => { it("encodes a room key to a non-empty base64 string", () => { const key = generateRoomKey(); const encoded = roomKeyToBase64(key); expect(typeof encoded).toBe("string"); expect(encoded.length).toBeGreaterThan(0); expect(() => atob(encoded)).not.toThrow(); }); }); // ── Identity sign / verify (F3 TOFU) ─────────────────────────────────────── describe("signEphemeralKey / verifyEphemeralKeySignature", () => { const userId = 42; async function fixture() { const identity = await generateIdentityKeyPair(); const ephemeral = await generateECDHKeyPair(); const ephemeralRaw = new Uint8Array( await crypto.subtle.exportKey("raw", ephemeral.publicKey), ); const signature = await signEphemeralKey(identity.privateKey, userId, ephemeralRaw); return { identity, ephemeralRaw, signature }; } it("round-trips: a valid signature verifies against the identity public key", async () => { const { identity, ephemeralRaw, signature } = await fixture(); const ok = await verifyEphemeralKeySignature( identity.publicKey, userId, ephemeralRaw, signature, ); expect(ok).toBe(true); }); it("verifies against a public key re-imported from its base64 raw form", async () => { const { identity, ephemeralRaw, signature } = await fixture(); const pubBase64 = await exportPublicKey(identity.publicKey); const reimported = await importIdentityPublicKey(pubBase64); const ok = await verifyEphemeralKeySignature(reimported, userId, ephemeralRaw, signature); expect(ok).toBe(true); }); it("fails when the userId is tampered (server re-attribution)", async () => { const { identity, ephemeralRaw, signature } = await fixture(); const ok = await verifyEphemeralKeySignature( identity.publicKey, userId + 1, ephemeralRaw, signature, ); expect(ok).toBe(false); }); it("fails when the ephemeral key is substituted (server MITM)", async () => { const { identity, signature } = await fixture(); const other = await generateECDHKeyPair(); const otherRaw = new Uint8Array(await crypto.subtle.exportKey("raw", other.publicKey)); const ok = await verifyEphemeralKeySignature(identity.publicKey, userId, otherRaw, signature); expect(ok).toBe(false); }); it("fails when the signature bytes are tampered", async () => { const { identity, ephemeralRaw, signature } = await fixture(); const bytes = Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)); bytes[0] = bytes[0]! ^ 0xff; const tampered = btoa(String.fromCharCode(...bytes)); const ok = await verifyEphemeralKeySignature( identity.publicKey, userId, ephemeralRaw, tampered, ); expect(ok).toBe(false); }); it("returns false (not throw) on malformed base64 signature", async () => { const { identity, ephemeralRaw } = await fixture(); const ok = await verifyEphemeralKeySignature( identity.publicKey, userId, ephemeralRaw, "not valid base64 !!!", ); expect(ok).toBe(false); }); it("signs exactly ANNOUNCE_DOMAIN ‖ userId ‖ ephemeralPubRaw", async () => { const { identity, ephemeralRaw, signature } = await fixture(); const domain = new TextEncoder().encode("owncord-voice-e2ee-announce-v1"); const idBytes = new TextEncoder().encode(String(userId)); const message = new Uint8Array(domain.length + idBytes.length + ephemeralRaw.length); message.set(domain, 0); message.set(idBytes, domain.length); message.set(ephemeralRaw, domain.length + idBytes.length); const ok = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, identity.publicKey, Uint8Array.from(atob(signature), (c) => c.charCodeAt(0)), message, ); expect(ok).toBe(true); }); it("returns false (not true) when subtle.verify itself throws", async () => { const { ephemeralRaw, signature } = await fixture(); // An ECDH public key imported with no key usages: subtle.verify rejects // with InvalidAccessError rather than returning a boolean. const ecdh = await generateECDHKeyPair(); const wrongKind = await importPublicKey(await exportPublicKey(ecdh.publicKey)); const ok = await verifyEphemeralKeySignature(wrongKind, userId, ephemeralRaw, signature); expect(ok).toBe(false); }); it("fails against a different identity key (wrong signer)", async () => { const { ephemeralRaw, signature } = await fixture(); const attacker = await generateIdentityKeyPair(); const ok = await verifyEphemeralKeySignature( attacker.publicKey, userId, ephemeralRaw, signature, ); expect(ok).toBe(false); }); }); // ── Identity keypair persistence (keyring blob round-trip) ────────────────── describe("exportIdentityKeyPair / importIdentityKeyPair", () => { it("round-trips a keypair through the JWK blob and can still sign+verify", async () => { const original = await generateIdentityKeyPair(); const blob = await exportIdentityKeyPair(original.privateKey); const restored = await importIdentityKeyPair(blob); const ephemeral = await generateECDHKeyPair(); const ephemeralRaw = new Uint8Array( await crypto.subtle.exportKey("raw", ephemeral.publicKey), ); // Sign with the restored private key, verify with the restored public key. const sig = await signEphemeralKey(restored.privateKey, 7, ephemeralRaw); expect(await verifyEphemeralKeySignature(restored.publicKey, 7, ephemeralRaw, sig)).toBe( true, ); // The restored private key stays extractable, so it can be re-persisted. expect(await exportIdentityKeyPair(restored.privateKey)).toBe(blob); // Public key survives the round-trip identically (safety-number stability). const fpOriginal = await computeKeyFingerprint(original.publicKey); const fpRestored = await computeKeyFingerprint(restored.publicKey); expect(fpRestored).toBe(fpOriginal); }); }); // ── Identity fingerprint stability (safety number repoint) ────────────────── describe("computeKeyFingerprint on identity keys", () => { it("is stable across export/import of the identity public key", async () => { const identity = await generateIdentityKeyPair(); const base64 = await exportPublicKey(identity.publicKey); const reimported = await importIdentityPublicKey(base64); expect(await computeKeyFingerprint(reimported)).toBe( await computeKeyFingerprint(identity.publicKey), ); }); }); });