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