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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user