From 328d415cfe1084fa84e13d8070e221ed04143eba Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 3 Oct 2026 19:35:36 +0200 Subject: [PATCH] test(e2ee): the DM crypto round-trips, refuses the wrong key, and keeps its nonces apart lib/e2ee was 14% covered and fails silently in every way it can break. Real WebCrypto and an in-memory IndexedDB: a message decrypts for its partner and nobody else, every message gets its own IV, a new device unlocks with the passphrase and refuses a wrong one, the key survives a reload until reset, a partner's rotated key is used only after forgetConv, and the key check never shares the wrap IV (GCM nonce reuse). Mutations seen red: the shared IV, a no-op forgetConv, no persist. --- frontend/src/lib/e2ee.test.ts | 180 ++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 frontend/src/lib/e2ee.test.ts diff --git a/frontend/src/lib/e2ee.test.ts b/frontend/src/lib/e2ee.test.ts new file mode 100644 index 00000000..c7b3ac01 --- /dev/null +++ b/frontend/src/lib/e2ee.test.ts @@ -0,0 +1,180 @@ +// E32 S2: lib/e2ee was 14% covered, and every way it can break is silent — a message that will not +// decrypt, a passphrase that unlocks nothing, a key that does not survive a reload. Real WebCrypto +// (Node's), a minimal in-memory IndexedDB (the module uses open / one store / get / put / delete), +// and a fresh module per "device", because the unlocked key is module state. +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { MyKeyBundle } from "./api"; + +type E2ee = typeof import("./e2ee"); + +function memoryIndexedDb() { + const stores = new Map>(); + const later = (fn: () => void) => setTimeout(fn, 0); + const request = (run: () => T) => { + const r: { result?: T; onsuccess?: () => void; onerror?: () => void } = {}; + later(() => { + r.result = run(); + r.onsuccess?.(); + }); + return r; + }; + const db = { + createObjectStore: (name: string) => stores.set(name, new Map()), + transaction: (name: string) => { + const tx: { oncomplete?: () => void; onerror?: () => void; objectStore: () => unknown } = { + objectStore: () => { + const s = stores.get(name)!; + const done = (v: T) => (later(() => tx.oncomplete?.()), v); + return { + get: (k: unknown) => request(() => s.get(k)), + put: (v: unknown, k: unknown) => done(s.set(k, v)), + delete: (k: unknown) => done(s.delete(k)), + }; + }, + }; + return tx; + }, + }; + return { + open: () => { + const r: { result?: unknown; onupgradeneeded?: () => void; onsuccess?: () => void } = {}; + later(() => { + r.result = db; + if (stores.size === 0) r.onupgradeneeded?.(); + r.onsuccess?.(); + }); + return r; + }, + }; +} + +async function freshDevice(): Promise { + vi.resetModules(); + return import("./e2ee"); +} + +const bundleOf = (k: Awaited>): MyKeyBundle => + ({ ...k, has_key: true }) as unknown as MyKeyBundle; + +beforeEach(() => { + vi.stubGlobal("indexedDB", memoryIndexedDb()); +}); +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("e2ee", () => { + it("a message one user encrypts, the other decrypts — and nobody else", async () => { + const alice = await freshDevice(); + const aliceKey = await alice.setup(1, "alice passphrase"); + const bob = await freshDevice(); + const bobKey = await bob.setup(2, "bob passphrase"); + + const msg = await alice.encryptFor(2, bobKey.public_key, "szia, Bob — ő 🎬"); + expect(msg.ciphertext).not.toContain("Bob"); + expect(await bob.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).toBe( + "szia, Bob — ő 🎬", + ); + + const eve = await freshDevice(); + await eve.setup(3, "eve passphrase"); + await expect(eve.decryptFrom(1, aliceKey.public_key, msg.ciphertext, msg.iv)).rejects.toThrow(); + }, 30_000); + + it("each message gets its own IV", async () => { + const alice = await freshDevice(); + await alice.setup(1, "p"); + const bob = await freshDevice(); + const bobKey = await bob.setup(2, "q"); + const a = await alice.encryptFor(2, bobKey.public_key, "same"); + const b = await alice.encryptFor(2, bobKey.public_key, "same"); + expect(a.iv).not.toBe(b.iv); + expect(a.ciphertext).not.toBe(b.ciphertext); + }, 30_000); + + it("a new device unlocks with the passphrase and refuses a wrong one", async () => { + const first = await freshDevice(); + const bundle = bundleOf(await first.setup(1, "the right one")); + + vi.stubGlobal("indexedDB", memoryIndexedDb()); // another browser: nothing stored + const second = await freshDevice(); + expect(await second.loadFromDevice(1)).toBe(false); + await expect(second.unlock(1, "a wrong one", bundle)).rejects.toThrow(); + expect(second.isUnlocked()).toBe(false); + await second.unlock(1, "the right one", bundle); + expect(second.isUnlocked()).toBe(true); + }, 30_000); + + it("the unlocked key survives a reload on the same device, until it is reset", async () => { + const before = await freshDevice(); + await before.setup(7, "p"); + const after = await freshDevice(); // a reload: module state gone, IndexedDB kept + expect(after.isUnlocked()).toBe(false); + const seen = vi.fn(); + after.subscribeUnlock(seen); + expect(await after.loadFromDevice(7)).toBe(true); + expect(after.isUnlocked()).toBe(true); + expect(seen).toHaveBeenCalled(); + + await after.resetLocal(7); + expect(after.isUnlocked()).toBe(false); + const again = await freshDevice(); + expect(await again.loadFromDevice(7)).toBe(false); + }, 30_000); + + it("an incomplete bundle and a locked device are refused, not guessed around", async () => { + const e = await freshDevice(); + await expect(e.unlock(1, "p", { has_key: true } as unknown as MyKeyBundle)).rejects.toThrow( + "incomplete key bundle", + ); + await expect(e.encryptFor(2, "AAAA", "x")).rejects.toThrow("locked"); + }); + + it("the key check uses its own nonce, never the wrap IV (GCM nonce reuse)", async () => { + const e = await freshDevice(); + const k = await e.setup(1, "p"); + // Re-derive the wrap key the way the module does; then the key check must NOT open under the + // wrap IV (it would, were the nonce reused), while the wrapped key does. + const raw = (s: string) => Uint8Array.from(atob(s), (c) => c.charCodeAt(0)); + const base = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode("p"), + "PBKDF2", + false, + ["deriveKey"], + ); + const wrapKey = await crypto.subtle.deriveKey( + { name: "PBKDF2", salt: raw(k.salt), iterations: 600_000, hash: "SHA-256" }, + base, + { name: "AES-GCM", length: 256 }, + false, + ["decrypt"], + ); + const open = (ct: string) => + crypto.subtle.decrypt({ name: "AES-GCM", iv: raw(k.wrap_iv) }, wrapKey, raw(ct)); + await expect(open(k.wrapped_private_key)).resolves.toBeDefined(); + await expect(open(k.key_check)).rejects.toThrow(); + }, 30_000); + + it("a partner's rotated key is used only after forgetConv", async () => { + const alice = await freshDevice(); + const aliceKey = await alice.setup(1, "p"); + const bobOld = await freshDevice(); + const oldKey = await bobOld.setup(2, "q"); + await alice.encryptFor(2, oldKey.public_key, "warm the cache"); + + const bobNew = await freshDevice(); // Bob reset his passphrase: a new keypair + const newKey = await bobNew.setup(2, "r"); + // Cached by partner id alone: without forgetConv the OLD conversation key is still used… + const stale = await alice.encryptFor(2, newKey.public_key, "after the reset"); + await expect( + bobNew.decryptFrom(1, aliceKey.public_key, stale.ciphertext, stale.iv), + ).rejects.toThrow(); + // …and after it, the new one. + alice.forgetConv(2); + const fresh = await alice.encryptFor(2, newKey.public_key, "after the reset"); + expect(await bobNew.decryptFrom(1, aliceKey.public_key, fresh.ciphertext, fresh.iv)).toBe( + "after the reset", + ); + }, 60_000); +});