From ba712355394908a1ac9d6a03dd62b2597bbb35e4 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Mon, 7 Apr 2025 10:30:10 +0200 Subject: [PATCH] MatrixRTC: Introduce key transport abstraction as prep work for to-device encryption (#4773) * refactor: extract RoomKeyTransport class for key distribution * refact: Call key transport, pass the target recipients to sendKey * update IKeyTransport interface to event emitter. * fix not subscribing to KeyTransportEvents in the EncryptionManager + cleanup * fix one test and broken bits needed for the test (mostly statistics wrangling) * fix tests * add back decryptEventIfNeeded * move and fix room transport tests * dedupe isMyMembership * move type declarations around to be at more reasonable places * remove deprecated `onMembershipUpdate` * fix imports * only start keytransport when session is joined * use makeKey to reduce test loc * fix todo comment -> note comment --------- Co-authored-by: Timo --- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 378 ++++++++---------- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 123 +----- spec/unit/matrixrtc/RoomKeyTransport.spec.ts | 141 +++++++ spec/unit/matrixrtc/mocks.ts | 46 ++- src/matrixrtc/EncryptionManager.ts | 237 ++++------- src/matrixrtc/IKeyTransport.ts | 53 +++ src/matrixrtc/IMembershipManager.ts | 88 ++++ src/matrixrtc/LegacyMembershipManager.ts | 3 +- src/matrixrtc/MatrixRTCSession.ts | 74 ++-- src/matrixrtc/MatrixRTCSessionManager.ts | 51 +-- src/matrixrtc/NewMembershipManager.ts | 19 +- src/matrixrtc/RoomKeyTransport.ts | 184 +++++++++ src/matrixrtc/index.ts | 3 +- src/matrixrtc/types.ts | 90 ++--- 14 files changed, 819 insertions(+), 671 deletions(-) create mode 100644 spec/unit/matrixrtc/RoomKeyTransport.spec.ts create mode 100644 src/matrixrtc/IKeyTransport.ts create mode 100644 src/matrixrtc/IMembershipManager.ts create mode 100644 src/matrixrtc/RoomKeyTransport.ts diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 8d7bf41f5..9f66bdee6 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -20,7 +20,7 @@ import { DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "../../../sr import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { type EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { secureRandomString } from "../../../src/randomstring"; -import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks"; +import { makeMockEvent, makeMockRoom, makeMockRoomState, membershipTemplate, makeKey } from "./mocks"; const mockFocus = { type: "mock" }; @@ -34,6 +34,8 @@ describe("MatrixRTCSession", () => { client = new MatrixClient({ baseUrl: "base_url" }); client.getUserId = jest.fn().mockReturnValue("@alice:example.org"); client.getDeviceId = jest.fn().mockReturnValue("AAAAAAA"); + client.sendEvent = jest.fn().mockResolvedValue({ event_id: "success" }); + client.decryptEventIfNeeded = jest.fn(); }); afterEach(async () => { @@ -478,6 +480,7 @@ describe("MatrixRTCSession", () => { }); describe("key management", () => { + // TODO make this test suit only test the encryption manager. And mock the transport directly not the session. describe("sending", () => { let mockRoom: Room; let sendStateEventMock: jest.Mock; @@ -531,12 +534,7 @@ describe("MatrixRTCSession", () => { { call_id: "", device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], + keys: [makeKey(0, expect.stringMatching(".*"))], sent_ts: Date.now(), }, ); @@ -584,7 +582,7 @@ describe("MatrixRTCSession", () => { }); sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); - jest.advanceTimersByTime(10000); + await jest.runAllTimersAsync(); await eventSentPromise; @@ -739,12 +737,7 @@ describe("MatrixRTCSession", () => { { call_id: "", device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], + keys: [makeKey(0, expect.stringMatching(".*"))], sent_ts: Date.now(), }, ); @@ -793,12 +786,7 @@ describe("MatrixRTCSession", () => { { call_id: "", device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], + keys: [makeKey(0, expect.stringMatching(".*"))], sent_ts: Date.now(), }, ); @@ -831,12 +819,7 @@ describe("MatrixRTCSession", () => { { call_id: "", device_id: "AAAAAAA", - keys: [ - { - index: 0, - key: expect.stringMatching(".*"), - }, - ], + keys: [makeKey(0, expect.stringMatching(".*"))], sent_ts: Date.now(), }, ); @@ -985,61 +968,48 @@ describe("MatrixRTCSession", () => { }); describe("receiving", () => { - it("collects keys from encryption events", () => { + it("collects keys from encryption events", async () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { device_id: "bobsphone", call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")], }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - + ); + await jest.advanceTimersToNextTimerAsync(); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("this is the key"), 0, "@bob:example.org:bobsphone", ); - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); }); - it("collects keys at non-zero indices", () => { + it("collects keys at non-zero indices", async () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { device_id: "bobsphone", call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")], }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); + ); + await jest.advanceTimersToNextTimerAsync(); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("this is the key"), 4, @@ -1049,61 +1019,48 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); }); - it("collects keys by merging", () => { + it("collects keys by merging", async () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { device_id: "bobsphone", call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")], }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); + ); + await jest.advanceTimersToNextTimerAsync(); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); - expect(encryptionKeyChangedListener).toHaveBeenCalledWith( - textEncoder.encode("this is the key"), - 0, - "@bob:example.org:bobsphone", - ); - - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); - - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); - - encryptionKeyChangedListener.mockClear(); - sess!.reemitEncryptionKeys(); expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("this is the key"), 0, "@bob:example.org:bobsphone", ); + + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(1); + + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")], + }), + ); + await jest.advanceTimersToNextTimerAsync(); + + encryptionKeyChangedListener.mockClear(); + sess!.reemitEncryptionKeys(); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(3); + expect(encryptionKeyChangedListener).toHaveBeenCalledWith( + textEncoder.encode("this is the key"), + 0, + "@bob:example.org:bobsphone", + ); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("this is the key"), 4, @@ -1113,93 +1070,102 @@ describe("MatrixRTCSession", () => { expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); }); - it("ignores older keys at same index", () => { + it("ignores older keys at same index", async () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("newer key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(2000), - } as unknown as MatrixEvent); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))], + }, + 2000, + ), + ); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("older key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), // earlier timestamp than the newer key - } as unknown as MatrixEvent); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, encodeBase64(Buffer.from("newer key", "utf-8")))], + }, + 2000, + ), + ); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))], + }, + 1000, + ), + ); + await jest.advanceTimersToNextTimerAsync(); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("newer key"), 0, "@bob:example.org:bobsphone", ); - expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(2); + expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(3); }); - it("key timestamps are treated as monotonic", () => { + it("key timestamps are treated as monotonic", async () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("first key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), - } as unknown as MatrixEvent); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, encodeBase64(Buffer.from("older key", "utf-8")))], + }, + 1000, + ), + ); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: encodeBase64(Buffer.from("second key", "utf-8")), - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(1000), // same timestamp as the first key - } as unknown as MatrixEvent); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, encodeBase64(Buffer.from("second key", "utf-8")))], + }, + 1000, + ), + ); + await jest.advanceTimersToNextTimerAsync(); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(2); expect(encryptionKeyChangedListener).toHaveBeenCalledWith( textEncoder.encode("second key"), 0, @@ -1210,31 +1176,25 @@ describe("MatrixRTCSession", () => { it("ignores keys event for the local participant", () => { const mockRoom = makeMockRoom([membershipTemplate]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", client.getUserId()!, "1234roomId", { device_id: client.getDeviceId(), call_id: "", - keys: [ - { - index: 4, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(4, "dGhpcyBpcyB0aGUga2V5")], }), - getSender: jest.fn().mockReturnValue(client.getUserId()), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); + ); const encryptionKeyChangedListener = jest.fn(); sess!.on(MatrixRTCSessionEvent.EncryptionKeyChanged, encryptionKeyChangedListener); sess!.reemitEncryptionKeys(); - expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(0); + expect(encryptionKeyChangedListener).toHaveBeenCalledTimes(1); expect(sess!.statistics.counters.roomEventEncryptionKeysReceived).toEqual(0); }); - it("tracks total age statistics for collected keys", () => { + it("tracks total age statistics for collected keys", async () => { jest.useFakeTimers(); try { const mockRoom = makeMockRoom([membershipTemplate]); @@ -1242,59 +1202,49 @@ describe("MatrixRTCSession", () => { // defaults to getTs() jest.setSystemTime(1000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ - device_id: "bobsphone", - call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], - }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(0), - } as unknown as MatrixEvent); + sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + mockRoom.emitTimelineEvent( + makeMockEvent( + "io.element.call.encryption_keys", + "@bob:example.org", + "1234roomId", + { + device_id: "bobsphone", + call_id: "", + keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")], + }, + 0, + ), + ); + await jest.advanceTimersToNextTimerAsync(); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(1000); jest.setSystemTime(2000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { device_id: "bobsphone", call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")], sent_ts: 0, }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); + ); + await jest.advanceTimersToNextTimerAsync(); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(3000); jest.setSystemTime(3000); - sess.onCallEncryption({ - getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), - getContent: jest.fn().mockReturnValue({ + mockRoom.emitTimelineEvent( + makeMockEvent("io.element.call.encryption_keys", "@bob:example.org", "1234roomId", { device_id: "bobsphone", call_id: "", - keys: [ - { - index: 0, - key: "dGhpcyBpcyB0aGUga2V5", - }, - ], + keys: [makeKey(0, "dGhpcyBpcyB0aGUga2V5")], sent_ts: 1000, }), - getSender: jest.fn().mockReturnValue("@bob:example.org"), - getTs: jest.fn().mockReturnValue(Date.now()), - } as unknown as MatrixEvent); + ); + await jest.advanceTimersToNextTimerAsync(); + expect(sess!.statistics.totals.roomEventEncryptionKeysReceivedTotalAge).toEqual(5000); } finally { jest.useRealTimers(); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index a2912b82c..5cdb9278f 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -16,15 +16,7 @@ limitations under the License. import { type Mock } from "jest-mock"; -import { - ClientEvent, - EventTimeline, - EventType, - type IRoomTimelineData, - MatrixClient, - type MatrixEvent, - RoomEvent, -} from "../../../src"; +import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks"; @@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => { expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); - - it("Calls onCallEncryption on encryption keys event", async () => { - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - const onCallEncryptionMock = jest.fn(); - client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock; - client.decryptEventIfNeeded = () => Promise.resolve(); - const timelineEvent = { - getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getRoomId: jest.fn().mockReturnValue("!room:id"), - isDecryptionFailure: jest.fn().mockReturnValue(false), - sender: { - userId: "@mock:user.example", - }, - } as unknown as MatrixEvent; - client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); - await new Promise(process.nextTick); - expect(onCallEncryptionMock).toHaveBeenCalled(); - }); - - describe("event decryption", () => { - it("Retries decryption and processes success", async () => { - try { - jest.useFakeTimers(); - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - const onCallEncryptionMock = jest.fn(); - client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock; - let isDecryptionFailure = true; - client.decryptEventIfNeeded = jest - .fn() - .mockReturnValueOnce(Promise.resolve()) - .mockImplementation(() => { - isDecryptionFailure = false; - return Promise.resolve(); - }); - const timelineEvent = { - getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getRoomId: jest.fn().mockReturnValue("!room:id"), - isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure), - getId: jest.fn().mockReturnValue("event_id"), - sender: { - userId: "@mock:user.example", - }, - } as unknown as MatrixEvent; - client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); - - expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1); - expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); - - // should retry after one second: - await jest.advanceTimersByTimeAsync(1500); - - expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); - expect(onCallEncryptionMock).toHaveBeenCalledTimes(1); - } finally { - jest.useRealTimers(); - } - }); - - it("Retries decryption and processes failure", async () => { - try { - jest.useFakeTimers(); - const room1 = makeMockRoom([membershipTemplate]); - jest.spyOn(client, "getRooms").mockReturnValue([room1]); - jest.spyOn(client, "getRoom").mockReturnValue(room1); - - client.emit(ClientEvent.Room, room1); - const onCallEncryptionMock = jest.fn(); - client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock; - client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve()); - const timelineEvent = { - getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix), - getContent: jest.fn().mockReturnValue({}), - getSender: jest.fn().mockReturnValue("@mock:user.example"), - getRoomId: jest.fn().mockReturnValue("!room:id"), - isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail - getId: jest.fn().mockReturnValue("event_id"), - sender: { - userId: "@mock:user.example", - }, - } as unknown as MatrixEvent; - client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); - - expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1); - expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); - - // should retry after one second: - await jest.advanceTimersByTimeAsync(1500); - - expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); - expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); - - // doesn't retry again: - await jest.advanceTimersByTimeAsync(1500); - - expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); - expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); - } finally { - jest.useRealTimers(); - } - }); - }); }); diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts new file mode 100644 index 000000000..a5e462be8 --- /dev/null +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -0,0 +1,141 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks"; +import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport"; +import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; +import { EventType, MatrixClient, RoomEvent } from "../../../src"; +import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src"; + +describe("RoomKyTransport", () => { + let client: MatrixClient; + let room: Room & { + emitTimelineEvent: (event: MatrixEvent) => void; + }; + let transport: RoomKeyTransport; + const onCallEncryptionMock = jest.fn(); + beforeEach(() => { + onCallEncryptionMock.mockReset(); + const statistics = { + counters: { + roomEventEncryptionKeysSent: 0, + roomEventEncryptionKeysReceived: 0, + }, + totals: { + roomEventEncryptionKeysReceivedTotalAge: 0, + }, + }; + room = makeMockRoom([membershipTemplate]); + client = new MatrixClient({ baseUrl: "base_url" }); + client.matrixRTC.start(); + transport = new RoomKeyTransport(room, client, statistics); + transport.on(KeyTransportEvents.ReceivedKeys, (...p) => { + onCallEncryptionMock(...p); + }); + transport.start(); + }); + + afterEach(() => { + client.stopClient(); + client.matrixRTC.stop(); + transport.stop(); + }); + + it("Calls onCallEncryption on encryption keys event", async () => { + client.decryptEventIfNeeded = () => Promise.resolve(); + const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", { + call_id: "", + keys: [makeKey(0, "testKey")], + sent_ts: Date.now(), + device_id: "AAAAAAA", + }); + room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); + await new Promise(process.nextTick); + expect(onCallEncryptionMock).toHaveBeenCalled(); + }); + + describe("event decryption", () => { + it("Retries decryption and processes success", async () => { + jest.useFakeTimers(); + let isDecryptionFailure = true; + client.decryptEventIfNeeded = jest + .fn() + .mockReturnValueOnce(Promise.resolve()) + .mockImplementation(() => { + isDecryptionFailure = false; + return Promise.resolve(); + }); + + const timelineEvent = Object.assign( + makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", { + call_id: "", + keys: [makeKey(0, "testKey")], + sent_ts: Date.now(), + device_id: "AAAAAAA", + }), + { isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) }, + ); + room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); + + expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); + + // should retry after one second: + await jest.advanceTimersByTimeAsync(1500); + + expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); + expect(onCallEncryptionMock).toHaveBeenCalledTimes(1); + jest.useRealTimers(); + }); + + it("Retries decryption and processes failure", async () => { + try { + jest.useFakeTimers(); + const onCallEncryptionMock = jest.fn(); + client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve()); + + const timelineEvent = Object.assign( + makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", { + call_id: "", + keys: [makeKey(0, "testKey")], + sent_ts: Date.now(), + device_id: "AAAAAAA", + }), + { isDecryptionFailure: jest.fn().mockReturnValue(true) }, + ); + + room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); + + expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1); + expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); + + // should retry after one second: + await jest.advanceTimersByTimeAsync(1500); + + expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); + expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); + + // doesn't retry again: + await jest.advanceTimersByTimeAsync(1500); + + expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2); + expect(onCallEncryptionMock).toHaveBeenCalledTimes(0); + } finally { + jest.useRealTimers(); + } + }); + }); +}); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 0c8cb2ade..5a485e7d4 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -14,7 +14,9 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src"; +import { EventEmitter } from "stream"; + +import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src"; import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership"; import { secureRandomString } from "../../../src/randomstring"; @@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient { }; } -export function makeMockRoom(membershipData: MembershipData): Room { +export function makeMockRoom( + membershipData: MembershipData, +): Room & { emitTimelineEvent: (event: MatrixEvent) => void } { const roomId = secureRandomString(8); // Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()` const roomState = makeMockRoomState(membershipData, roomId); - const room = { + const room = Object.assign(new EventEmitter(), { roomId: roomId, hasMembershipState: jest.fn().mockReturnValue(true), getLiveTimeline: jest.fn().mockReturnValue({ getState: jest.fn().mockReturnValue(roomState), }), getVersion: jest.fn().mockReturnValue("default"), - } as unknown as Room; - return room; + }) as unknown as Room; + return Object.assign(room, { + emitTimelineEvent: (event: MatrixEvent) => + room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any), + }); } export function makeMockRoomState(membershipData: MembershipData, roomId: string) { @@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string }; } -export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent { - const sender = customSender ?? "@mock:user.example"; +export function makeMockEvent( + type: string, + sender: string, + roomId: string, + content: any, + timestamp?: number, +): MatrixEvent { return { - getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix), - getContent: jest.fn().mockReturnValue(membershipData), + getType: jest.fn().mockReturnValue(type), + getContent: jest.fn().mockReturnValue(content), getSender: jest.fn().mockReturnValue(sender), - getTs: jest.fn().mockReturnValue(Date.now()), + getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()), getRoomId: jest.fn().mockReturnValue(roomId), + getId: jest.fn().mockReturnValue(secureRandomString(8)), isDecryptionFailure: jest.fn().mockReturnValue(false), } as unknown as MatrixEvent; } + +export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent { + const sender = customSender ?? "@mock:user.example"; + return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData); +} + export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership { return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData); } + +export function makeKey(id: number, key: string): { key: string; index: number } { + return { + key: key, + index: id, + }; +} diff --git a/src/matrixrtc/EncryptionManager.ts b/src/matrixrtc/EncryptionManager.ts index 09cda18d2..b97cd9dd3 100644 --- a/src/matrixrtc/EncryptionManager.ts +++ b/src/matrixrtc/EncryptionManager.ts @@ -1,57 +1,50 @@ -import { type MatrixClient } from "../client.ts"; import { logger as rootLogger } from "../logger.ts"; -import { type MatrixEvent } from "../models/event.ts"; -import { type Room } from "../models/room.ts"; import { type EncryptionConfig } from "./MatrixRTCSession.ts"; import { secureRandomBase64Url } from "../randomstring.ts"; -import { type EncryptionKeysEventContent } from "./types.ts"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; -import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts"; +import { safeGetRetryAfterMs } from "../http-api/errors.ts"; import { type CallMembership } from "./CallMembership.ts"; -import { EventType } from "../@types/event.ts"; -const logger = rootLogger.getChild("MatrixRTCSession"); +import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts"; +import { isMyMembership, type Statistics } from "./types.ts"; -/** - * A type collecting call encryption statistics for a session. - */ -export type Statistics = { - counters: { - /** - * The number of times we have sent a room event containing encryption keys. - */ - roomEventEncryptionKeysSent: number; - /** - * The number of times we have received a room event containing encryption keys. - */ - roomEventEncryptionKeysReceived: number; - }; - totals: { - /** - * The total age (in milliseconds) of all room events containing encryption keys that we have received. - * We track the total age so that we can later calculate the average age of all keys received. - */ - roomEventEncryptionKeysReceivedTotalAge: number; - }; -}; +const logger = rootLogger.getChild("MatrixRTCSession"); /** * This interface is for testing and for making it possible to interchange the encryption manager. * @internal */ +/** + * Interface representing an encryption manager for handling encryption-related + * operations in a real-time communication context. + */ export interface IEncryptionManager { - join(joinConfig: EncryptionConfig | undefined): void; - leave(): void; - onMembershipsUpdate(oldMemberships: CallMembership[]): void; /** - * Process `m.call.encryption_keys` events to track the encryption keys for call participants. - * This should be called each time the relevant event is received from a room timeline. - * If the event is malformed then it will be logged and ignored. + * Joins the encryption manager with the provided configuration. * - * @param event the event to process + * @param joinConfig - The configuration for joining encryption, or undefined + * if no specific configuration is provided. + */ + join(joinConfig: EncryptionConfig | undefined): void; + + /** + * Leaves the encryption manager, cleaning up any associated resources. + */ + leave(): void; + + /** + * Called from the MatrixRTCSession when the memberships in this session updated. + * + * @param oldMemberships - The previous state of call memberships before the update. + */ + onMembershipsUpdate(oldMemberships: CallMembership[]): void; + + /** + * Retrieves the encryption keys currently managed by the encryption manager. + * + * @returns A map where the keys are identifiers and the values are arrays of + * objects containing encryption keys and their associated timestamps. */ - onCallEncryptionEventReceived(event: MatrixEvent): void; getEncryptionKeys(): Map>; - statistics: Statistics; } /** @@ -71,9 +64,11 @@ export class EncryptionManager implements IEncryptionManager { private get updateEncryptionKeyThrottle(): number { return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000; } + private get makeKeyDelay(): number { return this.joinConfig?.makeKeyDelay ?? 3_000; } + private get useKeyDelay(): number { return this.joinConfig?.useKeyDelay ?? 5_000; } @@ -87,21 +82,14 @@ export class EncryptionManager implements IEncryptionManager { private currentEncryptionKeyIndex = -1; - public statistics: Statistics = { - counters: { - roomEventEncryptionKeysSent: 0, - roomEventEncryptionKeysReceived: 0, - }, - totals: { - roomEventEncryptionKeysReceivedTotalAge: 0, - }, - }; private joinConfig: EncryptionConfig | undefined; public constructor( - private client: Pick, - private room: Pick, + private userId: string, + private deviceId: string, private getMemberships: () => CallMembership[], + private transport: IKeyTransport, + private statistics: Statistics, private onEncryptionKeysChanged: ( keyBin: Uint8Array, encryptionKeyIndex: number, @@ -112,11 +100,16 @@ export class EncryptionManager implements IEncryptionManager { public getEncryptionKeys(): Map> { return this.encryptionKeys; } + private joined = false; + public join(joinConfig: EncryptionConfig): void { this.joinConfig = joinConfig; this.joined = true; this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys; + + this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); + this.transport.start(); if (this.joinConfig?.manageMediaKeys) { this.makeNewSenderKey(); this.requestSendCurrentKey(); @@ -124,15 +117,12 @@ export class EncryptionManager implements IEncryptionManager { } public leave(): void { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - - if (!userId) throw new Error("No userId"); - if (!deviceId) throw new Error("No deviceId"); // clear our encryption keys as we're done with them now (we'll // make new keys if we rejoin). We leave keys for other participants // as they may still be using the same ones. - this.encryptionKeys.set(getParticipantId(userId, deviceId), []); + this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []); + this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); + this.transport.stop(); if (this.makeNewKeyTimeout !== undefined) { clearTimeout(this.makeNewKeyTimeout); @@ -146,18 +136,17 @@ export class EncryptionManager implements IEncryptionManager { this.manageMediaKeys = false; this.joined = false; } - // TODO deduplicate this method. It also is in MatrixRTCSession. - private isMyMembership = (m: CallMembership): boolean => - m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); public onMembershipsUpdate(oldMemberships: CallMembership[]): void { if (this.manageMediaKeys && this.joined) { const oldMembershipIds = new Set( - oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership), + oldMemberships + .filter((m) => !isMyMembership(m, this.userId, this.deviceId)) + .map(getParticipantIdFromMembership), ); const newMembershipIds = new Set( this.getMemberships() - .filter((m) => !this.isMyMembership(m)) + .filter((m) => !isMyMembership(m, this.userId, this.deviceId)) .map(getParticipantIdFromMembership), ); @@ -204,16 +193,17 @@ export class EncryptionManager implements IEncryptionManager { * @returns The index of the new key */ private makeNewSenderKey(delayBeforeUse = false): number { - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - - if (!userId) throw new Error("No userId"); - if (!deviceId) throw new Error("No deviceId"); - const encryptionKey = secureRandomBase64Url(16); const encryptionKeyIndex = this.getNewEncryptionKeyIndex(); logger.info("Generated new key at index " + encryptionKeyIndex); - this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse); + this.setEncryptionKey( + this.userId, + this.deviceId, + encryptionKeyIndex, + encryptionKey, + Date.now(), + delayBeforeUse, + ); return encryptionKeyIndex; } @@ -266,13 +256,7 @@ export class EncryptionManager implements IEncryptionManager { logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`); - const userId = this.client.getUserId(); - const deviceId = this.client.getDeviceId(); - - if (!userId) throw new Error("No userId"); - if (!deviceId) throw new Error("No deviceId"); - - const myKeys = this.getKeysForParticipant(userId, deviceId); + const myKeys = this.getKeysForParticipant(this.userId, this.deviceId); if (!myKeys) { logger.warn("Tried to send encryption keys event but no keys found!"); @@ -288,35 +272,15 @@ export class EncryptionManager implements IEncryptionManager { const keyToSend = myKeys[keyIndexToSend]; try { - const content: EncryptionKeysEventContent = { - keys: [ - { - index: keyIndexToSend, - key: encodeUnpaddedBase64(keyToSend), - }, - ], - device_id: deviceId, - call_id: "", - sent_ts: Date.now(), - }; - this.statistics.counters.roomEventEncryptionKeysSent += 1; - - await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content); - + await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships()); logger.debug( - `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`, + `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`, this.encryptionKeys, ); } catch (error) { - const matrixError = error as MatrixError; - if (matrixError.event) { - // cancel the pending event: we'll just generate a new one with our latest - // keys when we resend - this.client.cancelPendingEvent(matrixError.event); - } if (this.keysEventUpdateTimeout === undefined) { - const resendDelay = safeGetRetryAfterMs(matrixError, 5000); + const resendDelay = safeGetRetryAfterMs(error, 5000); logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error); this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay); } else { @@ -325,79 +289,14 @@ export class EncryptionManager implements IEncryptionManager { } }; - public onCallEncryptionEventReceived = (event: MatrixEvent): void => { - const userId = event.getSender(); - const content = event.getContent(); - - const deviceId = content["device_id"]; - const callId = content["call_id"]; - - if (!userId) { - logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`); - return; - } - - // We currently only handle callId = "" (which is the default for room scoped calls) - if (callId !== "") { - logger.warn( - `Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`, - ); - return; - } - - if (!Array.isArray(content.keys)) { - logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`); - return; - } - - if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { - // We store our own sender key in the same set along with keys from others, so it's - // important we don't allow our own keys to be set by one of these events (apart from - // the fact that we don't need it anyway because we already know our own keys). - logger.info("Ignoring our own keys event"); - return; - } - - this.statistics.counters.roomEventEncryptionKeysReceived += 1; - const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs()); - this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age; - - for (const key of content.keys) { - if (!key) { - logger.info("Ignoring false-y key in keys event"); - continue; - } - - const encryptionKey = key.key; - const encryptionKeyIndex = key.index; - - if ( - !encryptionKey || - encryptionKeyIndex === undefined || - encryptionKeyIndex === null || - callId === undefined || - callId === null || - typeof deviceId !== "string" || - typeof callId !== "string" || - typeof encryptionKey !== "string" || - typeof encryptionKeyIndex !== "number" - ) { - logger.warn( - `Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`, - ); - } else { - logger.debug( - `Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`, - this.encryptionKeys, - ); - this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs()); - } - } + public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => { + this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp); }; + private storeLastMembershipFingerprints(): void { this.lastMembershipFingerprints = new Set( this.getMemberships() - .filter((m) => !this.isMyMembership(m)) + .filter((m) => !isMyMembership(m, this.userId, this.deviceId)) .map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`), ); } @@ -466,14 +365,14 @@ export class EncryptionManager implements IEncryptionManager { const useKeyTimeout = setTimeout(() => { this.setNewKeyTimeouts.delete(useKeyTimeout); logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`); - if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { + if (userId === this.userId && deviceId === this.deviceId) { this.currentEncryptionKeyIndex = encryptionKeyIndex; } this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); }, this.useKeyDelay); this.setNewKeyTimeouts.add(useKeyTimeout); } else { - if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { + if (userId === this.userId && deviceId === this.deviceId) { this.currentEncryptionKeyIndex = encryptionKeyIndex; } this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); @@ -493,8 +392,10 @@ export class EncryptionManager implements IEncryptionManager { } const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; + function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean { if (a === b) return true; return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]); } + const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId); diff --git a/src/matrixrtc/IKeyTransport.ts b/src/matrixrtc/IKeyTransport.ts new file mode 100644 index 000000000..4548f746a --- /dev/null +++ b/src/matrixrtc/IKeyTransport.ts @@ -0,0 +1,53 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { type CallMembership } from "./CallMembership.ts"; + +export enum KeyTransportEvents { + ReceivedKeys = "received_keys", +} + +export type KeyTransportEventListener = ( + userId: string, + deviceId: string, + keyBase64Encoded: string, + index: number, + timestamp: number, +) => void; + +export type KeyTransportEventsHandlerMap = { + [KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener; +}; + +/** + * Generic interface for the transport used to share room keys. + * Keys can be shared using different transports, e.g. to-device messages or room messages. + */ +export interface IKeyTransport { + /** + * Sends the current user media key to the given members. + * @param keyBase64Encoded + * @param index + * @param members - The participants that should get they key + */ + sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise; + + on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; + off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; + + start(): void; + stop(): void; +} diff --git a/src/matrixrtc/IMembershipManager.ts b/src/matrixrtc/IMembershipManager.ts new file mode 100644 index 000000000..bff438179 --- /dev/null +++ b/src/matrixrtc/IMembershipManager.ts @@ -0,0 +1,88 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { CallMembership } from "./CallMembership.ts"; +import type { Focus } from "./focus.ts"; +import type { Status } from "./types.ts"; + +export enum MembershipManagerEvent { + StatusChanged = "StatusChanged", +} + +export type MembershipManagerEventHandlerMap = { + [MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void; +}; + +/** + * This interface defines what a MembershipManager uses and exposes. + * This interface is what we use to write tests and allows changing the actual implementation + * without breaking tests because of some internal method renaming. + * + * @internal + */ +export interface IMembershipManager { + /** + * If we are trying to join, or have successfully joined the session. + * It does not reflect if the room state is already configured to represent us being joined. + * It only means that the Manager should be trying to connect or to disconnect running. + * The Manager is still running right after isJoined becomes false to send the disconnect events. + * @returns true if we intend to be participating in the MatrixRTC session + * @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`) + */ + isJoined(): boolean; + /** + * If the manager is activated. This means it tries to do its job to join the call, resend state events... + * It does not imply that the room state is already configured to represent being joined. + * It means that the Manager tries to connect or is connected. ("the manager is still active") + * Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves. + * @returns `true` if we intend to be participating in the MatrixRTC session + */ + isActivated(): boolean; + /** + * Get the actual connection status of the manager. + */ + get status(): Status; + /** + * The current status while the manager is activated + */ + /** + * Start sending all necessary events to make this user participate in the RTC session. + * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. + * @param fociActive the active focus to use in the joined RTC membership event. + * @throws can throw if it exceeds a configured maximum retry. + */ + join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void; + /** + * Send all necessary events to make this user leave the RTC session. + * @param timeout the maximum duration in ms until the promise is forced to resolve. + * @returns It resolves with true in case the leave was sent successfully. + * It resolves with false in case we hit the timeout before sending successfully. + */ + leave(timeout?: number): Promise; + /** + * Call this if the MatrixRTC session members have changed. + */ + onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; + /** + * The used active focus in the currently joined session. + * @returns the used active focus in the currently joined session or undefined if not joined. + */ + getActiveFocus(): Focus | undefined; + + // TypedEventEmitter methods: + on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this; + off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this; +} diff --git a/src/matrixrtc/LegacyMembershipManager.ts b/src/matrixrtc/LegacyMembershipManager.ts index 6ecff9cbc..7ba393dbc 100644 --- a/src/matrixrtc/LegacyMembershipManager.ts +++ b/src/matrixrtc/LegacyMembershipManager.ts @@ -27,7 +27,8 @@ import { type Focus } from "./focus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { type MembershipConfig } from "./MatrixRTCSession.ts"; import { type EmptyObject } from "../@types/common.ts"; -import { type IMembershipManager, type MembershipManagerEvent, Status } from "./types.ts"; +import { Status } from "./types.ts"; +import type { IMembershipManager, MembershipManagerEvent } from "./IMembershipManager.ts"; /** * This internal class is used by the MatrixRTCSession to manage the local user's own membership of the session. diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 26f11f124..0c8cb6ee6 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -24,12 +24,13 @@ import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { type Focus } from "./focus.ts"; import { KnownMembership } from "../@types/membership.ts"; -import { type MatrixEvent } from "../models/event.ts"; import { MembershipManager } from "./NewMembershipManager.ts"; -import { EncryptionManager, type IEncryptionManager, type Statistics } from "./EncryptionManager.ts"; +import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { LegacyMembershipManager } from "./LegacyMembershipManager.ts"; import { logDurationSync } from "../utils.ts"; -import type { IMembershipManager } from "./types.ts"; +import { RoomKeyTransport } from "./RoomKeyTransport.ts"; +import { type IMembershipManager } from "./IMembershipManager.ts"; +import { type Statistics } from "./types.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -159,7 +160,7 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig; */ export class MatrixRTCSession extends TypedEventEmitter { private membershipManager?: IMembershipManager; - private encryptionManager: IEncryptionManager; + private encryptionManager?: IEncryptionManager; // The session Id of the call, this is the call_id of the call Member event. private _callId: string | undefined; @@ -173,9 +174,15 @@ export class MatrixRTCSession extends TypedEventEmitter, + private roomSubset: Pick< + Room, + "getLiveTimeline" | "roomId" | "getVersion" | "hasMembershipState" | "on" | "off" >, - private roomSubset: Pick, public memberships: CallMembership[], ) { super(); @@ -306,14 +317,6 @@ export class MatrixRTCSession extends TypedEventEmitter this.memberships, - (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { - this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); - }, - ); } /* @@ -366,6 +369,18 @@ export class MatrixRTCSession extends TypedEventEmitter this.memberships, + transport, + this.statistics, + (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { + this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); + }, + ); } // Join! @@ -397,7 +412,7 @@ export class MatrixRTCSession extends TypedEventEmitter { + this.encryptionManager?.getEncryptionKeys().forEach((keys, participantId) => { keys.forEach((key, index) => { this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId); }); @@ -452,7 +467,7 @@ export class MatrixRTCSession extends TypedEventEmitter]> { const keys = - this.encryptionManager.getEncryptionKeys() ?? + this.encryptionManager?.getEncryptionKeys() ?? new Map>(); // the returned array doesn't contain the timestamps return Array.from(keys.entries()) @@ -484,25 +499,6 @@ export class MatrixRTCSession extends TypedEventEmitter { - this.encryptionManager.onCallEncryptionEventReceived(event); - }; - - /** - * @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated - * the old name might have implied to only need to call this when your own membership changes. - */ - public onMembershipUpdate = (): void => { - this.recalculateSessionMembers(); - }; - /** * Call this when the Matrix room members have changed. */ @@ -544,7 +540,7 @@ export class MatrixRTCSession extends TypedEventEmitter { - await this.client.decryptEventIfNeeded(event); - if (event.isDecryptionFailure()) { - if (!isRetry) { - logger.warn( - `Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`, - ); - // retry after 1 second. After this we give up. - setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000); - } else { - logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); - } - return; - } else if (isRetry) { - logger.info(`Decryption succeeded for event ${event.getId()} after retry`); - } - - if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve(); - - const room = this.client.getRoom(event.getRoomId()); - if (!room) { - logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); - return Promise.resolve(); - } - - this.getRoomSession(room).onCallEncryption(event); - } - private onTimeline = (event: MatrixEvent): void => { - void this.consumeCallEncryptionEvent(event); - }; - private onRoom = (room: Room): void => { this.refreshRoom(room); }; @@ -149,17 +116,23 @@ export class MatrixRTCSessionManager extends TypedEventEmitter 0 && !isNewSession; + const wasActiveAndKnown = session.memberships.length > 0 && !isNewSession; + // This needs to be here and the event listener cannot be setup in the MatrixRTCSession, + // because we need the update to happen between: + // wasActiveAndKnown = session.memberships.length > 0 and + // nowActive = session.memberships.length + // Alternatively we would need to setup some event emission when the RTC session ended. + session.onRTCSessionMemberUpdate(); - sess.onRTCSessionMemberUpdate(); - - const nowActive = sess.memberships.length > 0; + const nowActive = session.memberships.length > 0; if (wasActiveAndKnown && !nowActive) { + logger.trace(`Session ended for ${room.roomId} (${session.memberships.length} members)`); this.emit(MatrixRTCSessionManagerEvents.SessionEnded, room.roomId, this.roomSessions.get(room.roomId)!); } else if (!wasActiveAndKnown && nowActive) { + logger.trace(`Session started for ${room.roomId} (${session.memberships.length} members)`); this.emit(MatrixRTCSessionManagerEvents.SessionStarted, room.roomId, this.roomSessions.get(room.roomId)!); } } diff --git a/src/matrixrtc/NewMembershipManager.ts b/src/matrixrtc/NewMembershipManager.ts index d4307a7a9..10b47fe51 100644 --- a/src/matrixrtc/NewMembershipManager.ts +++ b/src/matrixrtc/NewMembershipManager.ts @@ -24,16 +24,16 @@ import { type Room } from "../models/room.ts"; import { defer, type IDeferred } from "../utils.ts"; import { type CallMembership, DEFAULT_EXPIRE_DURATION, type SessionMembershipData } from "./CallMembership.ts"; import { type Focus } from "./focus.ts"; -import { - type IMembershipManager, - type MembershipManagerEventHandlerMap, - MembershipManagerEvent, - Status, -} from "./types.ts"; +import { isMyMembership, Status } from "./types.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { type MembershipConfig } from "./MatrixRTCSession.ts"; import { ActionScheduler, type ActionUpdate } from "./NewMembershipManagerActionScheduler.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; +import { + MembershipManagerEvent, + type IMembershipManager, + type MembershipManagerEventHandlerMap, +} from "./IMembershipManager.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -219,10 +219,9 @@ export class MembershipManager private leavePromiseDefer?: IDeferred; public async onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise { - const isMyMembership = (m: CallMembership): boolean => - m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); - - if (this.isJoined() && !memberships.some(isMyMembership)) { + const userId = this.client.getUserId(); + const deviceId = this.client.getDeviceId(); + if (userId && deviceId && this.isJoined() && !memberships.some((m) => isMyMembership(m, userId, deviceId))) { // If one of these actions are scheduled or are getting inserted in the next iteration, we should already // take care of our missing membership. const sendingMembershipActions = [ diff --git a/src/matrixrtc/RoomKeyTransport.ts b/src/matrixrtc/RoomKeyTransport.ts new file mode 100644 index 000000000..f255eb2a1 --- /dev/null +++ b/src/matrixrtc/RoomKeyTransport.ts @@ -0,0 +1,184 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import type { MatrixClient } from "../client.ts"; +import type { EncryptionKeysEventContent, Statistics } from "./types.ts"; +import { EventType } from "../@types/event.ts"; +import { type MatrixError } from "../http-api/errors.ts"; +import { logger, type Logger } from "../logger.ts"; +import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts"; +import { type MatrixEvent } from "../models/event.ts"; +import { type CallMembership } from "./CallMembership.ts"; +import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; +import { type Room, RoomEvent } from "../models/room.ts"; + +export class RoomKeyTransport + extends TypedEventEmitter + implements IKeyTransport +{ + private readonly prefixedLogger: Logger; + + public constructor( + private room: Pick, + private client: Pick< + MatrixClient, + "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent" | "decryptEventIfNeeded" + >, + private statistics: Statistics, + ) { + super(); + this.prefixedLogger = logger.getChild(`[RTC: ${room.roomId} RoomKeyTransport]`); + } + public start(): void { + this.room.on(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev)); + } + public stop(): void { + this.room.off(RoomEvent.Timeline, (ev) => void this.consumeCallEncryptionEvent(ev)); + } + + private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise { + await this.client.decryptEventIfNeeded(event); + + if (event.isDecryptionFailure()) { + if (!isRetry) { + logger.warn( + `Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`, + ); + // retry after 1 second. After this we give up. + setTimeout(() => void this.consumeCallEncryptionEvent(event, true), 1000); + } else { + logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); + } + return; + } else if (isRetry) { + logger.info(`Decryption succeeded for event ${event.getId()} after retry`); + } + + if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve(); + + if (!this.room) { + logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); + return Promise.resolve(); + } + + this.onEncryptionEvent(event); + } + + /** implements {@link IKeyTransport#sendKey} */ + public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise { + // members not used in room transports as the keys are sent to all room members + const content: EncryptionKeysEventContent = { + keys: [ + { + index: index, + key: keyBase64Encoded, + }, + ], + device_id: this.client.getDeviceId()!, + call_id: "", + sent_ts: Date.now(), + }; + + try { + await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content); + } catch (error) { + this.prefixedLogger.error("Failed to send call encryption keys", error); + const matrixError = error as MatrixError; + if (matrixError.event) { + // cancel the pending event: we'll just generate a new one with our latest + // keys when we resend + this.client.cancelPendingEvent(matrixError.event); + } + throw error; + } + } + + public onEncryptionEvent(event: MatrixEvent): void { + const userId = event.getSender(); + const content = event.getContent(); + + const deviceId = content["device_id"]; + const callId = content["call_id"]; + + if (!userId) { + logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`); + return; + } + + // We currently only handle callId = "" (which is the default for room scoped calls) + if (callId !== "") { + logger.warn( + `Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`, + ); + return; + } + + if (!Array.isArray(content.keys)) { + logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`); + return; + } + + if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { + // We store our own sender key in the same set along with keys from others, so it's + // important we don't allow our own keys to be set by one of these events (apart from + // the fact that we don't need it anyway because we already know our own keys). + logger.info("Ignoring our own keys event"); + return; + } + + this.statistics.counters.roomEventEncryptionKeysReceived += 1; + const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs()); + this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age; + + for (const key of content.keys) { + if (!key) { + logger.info("Ignoring false-y key in keys event"); + continue; + } + + const encryptionKey = key.key; + const encryptionKeyIndex = key.index; + + if ( + !encryptionKey || + encryptionKeyIndex === undefined || + encryptionKeyIndex === null || + callId === undefined || + callId === null || + typeof deviceId !== "string" || + typeof callId !== "string" || + typeof encryptionKey !== "string" || + typeof encryptionKeyIndex !== "number" + ) { + logger.warn( + `Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`, + ); + } else { + logger.debug( + `Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`, + ); + this.emit( + KeyTransportEvents.ReceivedKeys, + userId, + deviceId, + encryptionKey, + encryptionKeyIndex, + event.getTs(), + ); + } + } + } +} diff --git a/src/matrixrtc/index.ts b/src/matrixrtc/index.ts index 1ce647095..40ab6919f 100644 --- a/src/matrixrtc/index.ts +++ b/src/matrixrtc/index.ts @@ -20,4 +20,5 @@ export * from "./LivekitFocus.ts"; export * from "./MatrixRTCSession.ts"; export * from "./MatrixRTCSessionManager.ts"; export type * from "./types.ts"; -export { Status, MembershipManagerEvent } from "./types.ts"; +export { Status } from "./types.ts"; +export { MembershipManagerEvent } from "./IMembershipManager.ts"; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 532ac8dc7..ee8c654bb 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -15,7 +15,6 @@ limitations under the License. */ import type { IMentions } from "../matrix.ts"; import type { CallMembership } from "./CallMembership.ts"; -import type { Focus } from "./focus.ts"; export interface EncryptionKeyEntry { index: number; @@ -49,71 +48,28 @@ export enum Status { Unknown = "Unknown", } -export enum MembershipManagerEvent { - StatusChanged = "StatusChanged", -} - -export type MembershipManagerEventHandlerMap = { - [MembershipManagerEvent.StatusChanged]: (prefStatus: Status, newStatus: Status) => void; +/** + * A type collecting call encryption statistics for a session. + */ +export type Statistics = { + counters: { + /** + * The number of times we have sent a room event containing encryption keys. + */ + roomEventEncryptionKeysSent: number; + /** + * The number of times we have received a room event containing encryption keys. + */ + roomEventEncryptionKeysReceived: number; + }; + totals: { + /** + * The total age (in milliseconds) of all room events containing encryption keys that we have received. + * We track the total age so that we can later calculate the average age of all keys received. + */ + roomEventEncryptionKeysReceivedTotalAge: number; + }; }; -/** - * This interface defines what a MembershipManager uses and exposes. - * This interface is what we use to write tests and allows changing the actual implementation - * without breaking tests because of some internal method renaming. - * - * @internal - */ -export interface IMembershipManager { - /** - * If we are trying to join, or have successfully joined the session. - * It does not reflect if the room state is already configured to represent us being joined. - * It only means that the Manager should be trying to connect or to disconnect running. - * The Manager is still running right after isJoined becomes false to send the disconnect events. - * @returns true if we intend to be participating in the MatrixRTC session - * @deprecated This name is confusing and replaced by `isActivated()`. (Returns the same as `isActivated()`) - */ - isJoined(): boolean; - /** - * If the manager is activated. This means it tries to do its job to join the call, resend state events... - * It does not imply that the room state is already configured to represent being joined. - * It means that the Manager tries to connect or is connected. ("the manager is still active") - * Once `leave()` is called the manager is not activated anymore but still running until `leave()` resolves. - * @returns `true` if we intend to be participating in the MatrixRTC session - */ - isActivated(): boolean; - /** - * Get the actual connection status of the manager. - */ - get status(): Status; - /** - * The current status while the manager is activated - */ - /** - * Start sending all necessary events to make this user participate in the RTC session. - * @param fociPreferred the list of preferred foci to use in the joined RTC membership event. - * @param fociActive the active focus to use in the joined RTC membership event. - * @throws can throw if it exceeds a configured maximum retry. - */ - join(fociPreferred: Focus[], fociActive?: Focus, onError?: (error: unknown) => void): void; - /** - * Send all necessary events to make this user leave the RTC session. - * @param timeout the maximum duration in ms until the promise is forced to resolve. - * @returns It resolves with true in case the leave was sent successfully. - * It resolves with false in case we hit the timeout before sending successfully. - */ - leave(timeout?: number): Promise; - /** - * Call this if the MatrixRTC session members have changed. - */ - onRTCSessionMemberUpdate(memberships: CallMembership[]): Promise; - /** - * The used active focus in the currently joined session. - * @returns the used active focus in the currently joined session or undefined if not joined. - */ - getActiveFocus(): Focus | undefined; - - // TypedEventEmitter methods: - on(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this; - off(event: MembershipManagerEvent.StatusChanged, listener: (oldStatus: Status, newStatus: Status) => void): this; -} +export const isMyMembership = (m: CallMembership, userId: string, deviceId: string): boolean => + m.sender === userId && m.deviceId === deviceId;