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.
This commit is contained in:
2026-10-03 19:35:36 +02:00
parent 43e4929b45
commit 328d415cfe
+180
View File
@@ -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<string, Map<unknown, unknown>>();
const later = (fn: () => void) => setTimeout(fn, 0);
const request = <T>(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 = <T>(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<E2ee> {
vi.resetModules();
return import("./e2ee");
}
const bundleOf = (k: Awaited<ReturnType<E2ee["setup"]>>): 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);
});