import "../olm-loader"; // eslint-disable-next-line no-restricted-imports import { EventEmitter } from "events"; import type { PkDecryption, PkSigning } from "@matrix-org/olm"; import { type IClaimOTKsResult, type MatrixClient } from "../../src/client"; import { Crypto } from "../../src/crypto"; import { MemoryCryptoStore } from "../../src/crypto/store/memory-crypto-store"; import { MockStorageApi } from "../MockStorageApi"; import { TestClient } from "../TestClient"; import { MatrixEvent } from "../../src/models/event"; import { Room } from "../../src/models/room"; import * as olmlib from "../../src/crypto/olmlib"; import { sleep } from "../../src/utils"; import { CRYPTO_ENABLED } from "../../src/client"; import { DeviceInfo } from "../../src/crypto/deviceinfo"; import { logger } from "../../src/logger"; import { DeviceVerification, MemoryStore } from "../../src"; import { RoomKeyRequestState } from "../../src/crypto/OutgoingRoomKeyRequestManager"; import { RoomMember } from "../../src/models/room-member"; import { type IStore } from "../../src/store"; import { type IRoomEncryption, type RoomList } from "../../src/crypto/RoomList"; import { EventShieldColour, EventShieldReason } from "../../src/crypto-api"; import { UserTrustLevel } from "../../src/crypto/CrossSigning"; import { type CryptoBackend } from "../../src/common-crypto/CryptoBackend"; import { type EventDecryptionResult } from "../../src/common-crypto/CryptoBackend"; import * as testData from "../test-utils/test-data"; import { KnownMembership } from "../../src/@types/membership"; import type { DeviceInfoMap } from "../../src/crypto/DeviceList"; const Olm = globalThis.Olm; function awaitEvent(emitter: EventEmitter, event: string): Promise { return new Promise((resolve) => { emitter.once(event, (result) => { resolve(result); }); }); } async function keyshareEventForEvent(client: MatrixClient, event: MatrixEvent, index?: number): Promise { const roomId = event.getRoomId()!; const eventContent = event.getWireContent(); const key = await client.crypto!.olmDevice.getInboundGroupSessionKey( roomId, eventContent.sender_key, eventContent.session_id, index, ); const ksEvent = new MatrixEvent({ type: "m.forwarded_room_key", sender: client.getUserId()!, content: { "algorithm": olmlib.MEGOLM_ALGORITHM, "room_id": roomId, "sender_key": eventContent.sender_key, "sender_claimed_ed25519_key": key?.sender_claimed_ed25519_key, "session_id": eventContent.session_id, "session_key": key?.key, "chain_index": key?.chain_index, "forwarding_curve25519_key_chain": key?.forwarding_curve25519_key_chain, "org.matrix.msc3061.shared_history": true, }, }); // make onRoomKeyEvent think this was an encrypted event // @ts-ignore private property ksEvent.senderCurve25519Key = "akey"; ksEvent.getWireType = () => "m.room.encrypted"; ksEvent.getWireContent = () => { return { algorithm: "m.olm.v1.curve25519-aes-sha2", }; }; return ksEvent; } function roomKeyEventForEvent(client: MatrixClient, event: MatrixEvent): MatrixEvent { const roomId = event.getRoomId(); const eventContent = event.getWireContent(); const key = client.crypto!.olmDevice.getOutboundGroupSessionKey(eventContent.session_id); const ksEvent = new MatrixEvent({ type: "m.room_key", sender: client.getUserId()!, content: { algorithm: olmlib.MEGOLM_ALGORITHM, room_id: roomId, session_id: eventContent.session_id, session_key: key.key, }, }); // make onRoomKeyEvent think this was an encrypted event // @ts-ignore private property ksEvent.senderCurve25519Key = event.getSenderKey(); ksEvent.getWireType = () => "m.room.encrypted"; ksEvent.getWireContent = () => { return { algorithm: "m.olm.v1.curve25519-aes-sha2", }; }; return ksEvent; } describe("Crypto", function () { if (!CRYPTO_ENABLED) { return; } beforeAll(function () { return Olm.init(); }); afterEach(() => { jest.useRealTimers(); }); it("Crypto exposes the correct olm library version", function () { expect(Crypto.getOlmVersion()[0]).toEqual(3); }); it("getVersion() should return the current version of the olm library", async () => { const client = new TestClient("@alice:example.com", "deviceid").client; await client.initLegacyCrypto(); const olmVersionTuple = Crypto.getOlmVersion(); expect(client.getCrypto()?.getVersion()).toBe( `Olm ${olmVersionTuple[0]}.${olmVersionTuple[1]}.${olmVersionTuple[2]}`, ); }); describe("encrypted events", function () { it("provides encryption information for events from unverified senders", async function () { const client = new TestClient("@alice:example.com", "deviceid").client; await client.initLegacyCrypto(); // unencrypted event const event = { getId: () => "$event_id", getSender: () => "@bob:example.com", getSenderKey: () => null, getWireContent: () => { return {}; }, } as unknown as MatrixEvent; let encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeFalsy(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toBe(null); // unknown sender (e.g. deleted device), forwarded megolm key (untrusted) event.getSenderKey = () => "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; event.getWireContent = () => { return { algorithm: olmlib.MEGOLM_ALGORITHM }; }; event.getForwardingCurve25519KeyChain = () => ["not empty"]; event.isKeySourceUntrusted = () => true; event.getClaimedEd25519Key = () => "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeFalsy(); expect(encryptionInfo.sender).toBeFalsy(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.GREY, shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, }); // known sender, megolm key from backup event.getForwardingCurve25519KeyChain = () => []; event.isKeySourceUntrusted = () => true; const device = new DeviceInfo("FLIBBLE"); device.keys["curve25519:FLIBBLE"] = "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI"; device.keys["ed25519:FLIBBLE"] = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; client.crypto!.deviceList.getDeviceByIdentityKey = () => device; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeFalsy(); expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeFalsy(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.GREY, shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, }); // known sender, trusted megolm key, but bad ed25519key event.isKeySourceUntrusted = () => false; device.keys["ed25519:FLIBBLE"] = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"; encryptionInfo = client.getEventEncryptionInfo(event); expect(encryptionInfo.encrypted).toBeTruthy(); expect(encryptionInfo.authenticated).toBeTruthy(); expect(encryptionInfo.sender).toBeTruthy(); expect(encryptionInfo.mismatchedSender).toBeTruthy(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.RED, shieldReason: EventShieldReason.MISMATCHED_SENDER_KEY, }); client.stopClient(); }); describe("provides encryption information for events from verified senders", function () { const testDeviceId = testData.BOB_TEST_DEVICE_ID; const testDevice = testData.BOB_SIGNED_TEST_DEVICE_DATA; let client: MatrixClient; beforeEach(async () => { client = new TestClient("@alice:example.com", "deviceid").client; await client.initLegacyCrypto(); // mock out the verification check client.crypto!.checkUserTrust = (userId) => new UserTrustLevel(true, false, false); }); afterEach(() => { client.stopClient(); }); async function buildEncryptedEvent( decryptionResult: Partial = {}, ): Promise { const mockCryptoBackend = { decryptEvent: async (event: MatrixEvent): Promise => { return { claimedEd25519Key: testDevice.keys["ed25519:" + testDeviceId], clearEvent: { room_id: "!room_id", type: "m.room.message", content: { body: "test" }, }, forwardingCurve25519KeyChain: [], senderCurve25519Key: testDevice.keys["curve25519:" + testDeviceId], ...decryptionResult, }; }, } as unknown as CryptoBackend; const event = new MatrixEvent({ event_id: "$event_id", sender: testData.BOB_TEST_USER_ID, type: "m.room.encrypted", content: { algorithm: "m.megolm.v1.aes-sha2" }, }); await event.attemptDecryption(mockCryptoBackend); return event; } it("unknown device", async () => { const event = await buildEncryptedEvent(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.GREY, shieldReason: EventShieldReason.UNKNOWN_DEVICE, }); }); it("known but unsigned device", async () => { client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { [testDeviceId]: { keys: testDevice.keys, algorithms: testDevice.algorithms, verified: DeviceVerification.Unverified, known: true, }, }); const event = await buildEncryptedEvent(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.RED, shieldReason: EventShieldReason.UNSIGNED_DEVICE, }); }); describe("known and verified device", () => { beforeEach(() => { client.crypto!.deviceList.storeDevicesForUser(testData.BOB_TEST_USER_ID, { [testDeviceId]: { keys: testDevice.keys, algorithms: testDevice.algorithms, verified: DeviceVerification.Verified, known: true, }, }); }); it("regular key", async () => { const event = await buildEncryptedEvent(); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.NONE, shieldReason: null, }); }); it("unauthenticated key", async () => { const event = await buildEncryptedEvent({ untrusted: true }); expect(await client.getCrypto()!.getEncryptionInfoForEvent(event)).toEqual({ shieldColour: EventShieldColour.GREY, shieldReason: EventShieldReason.AUTHENTICITY_NOT_GUARANTEED, }); }); }); }); it("doesn't throw an error when attempting to decrypt a redacted event", async () => { const client = new TestClient("@alice:example.com", "deviceid").client; await client.initLegacyCrypto(); const event = new MatrixEvent({ content: {}, event_id: "$event_id", room_id: "!room_id", sender: "@bob:example.com", type: "m.room.encrypted", unsigned: { redacted_because: { content: {}, event_id: "$redaction_event_id", redacts: "$event_id", room_id: "!room_id", origin_server_ts: 1234567890, sender: "@bob:example.com", type: "m.room.redaction", unsigned: {}, }, }, }); await event.attemptDecryption(client.crypto!); expect(event.isDecryptionFailure()).toBeFalsy(); // since the redaction event isn't encrypted, the redacted_because // should be the same as in the original event expect(event.getRedactionEvent()).toEqual(event.getUnsigned().redacted_because); client.stopClient(); }); }); describe("Session management", function () { const otkResponse: IClaimOTKsResult = { failures: {}, one_time_keys: { "@alice:home.server": { aliceDevice: { "signed_curve25519:FLIBBLE": { key: "YmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmJiYmI", signatures: { "@alice:home.server": { "ed25519:aliceDevice": "totally a valid signature", }, }, }, }, }, }, }; let crypto: Crypto; let mockBaseApis: MatrixClient; let fakeEmitter: EventEmitter; beforeEach(async function () { const mockStorage = new MockStorageApi() as unknown as Storage; const clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; const cryptoStore = new MemoryCryptoStore(); cryptoStore.storeEndToEndDeviceData( { devices: { "@bob:home.server": { BOBDEVICE: { algorithms: [], verified: 1, known: false, keys: { "curve25519:BOBDEVICE": "this is a key", }, }, }, }, trackingStatus: {}, }, {}, ); mockBaseApis = { sendToDevice: jest.fn(), getKeyBackupVersion: jest.fn(), isGuest: jest.fn(), emit: jest.fn(), } as unknown as MatrixClient; fakeEmitter = new EventEmitter(); crypto = new Crypto(mockBaseApis, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []); crypto.registerEventHandlers(fakeEmitter as any); await crypto.init(); }); afterEach(async function () { await crypto.stop(); }); it("restarts wedged Olm sessions", async function () { const prom = new Promise((resolve) => { mockBaseApis.claimOneTimeKeys = function () { resolve(); return Promise.resolve(otkResponse); }; }); fakeEmitter.emit("toDeviceEvent", { getId: jest.fn().mockReturnValue("$wedged"), getType: jest.fn().mockReturnValue("m.room.message"), getContent: jest.fn().mockReturnValue({ msgtype: "m.bad.encrypted", }), getWireContent: jest.fn().mockReturnValue({ algorithm: "m.olm.v1.curve25519-aes-sha2", sender_key: "this is a key", }), getSender: jest.fn().mockReturnValue("@bob:home.server"), }); await prom; }); }); describe("Key requests", function () { let aliceClient: MatrixClient; let secondAliceClient: MatrixClient; let bobClient: MatrixClient; let claraClient: MatrixClient; beforeEach(async function () { aliceClient = new TestClient("@alice:example.com", "alicedevice").client; secondAliceClient = new TestClient("@alice:example.com", "secondAliceDevice").client; bobClient = new TestClient("@bob:example.com", "bobdevice").client; claraClient = new TestClient("@clara:example.com", "claradevice").client; await aliceClient.initLegacyCrypto(); await secondAliceClient.initLegacyCrypto(); await bobClient.initLegacyCrypto(); await claraClient.initLegacyCrypto(); }); afterEach(async function () { aliceClient.stopClient(); secondAliceClient.stopClient(); bobClient.stopClient(); claraClient.stopClient(); }); it("does not cancel keyshare requests until all messages are decrypted with trusted keys", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); // Make Bob invited by Alice so Bob will accept Alice's forwarded keys bobRoom.currentState.setStateEvents([ new MatrixEvent({ type: "m.room.member", sender: "@alice:example.com", room_id: roomId, content: { membership: KnownMembership.Invite }, state_key: "@bob:example.com", }), ]); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await bobClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); }), ); const device = new DeviceInfo(aliceClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const decryptEventsPromise = Promise.all( events.map((ev) => { return awaitEvent(ev, "Event.decrypted"); }), ); // keyshare the session key starting at the second message, so // the first message can't be decrypted yet, but the second one // can let ksEvent = await keyshareEventForEvent(aliceClient, events[1], 1); bobClient.crypto!.deviceList.downloadKeys = () => Promise.resolve(new Map()); bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; await bobDecryptor.onRoomKeyEvent(ksEvent); await decryptEventsPromise; expect(events[0].getContent().msgtype).toBe("m.bad.encrypted"); expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); const cryptoStore = bobClient.crypto!.cryptoStore; const eventContent = events[0].getWireContent(); const senderKey = eventContent.sender_key; const sessionId = eventContent.session_id; const roomKeyRequestBody = { algorithm: olmlib.MEGOLM_ALGORITHM, room_id: roomId, sender_key: senderKey, session_id: sessionId, }; // the room key request should still be there, since we haven't // decrypted everything expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); // keyshare the session key starting at the first message, so // that it can now be decrypted const decryptEventPromise = awaitEvent(events[0], "Event.decrypted"); ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); await bobDecryptor.onRoomKeyEvent(ksEvent); await decryptEventPromise; expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); expect(events[0].isKeySourceUntrusted()).toBeTruthy(); await sleep(1); // the room key request should still be there, since we've // decrypted everything with an untrusted key expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); // Now share a trusted room key event so Bob will re-decrypt the messages. // Bob will backfill trust when they receive a trusted session with a higher // index that connects to an untrusted session with a lower index. const roomKeyEvent = roomKeyEventForEvent(aliceClient, events[1]); const trustedDecryptEventPromise = awaitEvent(events[0], "Event.decrypted"); await bobDecryptor.onRoomKeyEvent(roomKeyEvent); await trustedDecryptEventPromise; expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); expect(events[0].isKeySourceUntrusted()).toBeFalsy(); await sleep(1); // now the room key request should be gone, since there's // no better key to wait for expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeFalsy(); }); it("should error if a forwarded room key lacks a content.sender_key", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await bobClient.setRoomEncryption(roomId, encryptionCfg); const event = new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }); // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private property event.clearEvent = undefined; // @ts-ignore private property event.senderCurve25519Key = null; // @ts-ignore private property event.claimedEd25519Key = null; try { await bobClient.crypto!.decryptEvent(event); } catch { // we expect this to fail because we don't have the // decryption keys yet } const device = new DeviceInfo(aliceClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const ksEvent = await keyshareEventForEvent(aliceClient, event, 1); ksEvent.getContent().sender_key = undefined; // test bobClient.crypto!.olmDevice.addInboundGroupSession = jest.fn(); await bobDecryptor.onRoomKeyEvent(ksEvent); expect(bobClient.crypto!.olmDevice.addInboundGroupSession).not.toHaveBeenCalled(); }); it("creates a new keyshare request if we request a keyshare", async function () { // make sure that cancelAndResend... creates a new keyshare request // if there wasn't an already-existing one const event = new MatrixEvent({ sender: "@bob:example.com", room_id: "!someroom", content: { algorithm: olmlib.MEGOLM_ALGORITHM, session_id: "sessionid", sender_key: "senderkey", }, }); await aliceClient.cancelAndResendEventRoomKeyRequest(event); const cryptoStore = aliceClient.crypto!.cryptoStore; const roomKeyRequestBody = { algorithm: olmlib.MEGOLM_ALGORITHM, room_id: "!someroom", session_id: "sessionid", sender_key: "senderkey", }; expect(await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody)).toBeDefined(); }); it("uses a new txnid for re-requesting keys", async function () { jest.useFakeTimers(); const event = new MatrixEvent({ sender: "@bob:example.com", room_id: "!someroom", content: { algorithm: olmlib.MEGOLM_ALGORITHM, session_id: "sessionid", sender_key: "senderkey", }, }); // replace Alice's sendToDevice function with a mock const aliceSendToDevice = jest.fn().mockResolvedValue(undefined); aliceClient.sendToDevice = aliceSendToDevice; aliceClient.startClient(); // make a room key request, and record the transaction ID for the // sendToDevice call await aliceClient.cancelAndResendEventRoomKeyRequest(event); // key requests get queued until the sync has finished, but we don't // let the client set up enough for that to happen, so gut-wrench a bit // to force it to send now. // @ts-ignore aliceClient.crypto!.outgoingRoomKeyRequestManager.sendQueuedRequests(); jest.runAllTimers(); await Promise.resolve(); expect(aliceSendToDevice).toHaveBeenCalledTimes(1); const txnId = aliceSendToDevice.mock.calls[0][2]; // give the room key request manager time to update the state // of the request await Promise.resolve(); // cancel and resend the room key request await aliceClient.cancelAndResendEventRoomKeyRequest(event); jest.runAllTimers(); await Promise.resolve(); // cancelAndResend will call sendToDevice twice: // the first call to sendToDevice will be the cancellation // the second call to sendToDevice will be the key request expect(aliceSendToDevice).toHaveBeenCalledTimes(3); expect(aliceSendToDevice.mock.calls[2][2]).not.toBe(txnId); }); it("should accept forwarded keys it requested from one of its own user's other devices", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, secondAliceClient, "@alice:example.com", {}); aliceClient.store.storeRoom(aliceRoom); secondAliceClient.store.storeRoom(bobRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await secondAliceClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; await expect(secondAliceClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); }), ); const device = new DeviceInfo(aliceClient.deviceId!); device.verified = DeviceInfo.DeviceVerification.VERIFIED; secondAliceClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; secondAliceClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; const cryptoStore = secondAliceClient.crypto!.cryptoStore; const eventContent = events[0].getWireContent(); const senderKey = eventContent.sender_key; const sessionId = eventContent.session_id; const roomKeyRequestBody = { algorithm: olmlib.MEGOLM_ALGORITHM, room_id: roomId, sender_key: senderKey, session_id: sessionId, }; const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); expect(outgoingReq).toBeDefined(); await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, { state: RoomKeyRequestState.Sent, }); const bobDecryptor = secondAliceClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const decryptEventsPromise = Promise.all( events.map((ev) => { return awaitEvent(ev, "Event.decrypted"); }), ); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); await bobDecryptor.onRoomKeyEvent(ksEvent); const key = await secondAliceClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, events[0].getWireContent().sender_key, events[0].getWireContent().session_id, ); expect(key).not.toBeNull(); await decryptEventsPromise; expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); }); it("should accept forwarded keys from the user who invited it to the room", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); // Make Bob invited by Clara bobRoom.currentState.setStateEvents([ new MatrixEvent({ type: "m.room.member", sender: "@clara:example.com", room_id: roomId, content: { membership: KnownMembership.Invite }, state_key: "@bob:example.com", }), ]); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); claraClient.store.storeRoom(claraRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await bobClient.setRoomEncryption(roomId, encryptionCfg); await claraClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); }), ); const device = new DeviceInfo(claraClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@clara:example.com"; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const decryptEventsPromise = Promise.all( events.map((ev) => { return awaitEvent(ev, "Event.decrypted"); }), ); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); ksEvent.event.sender = claraClient.getUserId()!; ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!); await bobDecryptor.onRoomKeyEvent(ksEvent); const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, events[0].getWireContent().sender_key, events[0].getWireContent().session_id, ); expect(key).not.toBeNull(); await decryptEventsPromise; expect(events[0].getContent().msgtype).not.toBe("m.bad.encrypted"); expect(events[1].getContent().msgtype).not.toBe("m.bad.encrypted"); }); it("should not accept requested forwarded keys from other users", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await bobClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); }), ); const cryptoStore = bobClient.crypto!.cryptoStore; const eventContent = events[0].getWireContent(); const senderKey = eventContent.sender_key; const sessionId = eventContent.session_id; const roomKeyRequestBody = { algorithm: olmlib.MEGOLM_ALGORITHM, room_id: roomId, sender_key: senderKey, session_id: sessionId, }; const outgoingReq = await cryptoStore.getOutgoingRoomKeyRequest(roomKeyRequestBody); expect(outgoingReq).toBeDefined(); await cryptoStore.updateOutgoingRoomKeyRequest(outgoingReq!.requestId, RoomKeyRequestState.Unsent, { state: RoomKeyRequestState.Sent, }); const device = new DeviceInfo(aliceClient.deviceId!); device.verified = DeviceInfo.DeviceVerification.VERIFIED; bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); ksEvent.event.sender = aliceClient.getUserId()!; ksEvent.sender = new RoomMember(roomId, aliceClient.getUserId()!); await bobDecryptor.onRoomKeyEvent(ksEvent); const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, events[0].getWireContent().sender_key, events[0].getWireContent().session_id, ); expect(key).toBeNull(); }); it("should not accept unexpected forwarded keys for a room it's in", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); const bobRoom = new Room(roomId, bobClient, "@bob:example.com", {}); const claraRoom = new Room(roomId, claraClient, "@clara:example.com", {}); aliceClient.store.storeRoom(aliceRoom); bobClient.store.storeRoom(bobRoom); claraClient.store.storeRoom(claraRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); await bobClient.setRoomEncryption(roomId, encryptionCfg); await claraClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; await expect(bobClient.crypto!.decryptEvent(event)).rejects.toBeTruthy(); }), ); const device = new DeviceInfo(claraClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); ksEvent.event.sender = claraClient.getUserId()!; ksEvent.sender = new RoomMember(roomId, claraClient.getUserId()!); await bobDecryptor.onRoomKeyEvent(ksEvent); const key = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, events[0].getWireContent().sender_key, events[0].getWireContent().session_id, ); expect(key).toBeNull(); }); it("should park forwarded keys for a room it's not in", async function () { const encryptionCfg = { algorithm: "m.megolm.v1.aes-sha2", }; const roomId = "!someroom"; const aliceRoom = new Room(roomId, aliceClient, "@alice:example.com", {}); aliceClient.store.storeRoom(aliceRoom); await aliceClient.setRoomEncryption(roomId, encryptionCfg); const events = [ new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$1", content: { msgtype: "m.text", body: "1", }, }), new MatrixEvent({ type: "m.room.message", sender: "@alice:example.com", room_id: roomId, event_id: "$2", content: { msgtype: "m.text", body: "2", }, }), ]; await Promise.all( events.map(async (event) => { // alice encrypts each event, and then bob tries to decrypt // them without any keys, so that they'll be in pending await aliceClient.crypto!.encryptEvent(event, aliceRoom); // remove keys from the event // @ts-ignore private properties event.clearEvent = undefined; // @ts-ignore private properties event.senderCurve25519Key = null; // @ts-ignore private properties event.claimedEd25519Key = null; }), ); const device = new DeviceInfo(aliceClient.deviceId!); bobClient.crypto!.deviceList.getDeviceByIdentityKey = () => device; bobClient.crypto!.deviceList.getUserByIdentityKey = () => "@alice:example.com"; const bobDecryptor = bobClient.crypto!.getRoomDecryptor(roomId, olmlib.MEGOLM_ALGORITHM); const content = events[0].getWireContent(); const ksEvent = await keyshareEventForEvent(aliceClient, events[0], 0); await bobDecryptor.onRoomKeyEvent(ksEvent); const bobKey = await bobClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, content.sender_key, content.session_id, ); expect(bobKey).toBeNull(); const aliceKey = await aliceClient.crypto!.olmDevice.getInboundGroupSessionKey( roomId, content.sender_key, content.session_id, ); const parked = await bobClient.crypto!.cryptoStore.takeParkedSharedHistory(roomId); expect(parked).toEqual([ { senderId: aliceClient.getUserId(), senderKey: content.sender_key, sessionId: content.session_id, sessionKey: aliceKey!.key, keysClaimed: { ed25519: aliceKey!.sender_claimed_ed25519_key }, forwardingCurve25519KeyChain: ["akey"], }, ]); }); }); describe("Secret storage", function () { it("creates secret storage even if there is no keyInfo", async function () { jest.spyOn(logger, "debug").mockImplementation(() => {}); jest.setTimeout(10000); const client = new TestClient("@a:example.com", "dev").client; await client.initLegacyCrypto(); client.crypto!.isCrossSigningReady = async () => false; client.crypto!.baseApis.uploadDeviceSigningKeys = jest.fn().mockResolvedValue(null); client.crypto!.baseApis.setAccountData = jest.fn().mockResolvedValue(null); client.crypto!.baseApis.uploadKeySignatures = jest.fn(); client.crypto!.baseApis.http.authedRequest = jest.fn(); const createSecretStorageKey = async () => { return { keyInfo: undefined, // Returning undefined here used to cause a crash privateKey: Uint8Array.of(32, 33), }; }; await client.crypto!.bootstrapSecretStorage({ createSecretStorageKey, }); client.stopClient(); }); }); describe("encryptAndSendToDevices", () => { let client: TestClient; let ensureOlmSessionsForDevices: jest.SpiedFunction; let encryptMessageForDevice: jest.SpiedFunction; const payload = { hello: "world" }; let encryptedPayload: object; beforeEach(async () => { ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices"); ensureOlmSessionsForDevices.mockResolvedValue(new Map()); encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice"); encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => { result.plaintext = { type: 0, body: JSON.stringify(payload) }; }); client = new TestClient("@alice:example.org", "aliceweb"); // running initLegacyCrypto should trigger a key upload client.httpBackend.when("POST", "/keys/upload").respond(200, {}); await Promise.all([client.client.initLegacyCrypto(), client.httpBackend.flush("/keys/upload", 1)]); encryptedPayload = { algorithm: "m.olm.v1.curve25519-aes-sha2", sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key, ciphertext: { plaintext: { type: 0, body: JSON.stringify(payload) } }, }; }); afterEach(async () => { ensureOlmSessionsForDevices.mockRestore(); encryptMessageForDevice.mockRestore(); await client.stop(); }); it("encrypts and sends to devices", async () => { client.httpBackend .when("PUT", "/sendToDevice/m.room.encrypted") .check((request) => { const data = request.data; delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; delete data.messages["@bob:example.org"]["bobmobile"]["org.matrix.msgid"]; delete data.messages["@carol:example.org"]["caroldesktop"]["org.matrix.msgid"]; expect(data).toStrictEqual({ messages: { "@bob:example.org": { bobweb: encryptedPayload, bobmobile: encryptedPayload, }, "@carol:example.org": { caroldesktop: encryptedPayload, }, }, }); }) .respond(200, {}); await Promise.all([ client.client.encryptAndSendToDevices( [ { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }, { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobmobile") }, { userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") }, ], payload, ), client.httpBackend.flushAllExpected(), ]); }); it("sends nothing to devices that couldn't be encrypted to", async () => { encryptMessageForDevice.mockImplementation(async (...[result, , , , userId, device, payload]) => { // Refuse to encrypt to Carol's desktop device if (userId === "@carol:example.org" && device.deviceId === "caroldesktop") return; result.plaintext = { type: 0, body: JSON.stringify(payload) }; }); client.httpBackend .when("PUT", "/sendToDevice/m.room.encrypted") .check((req) => { const data = req.data; delete data.messages["@bob:example.org"]["bobweb"]["org.matrix.msgid"]; // Carol is nowhere to be seen expect(data).toStrictEqual({ messages: { "@bob:example.org": { bobweb: encryptedPayload } }, }); }) .respond(200, {}); await Promise.all([ client.client.encryptAndSendToDevices( [ { userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }, { userId: "@carol:example.org", deviceInfo: new DeviceInfo("caroldesktop") }, ], payload, ), client.httpBackend.flushAllExpected(), ]); }); it("no-ops if no devices can be encrypted to", async () => { // Refuse to encrypt to anybody encryptMessageForDevice.mockResolvedValue(undefined); // Get the room keys version request out of the way client.httpBackend.when("GET", "/room_keys/version").respond(404, {}); await client.httpBackend.flush("/room_keys/version", 1); await client.client.encryptAndSendToDevices( [{ userId: "@bob:example.org", deviceInfo: new DeviceInfo("bobweb") }], payload, ); client.httpBackend.verifyNoOutstandingRequests(); }); }); describe("encryptToDeviceMessages", () => { let client: TestClient; let ensureOlmSessionsForDevices: jest.SpiedFunction; let encryptMessageForDevice: jest.SpiedFunction; const payload = { hello: "world" }; let encryptedPayload: object; let crypto: Crypto; beforeEach(async () => { ensureOlmSessionsForDevices = jest.spyOn(olmlib, "ensureOlmSessionsForDevices"); ensureOlmSessionsForDevices.mockResolvedValue(new Map()); encryptMessageForDevice = jest.spyOn(olmlib, "encryptMessageForDevice"); encryptMessageForDevice.mockImplementation(async (...[result, , , , , , payload]) => { result.plaintext = { type: 0, body: JSON.stringify(payload) }; }); client = new TestClient("@alice:example.org", "aliceweb"); // running initLegacyCrypto should trigger a key upload client.httpBackend.when("POST", "/keys/upload").respond(200, {}); await Promise.all([client.client.initLegacyCrypto(), client.httpBackend.flush("/keys/upload", 1)]); encryptedPayload = { algorithm: "m.olm.v1.curve25519-aes-sha2", sender_key: client.client.crypto!.olmDevice.deviceCurve25519Key, ciphertext: { plaintext: { type: 0, body: JSON.stringify(payload) } }, }; crypto = client.client.getCrypto() as Crypto; }); afterEach(async () => { ensureOlmSessionsForDevices.mockRestore(); encryptMessageForDevice.mockRestore(); await client.stop(); }); it("returns encrypted batch where devices known", async () => { const deviceInfoMap: DeviceInfoMap = new Map([ [ "@bob:example.org", new Map([ ["bobweb", new DeviceInfo("bobweb")], ["bobmobile", new DeviceInfo("bobmobile")], ]), ], ["@carol:example.org", new Map([["caroldesktop", new DeviceInfo("caroldesktop")]])], ]); jest.spyOn(crypto.deviceList, "downloadKeys").mockResolvedValue(deviceInfoMap); // const deviceInfoMap = await this.downloadKeys(Array.from(userIds), false); const batch = await client.client.getCrypto()?.encryptToDeviceMessages( "m.test.type", [ { userId: "@bob:example.org", deviceId: "bobweb" }, { userId: "@bob:example.org", deviceId: "bobmobile" }, { userId: "@carol:example.org", deviceId: "caroldesktop" }, { userId: "@carol:example.org", deviceId: "carolmobile" }, // not known ], payload, ); expect(crypto.deviceList.downloadKeys).toHaveBeenCalledWith( ["@bob:example.org", "@carol:example.org"], false, ); expect(encryptMessageForDevice).toHaveBeenCalledTimes(3); const expectedPayload = expect.objectContaining({ ...encryptedPayload, "org.matrix.msgid": expect.any(String), "sender_key": expect.any(String), }); expect(batch?.eventType).toEqual("m.room.encrypted"); expect(batch?.batch.length).toEqual(3); expect(batch).toEqual({ eventType: "m.room.encrypted", batch: expect.arrayContaining([ { userId: "@bob:example.org", deviceId: "bobweb", payload: expectedPayload, }, { userId: "@bob:example.org", deviceId: "bobmobile", payload: expectedPayload, }, { userId: "@carol:example.org", deviceId: "caroldesktop", payload: expectedPayload, }, ]), }); }); it("returns empty batch if no devices known", async () => { jest.spyOn(crypto.deviceList, "downloadKeys").mockResolvedValue(new Map()); const batch = await crypto.encryptToDeviceMessages( "m.test.type", [ { deviceId: "AAA", userId: "@user1:domain" }, { deviceId: "BBB", userId: "@user1:domain" }, { deviceId: "CCC", userId: "@user2:domain" }, ], payload, ); expect(batch?.eventType).toEqual("m.room.encrypted"); expect(batch?.batch).toEqual([]); }); }); describe("checkSecretStoragePrivateKey", () => { let client: TestClient; beforeEach(async () => { client = new TestClient("@alice:example.org", "aliceweb"); await client.client.initLegacyCrypto(); }); afterEach(async () => { await client.stop(); }); it("should free PkDecryption", () => { const free = jest.fn(); jest.spyOn(Olm, "PkDecryption").mockImplementation( () => ({ init_with_private_key: jest.fn(), free, }) as unknown as PkDecryption, ); client.client.checkSecretStoragePrivateKey(new Uint8Array(), ""); expect(free).toHaveBeenCalled(); }); }); describe("checkCrossSigningPrivateKey", () => { let client: TestClient; beforeEach(async () => { client = new TestClient("@alice:example.org", "aliceweb"); await client.client.initLegacyCrypto(); }); afterEach(async () => { await client.stop(); }); it("should free PkSigning", () => { const free = jest.fn(); jest.spyOn(Olm, "PkSigning").mockImplementation( () => ({ init_with_seed: jest.fn(), free, }) as unknown as PkSigning, ); client.client.checkCrossSigningPrivateKey(new Uint8Array(), ""); expect(free).toHaveBeenCalled(); }); }); describe("start", () => { let client: TestClient; beforeEach(async () => { client = new TestClient("@alice:example.org", "aliceweb"); await client.client.initLegacyCrypto(); }); afterEach(async function () { await client!.stop(); }); // start() is a no-op nowadays, so there's not much to test here. it("should complete successfully", async () => { await client!.client.crypto!.start(); }); }); describe("setRoomEncryption", () => { let mockClient: MatrixClient; let mockRoomList: RoomList; let clientStore: IStore; let crypto: Crypto; beforeEach(async function () { mockClient = {} as MatrixClient; const mockStorage = new MockStorageApi() as unknown as Storage; clientStore = new MemoryStore({ localStorage: mockStorage }) as unknown as IStore; const cryptoStore = new MemoryCryptoStore(); mockRoomList = { getRoomEncryption: jest.fn().mockReturnValue(null), setRoomEncryption: jest.fn().mockResolvedValue(undefined), } as unknown as RoomList; crypto = new Crypto(mockClient, "@alice:home.server", "FLIBBLE", clientStore, cryptoStore, []); // @ts-ignore we are injecting a mock into a private property crypto.roomList = mockRoomList; }); it("should set the algorithm if called for a known room", async () => { const room = new Room("!room:id", mockClient, "@my.user:id"); await clientStore.storeRoom(room); await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); expect(mockRoomList!.setRoomEncryption).toHaveBeenCalledTimes(1); expect(jest.mocked(mockRoomList!.setRoomEncryption).mock.calls[0][0]).toEqual("!room:id"); }); it("should raise if called for an unknown room", async () => { await expect(async () => { await crypto.setRoomEncryption("!room:id", { algorithm: "m.megolm.v1.aes-sha2" } as IRoomEncryption); }).rejects.toThrow(/unknown room/); expect(mockRoomList!.setRoomEncryption).not.toHaveBeenCalled(); }); }); });