mtp/test/e2ee.mjs
Alex Emmet 08aa193fd1
Some checks failed
CI / checks (push) Failing after 2m23s
General Upgrade, NEW: WebServers, Better Docs
2026-07-18 03:21:44 +02:00

545 lines
18 KiB
JavaScript

import { initSync } from "../dist/raw/index.js";
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import assert from "node:assert/strict";
import { describe, it } from "node:test";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const wasmPath = path.resolve(__dirname, "../wasm/pkg/mtp_wasm_bg.wasm");
const wasmBytes = fs.readFileSync(wasmPath);
const wasmModule = new WebAssembly.Module(wasmBytes);
initSync(wasmModule);
const sdk = await import("../dist/sdk/index.js");
const { MTPRatchet } = await import("../dist/sdk/ratchet.js");
const {
serializeEncryptedMessage,
parseEncryptedMessage,
encryptPayload,
decryptPayload,
FLAG_INIT,
MTP_E2EE_VERSION,
} = await import("../dist/sdk/encrypted-message.js");
const {
MTPSessionManager,
InMemorySessionStorage,
deriveSessionKeys,
getConversationId,
} = await import("../dist/sdk/session.js");
const bindings = sdk.raw;
function concat(...arrays) {
const totalLen = arrays.reduce((sum, a) => sum + a.length, 0);
const result = new Uint8Array(totalLen);
let offset = 0;
for (const a of arrays) {
result.set(a, offset);
offset += a.length;
}
return result;
}
function setupSessions(sharedSecret, aliceId = 1n, bobId = 2n) {
const aliceStorage = new InMemorySessionStorage();
const aliceManager = new MTPSessionManager(aliceStorage);
const bobStorage = new InMemorySessionStorage();
const bobManager = new MTPSessionManager(bobStorage);
return {
aliceManager,
aliceStorage,
bobManager,
bobStorage,
async initSessions() {
const { initiatorSend, initiatorRecv } = await deriveSessionKeys(
sharedSecret,
new Uint8Array(0),
);
const aliceSession = await aliceManager.createSession({
ownClientId: aliceId,
peerClientId: bobId,
peerPublicKey: new Uint8Array(32),
sharedSecret,
role: "initiator",
});
const bobSession = await bobManager.createSession({
ownClientId: bobId,
peerClientId: aliceId,
peerPublicKey: new Uint8Array(32),
sharedSecret,
role: "receiver",
});
return { aliceSession, bobSession, initiatorSend, initiatorRecv };
},
};
}
await describe("E2EE Session Derivation", async () => {
await it("Both sides derive same shared secret", async () => {
const sharedSecret = sdk.crypto.sha256(new Uint8Array([1, 2, 3, 4, 5]));
const transcript = new Uint8Array(0);
const aliceKeys = await deriveSessionKeys(sharedSecret, transcript);
const bobKeys = await deriveSessionKeys(sharedSecret, transcript);
// Deterministic: same inputs → same outputs
assert.deepEqual(aliceKeys.initiatorSend, bobKeys.initiatorSend);
assert.deepEqual(aliceKeys.initiatorRecv, bobKeys.initiatorRecv);
// Init and recv keys are different
assert.notDeepEqual(aliceKeys.initiatorSend, aliceKeys.initiatorRecv);
});
await it("Session manager assigns correct chain keys per role", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([1]));
const { initSessions } = setupSessions(ss);
const { aliceSession, bobSession, initiatorSend, initiatorRecv } =
await initSessions();
// Alice (initiator): send = initiatorSend, recv = initiatorRecv
assert.deepEqual(aliceSession.sendChainKey, initiatorSend);
assert.deepEqual(aliceSession.recvChainKey, initiatorRecv);
// Bob (receiver): send = initiatorRecv, recv = initiatorSend
assert.deepEqual(bobSession.sendChainKey, initiatorRecv);
assert.deepEqual(bobSession.recvChainKey, initiatorSend);
// Alice's send chain = Bob's recv chain
assert.deepEqual(aliceSession.sendChainKey, bobSession.recvChainKey);
// Alice's recv chain = Bob's send chain
assert.deepEqual(aliceSession.recvChainKey, bobSession.sendChainKey);
});
await it("Different transcripts produce different keys", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([99]));
const aliceKeys1 = await deriveSessionKeys(ss, new Uint8Array(0));
const aliceKeys2 = await deriveSessionKeys(
ss,
sdk.crypto.sha256(new Uint8Array([42])),
);
assert.notDeepEqual(aliceKeys1.initiatorSend, aliceKeys2.initiatorSend);
});
});
await describe("E2EE Ratchet", async () => {
await it("Repeated sends produce different message keys", async () => {
const chainKey = sdk.crypto.sha256(new Uint8Array([42]));
const step1 = await MTPRatchet.step(chainKey);
const step2 = await MTPRatchet.step(step1.chainKey);
const step3 = await MTPRatchet.step(step2.chainKey);
assert.notDeepEqual(step1.key, step2.key);
assert.notDeepEqual(step2.key, step3.key);
assert.notDeepEqual(step1.key, step3.key);
assert.notDeepEqual(chainKey, step1.chainKey);
});
await it("Receiver can decrypt messages sent by sender in order", async () => {
const chainKey = sdk.crypto.sha256(new Uint8Array([7]));
const send1 = await MTPRatchet.step(chainKey);
const send2 = await MTPRatchet.step(send1.chainKey);
const send3 = await MTPRatchet.step(send2.chainKey);
const recv1 = await MTPRatchet.step(chainKey);
const recv2 = await MTPRatchet.step(recv1.chainKey);
const recv3 = await MTPRatchet.step(recv2.chainKey);
assert.deepEqual(send1.key, recv1.key);
assert.deepEqual(send2.key, recv2.key);
assert.deepEqual(send3.key, recv3.key);
});
});
await describe("E2EE Serialization", async () => {
await it("Roundtrips a basic message", () => {
const msg = {
header: {
version: 1,
flags: 0,
senderClientId: 0x1234567890abcdefn,
recipientClientId: 0xfedcba0987654321n,
messageNumber: 42,
},
aeadPayload: new Uint8Array([1, 2, 3, 4, 5]),
};
const bytes = serializeEncryptedMessage(msg);
const parsed = parseEncryptedMessage(bytes);
assert.equal(parsed.header.version, 1);
assert.equal(parsed.header.flags, 0);
assert.equal(parsed.header.senderClientId, msg.header.senderClientId);
assert.equal(parsed.header.recipientClientId, msg.header.recipientClientId);
assert.equal(parsed.header.messageNumber, 42);
assert.equal(parsed.header.kemCiphertext, undefined);
assert.deepEqual(parsed.aeadPayload, msg.aeadPayload);
});
await it("Roundtrips an init message with KEM ciphertext", () => {
const msg = {
header: {
version: 1,
flags: FLAG_INIT,
senderClientId: 1n,
recipientClientId: 2n,
messageNumber: 0,
kemCiphertext: new Uint8Array([0xde, 0xad, 0xbe, 0xef]),
},
aeadPayload: new Uint8Array([10, 20, 30]),
};
const bytes = serializeEncryptedMessage(msg);
const parsed = parseEncryptedMessage(bytes);
assert.equal(parsed.header.flags & FLAG_INIT, FLAG_INIT);
assert.deepEqual(parsed.header.kemCiphertext, msg.header.kemCiphertext);
});
await it("Roundtrip: serialize(parse(x)) === x", () => {
const msg = {
header: {
version: 1,
flags: 0,
senderClientId: 0xaaaabbbbccccddddn,
recipientClientId: 0xffff000011112222n,
messageNumber: 65535,
},
aeadPayload: new Uint8Array(100).fill(0x42),
};
const bytes = serializeEncryptedMessage(msg);
const parsed = parseEncryptedMessage(bytes);
const bytes2 = serializeEncryptedMessage(parsed);
assert.deepEqual(bytes, bytes2);
});
await it("Rejects malformed payloads", () => {
assert.throws(() => parseEncryptedMessage(new Uint8Array(0)));
assert.throws(() => parseEncryptedMessage(new Uint8Array([0x01])));
assert.throws(() =>
parseEncryptedMessage(
new Uint8Array([0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
),
);
});
await it("Rejects unsupported version", () => {
const msg = {
header: {
version: 1,
flags: 0,
senderClientId: 0n,
recipientClientId: 0n,
messageNumber: 0,
},
aeadPayload: new Uint8Array([1]),
};
const bytes = serializeEncryptedMessage(msg);
bytes[0] = 99;
assert.throws(() => parseEncryptedMessage(bytes));
});
await it("Rejects trailing data", () => {
const msg = {
header: {
version: 1,
flags: 0,
senderClientId: 0n,
recipientClientId: 0n,
messageNumber: 0,
},
aeadPayload: new Uint8Array([1]),
};
const bytes = concat(
serializeEncryptedMessage(msg),
new Uint8Array([0xff]),
);
assert.throws(() => parseEncryptedMessage(bytes));
});
});
await describe("E2EE Encrypt/Decrypt", async () => {
await it("Alice encrypts and Bob decrypts successfully", async () => {
const keyring = sdk.crypto.generateKeyring();
const bobKeys = sdk.crypto.keyringToKeys(keyring);
// Alice encapsulates to Bob's KEM public key
const enc = sdk.crypto.encapsulate(bobKeys.kemPublicKey);
// Bob decapsulates the ciphertext
const bobSS = sdk.crypto.decapsulate(bobKeys.kemSecretKey, enc.ciphertext);
assert.deepEqual(bobSS, enc.shared_secret);
const ss = enc.shared_secret;
const { initSessions } = setupSessions(ss);
const { aliceSession, bobSession } = await initSessions();
// Alice encrypts a message to Bob
const plaintext = sdk.codec.encode(
"Ping",
{ Version: "hello from Alice" },
{ sender: 1n, receiver: 2n },
);
const { payload, session: aliceNewSession } = await encryptPayload({
plaintext,
session: aliceSession,
kemCiphertext: enc.ciphertext,
});
// Bob decrypts the message
const { plaintext: decrypted, session: bobNewSession } =
await decryptPayload({
payload,
session: bobSession,
});
const frame = sdk.codec.decode(decrypted);
assert.equal(frame.type, "Ping");
assert.equal(frame.data["Version"], "hello from Alice");
// Chain keys advanced correctly
assert.deepEqual(aliceNewSession.sendChainKey, bobNewSession.recvChainKey);
assert.equal(aliceNewSession.sendCount, 1);
assert.equal(bobNewSession.recvCount, 1);
assert.notDeepEqual(
aliceNewSession.sendChainKey,
aliceSession.sendChainKey,
);
});
await it("Encrypt-decrypt multiple messages with chain advance", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([1, 2, 3]));
const { initSessions } = setupSessions(ss);
let { aliceSession, bobSession } = await initSessions();
// Message 1
const { payload: p1, session: aliceAfter1 } = await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: "msg1" }),
session: aliceSession,
});
const { plaintext: d1, session: bobAfter1 } = await decryptPayload({
payload: p1,
session: bobSession,
});
assert.equal(sdk.codec.decode(d1).data["Version"], "msg1");
assert.equal(bobAfter1.recvCount, 1);
// Message 2
const { payload: p2, session: aliceAfter2 } = await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: "msg2" }),
session: aliceAfter1,
});
const { plaintext: d2, session: bobAfter2 } = await decryptPayload({
payload: p2,
session: bobAfter1,
});
assert.equal(sdk.codec.decode(d2).data["Version"], "msg2");
assert.equal(bobAfter2.recvCount, 2);
// Chain keys match after two messages
assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey);
assert.equal(aliceAfter2.sendCount, 2);
});
await it("decrypts messages delivered out of order exactly once", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([4, 5, 6]));
const { initSessions } = setupSessions(ss);
let { aliceSession, bobSession } = await initSessions();
const sent = [];
for (const label of ["first", "second", "third"]) {
const encrypted = await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: label }),
session: aliceSession,
});
sent.push(encrypted.payload);
aliceSession = encrypted.session;
}
const third = await decryptPayload({ payload: sent[2], session: bobSession });
bobSession = third.session;
assert.equal(sdk.codec.decode(third.plaintext).data["Version"], "third");
assert.equal(bobSession.recvCount, 3);
assert.deepEqual(
bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber),
[0, 1],
);
const first = await decryptPayload({ payload: sent[0], session: bobSession });
bobSession = first.session;
assert.equal(sdk.codec.decode(first.plaintext).data["Version"], "first");
assert.deepEqual(
bobSession.skippedMessageKeys.map(({ messageNumber }) => messageNumber),
[1],
);
const second = await decryptPayload({ payload: sent[1], session: bobSession });
bobSession = second.session;
assert.equal(sdk.codec.decode(second.plaintext).data["Version"], "second");
assert.equal(bobSession.skippedMessageKeys.length, 0);
await assert.rejects(
decryptPayload({ payload: sent[0], session: bobSession }),
/replay/,
);
});
});
await describe("E2EE Public Key Bundle", async () => {
await it("parses public key bundles from GetUserData.PublicKey", () => {
const keyring = sdk.crypto.generateKeyring();
const keys = sdk.crypto.keyringToKeys(keyring);
const bundle = concat(
new Uint8Array([keys.kemPublicKey.length >> 8, keys.kemPublicKey.length & 0xff]),
keys.kemPublicKey,
new Uint8Array([keys.sigPqPublicKey.length >> 8, keys.sigPqPublicKey.length & 0xff]),
keys.sigPqPublicKey,
new Uint8Array([keys.sigClPublicKey.length >> 8, keys.sigClPublicKey.length & 0xff]),
keys.sigClPublicKey,
);
const parsed = sdk.crypto.publicKeyBundleToKeys(bundle);
assert.deepEqual(parsed.kemPublicKey, keys.kemPublicKey);
assert.deepEqual(parsed.sigPqPublicKey, keys.sigPqPublicKey);
assert.deepEqual(parsed.sigClPublicKey, keys.sigClPublicKey);
});
});
await describe("E2EE Session Manager", async () => {
await it("getConversationId is consistent regardless of order", () => {
const id1 = getConversationId(5n, 10n);
const id2 = getConversationId(10n, 5n);
assert.equal(id1, id2);
});
await it("MTPSessionManager creates, retrieves, and deletes sessions", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([1, 2, 3]));
const storage = new InMemorySessionStorage();
const manager = new MTPSessionManager(storage);
assert.equal(await manager.getSession(1n, 2n), null);
const session = await manager.createSession({
ownClientId: 1n,
peerClientId: 2n,
peerPublicKey: new Uint8Array(32),
sharedSecret: ss,
role: "initiator",
});
assert.equal(session.version, 1);
assert.equal(session.sendCount, 0);
assert.equal(session.recvCount, 0);
await manager.saveSession(session);
const retrieved = await manager.getSession(1n, 2n);
assert.notEqual(retrieved, null);
assert.equal(retrieved.conversationId, session.conversationId);
await manager.deleteSession(1n, 2n);
assert.equal(await manager.getSession(1n, 2n), null);
});
});
await describe("E2EE Full Flow: KEM + Session + Ratchet + AEAD", async () => {
await it("Alice encapsulates to Bob, both derive matching sessions, encrypt-decrypt works", async () => {
// Bob generates keyring
const bobKeyring = sdk.crypto.generateKeyring();
const bobKeys = sdk.crypto.keyringToKeys(bobKeyring);
// Alice encapsulates to Bob's KEM public key
const enc = sdk.crypto.encapsulate(bobKeys.kemPublicKey);
// Bob decapsulates
const bobSharedSecret = sdk.crypto.decapsulate(
bobKeys.kemSecretKey,
enc.ciphertext,
);
assert.deepEqual(bobSharedSecret, enc.shared_secret);
const ss = enc.shared_secret;
const { initSessions } = setupSessions(ss);
const { aliceSession, bobSession } = await initSessions();
// Alice sends encrypted init message with KEM ciphertext
const msg1 = new Uint8Array([0x48, 0x65, 0x6c, 0x6c, 0x6f]); // "Hello"
const { payload: p1, session: aliceAfter1 } = await encryptPayload({
plaintext: msg1,
session: aliceSession,
kemCiphertext: enc.ciphertext,
});
// Bob receives and decrypts
const { plaintext: d1, session: bobAfter1 } = await decryptPayload({
payload: p1,
session: bobSession,
});
assert.deepEqual(d1, msg1);
assert.deepEqual(aliceAfter1.sendChainKey, bobAfter1.recvChainKey);
assert.equal(aliceAfter1.sendCount, 1);
assert.equal(bobAfter1.recvCount, 1);
// Second message (no KEM ciphertext)
const msg2 = new Uint8Array([0x57, 0x6f, 0x72, 0x6c, 0x64]); // "World"
const { payload: p2, session: aliceAfter2 } = await encryptPayload({
plaintext: msg2,
session: aliceAfter1,
});
const { plaintext: d2, session: bobAfter2 } = await decryptPayload({
payload: p2,
session: bobAfter1,
});
assert.deepEqual(d2, msg2);
assert.deepEqual(aliceAfter2.sendChainKey, bobAfter2.recvChainKey);
assert.equal(aliceAfter2.sendCount, 2);
assert.equal(bobAfter2.recvCount, 2);
});
});
await describe("E2EE Tamper Detection", async () => {
await it("Rejects modified ciphertext", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([42]));
const { initSessions } = setupSessions(ss);
const { aliceSession, bobSession } = await initSessions();
const plaintext = new Uint8Array([0x01, 0x02, 0x03]);
const { payload } = await encryptPayload({
plaintext,
session: aliceSession,
});
// Tamper with AEAD payload
const tampered = new Uint8Array(payload);
tampered[tampered.length - 1] ^= 0xff;
await assert.rejects(
() => decryptPayload({ payload: tampered, session: bobSession }),
/decrypt failed/,
);
});
await it("Rejects out-of-order message numbers", async () => {
const ss = sdk.crypto.sha256(new Uint8Array([7]));
const { initSessions } = setupSessions(ss);
const { aliceSession, bobSession } = await initSessions();
// Send two messages
const { payload: p1, session: aliceAfter1 } = await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: "a" }),
session: aliceSession,
});
await encryptPayload({
plaintext: sdk.codec.encode("Ping", { Version: "b" }),
session: aliceAfter1,
});
// Bob decrypts p1
const { session: bobAfter1 } = await decryptPayload({
payload: p1,
session: bobSession,
});
// Now bob expects msgNumber 1, but we try to replay msgNumber 0
await assert.rejects(
() => decryptPayload({ payload: p1, session: bobAfter1 }),
/replay|out of order/,
);
});
});