monal-rebuild/test/omemo.test.ts

440 lines
22 KiB
TypeScript

// Run: npm test (Node strips the TypeScript types itself)
// Registers globalThis.indexedDB before anything imports src/store/idb.ts —
// Node has no IndexedDB of its own.
import "fake-indexeddb/auto";
import { test } from "node:test";
import assert from "node:assert/strict";
import { xml, type Element } from "@xmpp/client";
import { OmemoDB, type StoredPreKey } from "../src/crypto/omemo-db.ts";
import { OmemoStore } from "../src/crypto/omemo-store.ts";
import {
bootstrapIdentity,
decrypt,
encryptFor,
getTrustStatus,
keyFingerprintHex,
markVerified,
omemoSupported,
retractOwnDevices,
} from "../src/crypto/omemo.ts";
import { buildBundle, buildDeviceList, buildEncryptedMessage } from "../src/crypto/omemo-wire.ts";
import { composeReply, replyBody } from "../src/xmpp/replies.ts";
// A distinct account per test: fake-indexeddb keeps its databases per process.
const accounts = ["lukas@0ff.space", "anna@0ff.space", "bob@0ff.space", "clara@0ff.space", "dora@0ff.space", "ewa@0ff.space"];
const [lukas, anna, bob, clara, dora, ewa] = accounts;
// Distinct non-zero identity keys, for the trust-change checks.
const identityKey = (byte: number) => {
const b = new ArrayBuffer(33);
new Uint8Array(b)[0] = byte;
return b;
};
test("identity bootstrap is idempotent across repeated opens (relogin)", async () => {
const first = await bootstrapIdentity(lukas);
// A second open of omemo-db simulates logging back in on the same browser:
// the stored identity must come back unchanged, never freshly generated.
const second = await bootstrapIdentity(lukas);
assert.equal(second.registrationId, first.registrationId);
assert.equal(second.deviceId, first.deviceId);
assert.deepEqual(second.keyPair, first.keyPair);
// Third call, straight through a fresh OmemoDB handle, same identity again.
const db = await OmemoDB.open(lukas);
try {
assert.deepEqual(await db.getIdentity(), first);
} finally {
db.close();
}
});
test("keys round-trip through IndexedDB as plain structured-cloneable values", async () => {
await bootstrapIdentity(anna);
const db = await OmemoDB.open(anna);
try {
// Class instances and functions throw in structuredClone; the stored rows
// must be plain cloneable values, or later logout/relogin reads break.
const identity = await db.getIdentity();
assert.ok(identity);
const signedPreKeys = await db.getSignedPreKeys();
assert.equal(signedPreKeys.length, 1);
const preKeys = await db.getPreKeys();
assert.equal(preKeys.length, 100);
assert.ok(preKeys.every((k) => !k.used));
for (const row of [identity, signedPreKeys[0], preKeys[0], preKeys[99]]) {
assert.deepEqual(structuredClone(row), row);
}
} finally {
db.close();
}
});
test("OmemoStore adapts prekeys, signed prekeys, sessions and trust to OmemoDB", async () => {
const identity = await bootstrapIdentity(bob);
const db = await OmemoDB.open(bob);
try {
const store = new OmemoStore(db, "eu.siacs.conversations.axolotl");
assert.deepEqual(await store.getIdentityKeyPair(), identity.keyPair);
assert.equal(await store.getLocalRegistrationId(), identity.registrationId);
// One-time prekeys: store, load, and removed-after-use never reload.
// (Ids outside bootstrap's 1..100, so these rows are the test's own.)
const keyPair = { pubKey: identityKey(1), privKey: identityKey(2) };
await store.storePreKey(500, keyPair);
assert.deepEqual((await store.loadPreKey(500))?.keyPair, keyPair);
assert.equal(await store.loadPreKey(501), undefined);
await store.removePreKey(500);
assert.equal(await store.loadPreKey(500), undefined);
// Signed prekeys: the bootstrap ones load through the adapter.
const spk = await store.loadSignedPreKey(1);
assert.ok(spk);
assert.deepEqual(spk.keyPair, (await db.getSignedPreKey(1))?.keyPair);
// Sessions: plain serialized strings, keyed by the library's own
// `${bareJid}.${deviceId}` address, dotted domains included.
const record = JSON.stringify({ opaque: "ratchet state" });
await store.storeSession("anna@0ff.space.co.uk.42", record);
assert.equal(await store.loadSession("anna@0ff.space.co.uk.42"), record);
assert.equal(await store.loadSession("anna@0ff.space.co.uk.43"), undefined);
await store.storeSession("anna@0ff.space.co.uk.43", record);
await store.removeAllSessions("anna@0ff.space.co.uk");
assert.equal(await store.loadSession("anna@0ff.space.co.uk.42"), undefined);
assert.equal(await store.loadSession("anna@0ff.space.co.uk.43"), undefined);
// Trust: silent TOFU — first key pins silently; isTrustedIdentity never
// blocks, even against a changed key (OMEMO-PLAN.md, Trust model: warn,
// don't block — the library turns `false` into a thrown "Identity key
// changed" otherwise). saveIdentity is what actually detects and
// reports a change.
const first = identityKey(5);
assert.equal(await store.isTrustedIdentity("anna@0ff.space.42", first, 2), true);
assert.equal(await store.saveIdentity("anna@0ff.space.42", first), false);
assert.equal(await store.isTrustedIdentity("anna@0ff.space.42", first, 2), true);
const changed = identityKey(6);
assert.equal(await store.isTrustedIdentity("anna@0ff.space.42", changed, 2), true);
assert.equal(await store.saveIdentity("anna@0ff.space.42", changed), true);
assert.deepEqual(await store.loadIdentityKey("anna@0ff.space.42"), changed);
} finally {
db.close();
}
});
test("one store per namespace: same address, never the same session or trust", async () => {
await bootstrapIdentity(clara);
const db = await OmemoDB.open(clara);
try {
const legacy = new OmemoStore(db, "eu.siacs.conversations.axolotl");
const omemo2 = new OmemoStore(db, "urn:xmpp:omemo:2");
const address = "anna@0ff.space.42";
await legacy.storeSession(address, "legacy ratchet");
await omemo2.storeSession(address, "omemo2 ratchet");
assert.equal(await legacy.loadSession(address), "legacy ratchet");
assert.equal(await omemo2.loadSession(address), "omemo2 ratchet");
await legacy.removeAllSessions("anna@0ff.space");
assert.equal(await legacy.loadSession(address), undefined);
assert.equal(await omemo2.loadSession(address), "omemo2 ratchet");
// Pinned trust is per namespace too: legacy's pin is untouched by
// omemo2's calls, and omemo2 never pinned anything at all. (Both
// isTrustedIdentity calls return true regardless — it never blocks.)
const first = identityKey(5);
await legacy.saveIdentity(address, first);
assert.equal(await omemo2.isTrustedIdentity(address, identityKey(9), 2), true);
assert.equal(await legacy.isTrustedIdentity(address, identityKey(9), 2), true);
assert.deepEqual(await legacy.loadIdentityKey(address), first);
assert.equal(await omemo2.loadIdentityKey(address), undefined);
} finally {
db.close();
}
});
test("trust status: tofu -> changed -> verified, and a further change re-flags it", async () => {
await bootstrapIdentity(ewa + "-trust");
const db = await OmemoDB.open(ewa + "-trust");
try {
const store = new OmemoStore(db, "eu.siacs.conversations.axolotl");
const peer = "bob@0ff.space";
const deviceId = 7;
const address = `${peer}.${deviceId}`;
assert.equal(await getTrustStatus(db, peer, deviceId), undefined); // nothing pinned yet
await store.saveIdentity(address, identityKey(1));
assert.equal((await getTrustStatus(db, peer, deviceId))?.status, "tofu");
await markVerified(db, peer, deviceId);
assert.equal((await getTrustStatus(db, peer, deviceId))?.status, "verified");
// A real key change always re-flags "changed", even over "verified" —
// verifying key A says nothing about a later, different key B.
await store.saveIdentity(address, identityKey(2));
assert.equal((await getTrustStatus(db, peer, deviceId))?.status, "changed");
// markVerified on a device with no pinned key at all is a safe no-op.
await markVerified(db, "nobody@0ff.space", 1);
assert.equal(await getTrustStatus(db, "nobody@0ff.space", 1), undefined);
} finally {
db.close();
}
});
test("keyFingerprintHex is a stable, readable, grouped hex string", () => {
const key = identityKey(10);
const hex = keyFingerprintHex(key);
assert.equal(hex, keyFingerprintHex(key)); // stable for the same input
assert.match(hex, /^[0-9A-F]{1,8}( [0-9A-F]{1,8})*$/); // uppercase, grouped in 8s
assert.notEqual(keyFingerprintHex(identityKey(11)), hex); // distinct keys, distinct output
});
test("keyFingerprintHex drops the 0x05 type byte, as Monal and Conversations do", () => {
// A real Curve25519 identity key: the 0x05 DJB type byte then 32 key
// bytes. Confirmed 2026-10-07 against Monal on iPhone, which displayed a
// fingerprint starting DA02CF0F for a stored key starting 05DA02CF —
// i.e. the type byte is not shown. Rendering all 33 bytes put every
// character one byte out of step with the other app, so a user comparing
// them out of band would read "doesn't match" for identical keys.
const bytes = new Uint8Array(33);
bytes[0] = 0x05;
for (let i = 1; i < 33; i++) bytes[i] = i;
const hex = keyFingerprintHex(bytes.buffer);
assert.equal(hex, "01020304 05060708 090A0B0C 0D0E0F10 11121314 15161718 191A1B1C 1D1E1F20");
// Exactly 8 groups of 8: no stray short group, which is what a trailing
// 33rd byte produced and the clearest tell that the format was wrong.
assert.equal(hex.split(" ").length, 8);
assert.ok(hex.split(" ").every((g) => g.length === 8));
assert.equal(hex.replace(/ /g, "").length, 64);
// A key that is not 33 bytes, or does not start with 0x05, is rendered
// whole: never silently eat a byte of something we don't recognise.
const odd = new Uint8Array([0x04, 0xaa, 0xbb]);
assert.equal(keyFingerprintHex(odd.buffer), "04AABB");
});
test("omemoSupported reports true when WebAssembly actually works (this environment)", async () => {
// Node has no CSP, so the real, intended check — does WebAssembly.instantiate
// throw? — must pass here. The *blocked* branch (a CSP throwing) isn't
// separately unit-tested: omemoSupported() memoizes its result at module
// level for the lifetime of the page (deliberately — WASM support doesn't
// change mid-session), which in this test file's single process would
// permanently poison every later test that needs real OMEMO crypto
// (encryptFor/decrypt) if exercised here first. The gating logic itself —
// `if (!(await omemoSupported())) return [];` in cachedDeviceList — is
// trivial enough to trust from code review; this test only confirms the
// non-degraded path isn't itself broken.
assert.equal(await omemoSupported(), true);
});
// A fake PEP service for `encryptFor`/`decrypt`'s own fetch calls
// (fetchDeviceList/fetchBundle), so Phase 3's send-path crypto can be
// round-tripped end to end without a real XMPP connection — the only way
// to verify `encryptFor`'s wire format locally, short of a live Monal.
const NS_PUBSUB = "http://jabber.org/protocol/pubsub";
class FakeOmemoNetwork {
#devicelists = new Map<string, number[]>();
#bundles = new Map<string, Element>(); // key: `${bareJid}:${deviceId}`
/** Publish one device's bundle directly — e.g. a second device on an
* already-registered account (its own OmemoDB, same bare JID). */
registerDevice(bareJid: string, deviceId: number, bundle: Element): void {
this.#bundles.set(`${bareJid}:${deviceId}`, bundle);
this.#devicelists.set(bareJid, [...(this.#devicelists.get(bareJid) ?? []), deviceId]);
}
/** Bootstrap a fresh identity for `bareJid` in `db` and publish its bundle. */
async register(db: OmemoDB, bareJid: string): Promise<number> {
const identity = await bootstrapIdentity(bareJid);
const [signedPreKey] = await db.getSignedPreKeys();
const preKeys = (await db.getPreKeys()).filter((k) => !k.used);
const bundle = buildBundle(
identity.keyPair.pubKey,
{ keyId: signedPreKey.keyId, publicKey: signedPreKey.keyPair.pubKey, signature: signedPreKey.signature! },
preKeys.map((k: StoredPreKey) => ({ keyId: k.keyId, publicKey: k.keyPair.pubKey })),
);
this.registerDevice(bareJid, identity.deviceId, bundle);
return identity.deviceId;
}
iqCaller = {
get: async (stanza: Element, to: string): Promise<Element> => {
const node = stanza.getChild("items")?.attrs.node as string;
let payload: Element | undefined;
if (node === "eu.siacs.conversations.axolotl.devicelist") {
payload = buildDeviceList(this.#devicelists.get(to) ?? []);
} else {
const m = /^eu\.siacs\.conversations\.axolotl\.bundles:(\d+)$/.exec(node ?? "");
if (m) payload = this.#bundles.get(`${to}:${m[1]}`);
}
if (!payload) {
const err = new Error("item-not-found") as Error & { condition: string };
err.condition = "item-not-found";
throw err;
}
return xml("pubsub", { xmlns: NS_PUBSUB }, xml("items", { node }, xml("item", { id: "current" }, payload)));
},
// Only what retractOwnDevices needs: republishing a device list, and
// deleting a bundle node (pubsub#owner).
set: async (stanza: Element, to: string): Promise<Element> => {
const publish = stanza.getChild("publish");
if (publish?.attrs.node === "eu.siacs.conversations.axolotl.devicelist") {
const list = publish.getChild("item")?.getChild("list");
const ids = (list?.getChildren("device") ?? []).map((d) => Number(d.attrs.id));
this.#devicelists.set(to, ids);
this.publishedItemIds.push(String(publish.getChild("item")?.attrs.id));
}
const del = stanza.getChild("delete");
if (del?.attrs.node) this.deletedNodes.push(String(del.attrs.node));
return xml("iq");
},
};
/** Item ids the device list was republished under, in order. */
publishedItemIds: string[] = [];
/** Bundle nodes retractOwnDevices asked the server to delete. */
deletedNodes: string[] = [];
devicesOf(bareJid: string): number[] {
return this.#devicelists.get(bareJid) ?? [];
}
}
test("encryptFor/decrypt round-trip real plaintext through the Double Ratchet + AES-GCM, no network", async () => {
const net = new FakeOmemoNetwork();
const doraDb = await OmemoDB.open(dora);
const ewaDb = await OmemoDB.open(ewa);
try {
const doraDeviceId = await net.register(doraDb, dora);
const ewaDeviceId = await net.register(ewaDb, ewa);
const envelope = await encryptFor(net as any, doraDb, dora, doraDeviceId, ewa, "hello ewa, this is dora");
// The wire format never carries the plaintext or the raw AES key.
assert.equal(envelope.keys.length, 1); // ewa's one device only; dora has no other device
assert.equal(envelope.keys[0].deviceId, ewaDeviceId);
assert.equal(envelope.keys[0].prekey, true); // first message on a brand-new session
const plaintext = await decrypt(ewaDb, dora, doraDeviceId, ewaDeviceId, envelope);
assert.equal(new TextDecoder().decode(plaintext), "hello ewa, this is dora");
const quote = composeReply("encrypted answer 🎉", { key: "quote", peer: ewa, id: "original-id", to: ewa, text: "private original 🐱", encrypted: true });
const replyEnvelope = await encryptFor(net as any, doraDb, dora, doraDeviceId, ewa, quote.body);
const stanza = xml("message", null, xml("body", null, "This message is encrypted"), buildEncryptedMessage(replyEnvelope), ...quote.elements);
assert.ok(!stanza.toString().includes("private original"));
assert.ok(!stanza.toString().includes("encrypted answer"));
const monalText = new TextDecoder().decode(await decrypt(ewaDb, dora, doraDeviceId, ewaDeviceId, replyEnvelope));
assert.equal(monalText, quote.body); // Monal's legacy parser displays this text unchanged.
assert.deepEqual(replyBody(stanza, monalText, { id: "original-id", to: ewa }), { body: "encrypted answer 🎉", reply: quote.reply });
} finally {
doraDb.close();
ewaDb.close();
}
});
test("encryptFor wraps a key for the sender's own other device too", async () => {
const net = new FakeOmemoNetwork();
const account = dora + "2";
const doraDb = await OmemoDB.open(account); // the "web" device sending the message
const ewaDb = await OmemoDB.open(ewa + "2");
const doraPhoneDb = await OmemoDB.open(account + "-phone"); // a second device, same account
try {
const doraDeviceId = await net.register(doraDb, account);
const ewaDeviceId = await net.register(ewaDb, ewa + "2");
// The phone's identity lives in its own OmemoDB (a distinct device), but
// is registered under dora's *same bare JID* in the fake network — what
// a real second device publishing its own bundle to the shared
// devicelist node looks like.
const phoneIdentity = await bootstrapIdentity(account + "-phone");
const [phoneSignedPreKey] = await doraPhoneDb.getSignedPreKeys();
const phonePreKeys = (await doraPhoneDb.getPreKeys()).filter((k) => !k.used);
const phoneBundle = buildBundle(
phoneIdentity.keyPair.pubKey,
{ keyId: phoneSignedPreKey.keyId, publicKey: phoneSignedPreKey.keyPair.pubKey, signature: phoneSignedPreKey.signature! },
phonePreKeys.map((k) => ({ keyId: k.keyId, publicKey: k.keyPair.pubKey })),
);
net.registerDevice(account, phoneIdentity.deviceId, phoneBundle);
const envelope = await encryptFor(net as any, doraDb, account, doraDeviceId, ewa + "2", "hi from the web client");
// Two recipients: ewa's device and dora's own phone — never dora's own
// sending device.
assert.deepEqual(
envelope.keys.map((k) => k.deviceId).sort((a, b) => a - b),
[ewaDeviceId, phoneIdentity.deviceId].sort((a, b) => a - b),
);
// The phone decrypts its own wrapped key and reads back the exact text.
const plaintext = await decrypt(doraPhoneDb, account, doraDeviceId, phoneIdentity.deviceId, envelope);
assert.equal(new TextDecoder().decode(plaintext), "hi from the web client");
} finally {
doraDb.close();
ewaDb.close();
doraPhoneDb.close();
}
});
test("retractOwnDevices drops dead devices but never the one in use", async () => {
const net = new FakeOmemoNetwork();
const me = "gil@example.invalid";
// Three stale ids from earlier browser profiles, plus the device in use.
const inUse = 4242;
net.registerDevice(me, 111, xml("bundle"));
net.registerDevice(me, 222, xml("bundle"));
net.registerDevice(me, inUse, xml("bundle"));
// `inUse` is passed in the removal list too: a caller must not be able to
// retract the device it is running on, which would go silently unreachable.
const removed = await retractOwnDevices(net as any, me, [111, 222, inUse], inUse);
assert.deepEqual(removed.sort(), [111, 222]);
assert.deepEqual(net.devicesOf(me), [inUse]);
// Republished in place, under the item id every deployed client uses.
assert.deepEqual(net.publishedItemIds, ["current"]);
// Each retracted device's bundle node is cleaned up as well.
assert.deepEqual(net.deletedNodes.sort(), [
"eu.siacs.conversations.axolotl.bundles:111",
"eu.siacs.conversations.axolotl.bundles:222",
]);
});
test("retractOwnDevices is a no-op when there is nothing stale to remove", async () => {
const net = new FakeOmemoNetwork();
const me = "hana@example.invalid";
net.registerDevice(me, 777, xml("bundle"));
assert.deepEqual(await retractOwnDevices(net as any, me, [777], 777), []);
assert.deepEqual(await retractOwnDevices(net as any, me, [], 777), []);
// Nothing published, nothing deleted: an unchanged list must not be
// republished, which would churn every contact's devicelist subscription.
assert.deepEqual(net.publishedItemIds, []);
assert.deepEqual(net.deletedNodes, []);
assert.deepEqual(net.devicesOf(me), [777]);
});
test("device additions and removals change wrapped targets while preserving decryptable ratchets", async () => {
const net = new FakeOmemoNetwork();
const account = "target-a@0ff.space", peer = "target-b@0ff.space";
const a = await OmemoDB.open(account), b = await OmemoDB.open(peer);
const bNew = await OmemoDB.open(peer + "-new"), aNew = await OmemoDB.open(account + "-new");
const { acceptDeviceListEvent, cachedDeviceList, invalidateDeviceLists } = await import('../src/crypto/omemo.ts');
const add = async (db: OmemoDB, storage: string, publisher: string) => {
const identity = await bootstrapIdentity(storage);
const spk = (await db.getSignedPreKeys())[0];
net.registerDevice(publisher, identity.deviceId, buildBundle(identity.keyPair.pubKey,
{keyId:spk.keyId, publicKey:spk.keyPair.pubKey, signature:spk.signature!},
(await db.getPreKeys()).map(k => ({keyId:k.keyId, publicKey:k.keyPair.pubKey}))));
return identity.deviceId;
};
try {
const aid = await net.register(a, account), bid = await net.register(b, peer);
await cachedDeviceList(net as any, a, peer);
const newBid = await add(bNew, peer + '-new', peer);
await acceptDeviceListEvent(net as any, a, peer, [bid,newBid], true);
const first = await encryptFor(net as any, a, account, aid, peer, 'new B can read');
assert.equal(new TextDecoder().decode(await decrypt(bNew, account, aid, newBid, first)), 'new B can read');
const newAid = await add(aNew, account + '-new', account);
await acceptDeviceListEvent(net as any, a, account, [aid,newAid], true);
await acceptDeviceListEvent(net as any, a, peer, [newBid], true);
const second = await encryptFor(net as any, a, account, aid, peer, 'removed B omitted');
assert.deepEqual(second.keys.map(k=>k.deviceId).sort(), [newBid,newAid].sort());
assert.equal(new TextDecoder().decode(await decrypt(bNew, account, aid, newBid, second)), 'removed B omitted');
assert.equal(new TextDecoder().decode(await decrypt(aNew, account, aid, newAid, second)), 'removed B omitted');
invalidateDeviceLists(net as any);
const third = await encryptFor(net as any, a, account, aid, peer, 'reconnected');
assert.equal(new TextDecoder().decode(await decrypt(bNew, account, aid, newBid, third)), 'reconnected');
} finally {a.close();b.close();bNew.close();aNew.close();}
});