diff --git a/spec/unit/embedded.spec.ts b/spec/unit/embedded.spec.ts index 0b27541db31..f23fd75e9a1 100644 --- a/spec/unit/embedded.spec.ts +++ b/spec/unit/embedded.spec.ts @@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => { expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData); }); - it("sends encrypted (encryptAndSendToDevices)", async () => { + it("sends encrypted (encryptAndSendToDevice)", async () => { await makeClient({ sendToDevice: ["org.example.foo"] }); expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo"); - const payload = { type: "org.example.foo", hello: "world" }; + const payload = { hello: "world" }; const embeddedClient = client as RoomWidgetClient; - await embeddedClient.encryptAndSendToDevices( + await embeddedClient.encryptAndSendToDevice( + "org.example.foo", [ { userId: "@alice:example.org", deviceId: "aliceWeb" }, { userId: "@bob:example.org", deviceId: "bobDesktop" }, diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 9f66bdee6e8..7dee5ccaa46 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -486,14 +486,17 @@ describe("MatrixRTCSession", () => { let sendStateEventMock: jest.Mock; let sendDelayedStateMock: jest.Mock; let sendEventMock: jest.Mock; + let sendToDeviceMock: jest.Mock; beforeEach(() => { sendStateEventMock = jest.fn(); sendDelayedStateMock = jest.fn(); sendEventMock = jest.fn(); + sendToDeviceMock = jest.fn(); client.sendStateEvent = sendStateEventMock; client._unstable_sendDelayedStateEvent = sendDelayedStateMock; client.sendEvent = sendEventMock; + client.encryptAndSendToDevice = sendToDeviceMock; mockRoom = makeMockRoom([]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); @@ -832,6 +835,7 @@ describe("MatrixRTCSession", () => { it("rotates key if a member leaves", async () => { jest.useFakeTimers(); try { + const KEY_DELAY = 3000; const member2 = Object.assign({}, membershipTemplate, { device_id: "BBBBBBB", }); @@ -852,7 +856,8 @@ describe("MatrixRTCSession", () => { sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); }); - sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true }); + sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY }); + const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey"); const firstKeysPayload = await keysSentPromise1; expect(firstKeysPayload.keys).toHaveLength(1); expect(firstKeysPayload.keys[0].index).toEqual(0); @@ -869,14 +874,24 @@ describe("MatrixRTCSession", () => { .mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId)); sess.onRTCSessionMemberUpdate(); - jest.advanceTimersByTime(10000); + jest.advanceTimersByTime(KEY_DELAY); + expect(sendKeySpy).toHaveBeenCalledTimes(1); + // check that we send the key with index 1 even though the send gets delayed when leaving. + // this makes sure we do not use an index that is one too old. + expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships); + // fake a condition in which we send another encryption key event. + // this could happen do to someone joining the call. + (sess as unknown as any).encryptionManager.sendEncryptionKeysEvent(); + expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships); + jest.advanceTimersByTime(7000); const secondKeysPayload = await keysSentPromise2; expect(secondKeysPayload.keys).toHaveLength(1); expect(secondKeysPayload.keys[0].index).toEqual(1); expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2); - expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2); + // initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();` + expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3); } finally { jest.useRealTimers(); } @@ -965,6 +980,29 @@ describe("MatrixRTCSession", () => { jest.useRealTimers(); } }); + + it("send key as to device", async () => { + jest.useFakeTimers(); + try { + const keySentPromise = new Promise((resolve) => { + sendToDeviceMock.mockImplementation(resolve); + }); + + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + sess!.joinRoomSession([mockFocus], mockFocus, { + manageMediaKeys: true, + useExperimentalToDeviceTransport: true, + }); + + await keySentPromise; + + expect(sendToDeviceMock).toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); }); describe("receiving", () => { diff --git a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts index a5e462be826..0d0db2e4fff 100644 --- a/spec/unit/matrixrtc/RoomKeyTransport.spec.ts +++ b/spec/unit/matrixrtc/RoomKeyTransport.spec.ts @@ -20,7 +20,7 @@ import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport"; import { EventType, MatrixClient, RoomEvent } from "../../../src"; import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src"; -describe("RoomKyTransport", () => { +describe("RoomKeyTransport", () => { let client: MatrixClient; let room: Room & { emitTimelineEvent: (event: MatrixEvent) => void; diff --git a/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts new file mode 100644 index 00000000000..ae165152cb1 --- /dev/null +++ b/spec/unit/matrixrtc/ToDeviceKeyTransport.spec.ts @@ -0,0 +1,249 @@ +/* +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 Mocked } from "jest-mock"; + +import { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks"; +import { ClientEvent, EventType, type MatrixClient } from "../../../src"; +import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts"; +import { getMockClientWithEventEmitter } from "../../test-utils/client.ts"; +import { type Statistics } from "../../../src/matrixrtc"; +import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts"; +import { defer } from "../../../src/utils.ts"; +import { type Logger } from "../../../src/logger.ts"; + +describe("ToDeviceKeyTransport", () => { + const roomId = "!room:id"; + + let mockClient: Mocked; + let statistics: Statistics; + let mockLogger: Mocked; + let transport: ToDeviceKeyTransport; + + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + encryptAndSendToDevice: jest.fn(), + }); + mockLogger = { + debug: jest.fn(), + warn: jest.fn(), + } as unknown as Mocked; + statistics = { + counters: { + roomEventEncryptionKeysSent: 0, + roomEventEncryptionKeysReceived: 0, + }, + totals: { + roomEventEncryptionKeysReceivedTotalAge: 0, + }, + }; + + transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, { + getChild: jest.fn().mockReturnValue(mockLogger), + } as unknown as Mocked); + }); + + it("should send my keys on via to device", async () => { + transport.start(); + + const keyBase64Encoded = "ABCDEDF"; + const keyIndex = 2; + await transport.sendKey(keyBase64Encoded, keyIndex, [ + mockCallMembership( + Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }), + roomId, + "@bob:example.org", + ), + mockCallMembership( + Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }), + roomId, + "@carl:example.org", + ), + mockCallMembership( + Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }), + roomId, + "@mat:example.org", + ), + ]); + + expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1); + expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith( + "io.element.call.encryption_keys", + [ + { userId: "@bob:example.org", deviceId: "BOBDEVICE" }, + { userId: "@carl:example.org", deviceId: "CARLDEVICE" }, + { userId: "@mat:example.org", deviceId: "MATDEVICE" }, + ], + { + keys: { + index: keyIndex, + key: keyBase64Encoded, + }, + member: { + claimed_device_id: "MYDEVICE", + }, + room_id: roomId, + session: { + application: "m.call", + call_id: "", + scope: "m.room", + }, + }, + ); + + expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1); + }); + + it("should emit when a key is received", async () => { + const deferred = defer<{ userId: string; deviceId: string; keyBase64Encoded: string; index: number }>(); + transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => { + deferred.resolve({ userId, deviceId, keyBase64Encoded, index }); + }); + transport.start(); + + const testEncoded = "ABCDEDF"; + const testKeyIndex = 2; + + mockClient.emit( + ClientEvent.ToDeviceEvent, + makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, { + keys: { + index: testKeyIndex, + key: testEncoded, + }, + member: { + claimed_device_id: "BOBDEVICE", + }, + room_id: roomId, + session: { + application: "m.call", + call_id: "", + scope: "m.room", + }, + }), + ); + + const { userId, deviceId, keyBase64Encoded, index } = await deferred.promise; + expect(userId).toBe("@bob:example.org"); + expect(deviceId).toBe("BOBDEVICE"); + expect(keyBase64Encoded).toBe(testEncoded); + expect(index).toBe(testKeyIndex); + + expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1); + }); + + it("should not sent to ourself", async () => { + const keyBase64Encoded = "ABCDEDF"; + const keyIndex = 2; + await transport.sendKey(keyBase64Encoded, keyIndex, [ + mockCallMembership( + Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }), + roomId, + "@alice:example.org", + ), + ]); + + transport.start(); + + expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0); + }); + + it("should warn when there is a room mismatch", () => { + transport.start(); + + const testEncoded = "ABCDEDF"; + const testKeyIndex = 2; + + mockClient.emit( + ClientEvent.ToDeviceEvent, + makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, { + keys: { + index: testKeyIndex, + key: testEncoded, + }, + member: { + claimed_device_id: "BOBDEVICE", + }, + room_id: "!anotherroom:id", + session: { + application: "m.call", + call_id: "", + scope: "m.room", + }, + }), + ); + + expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId"); + expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0); + }); + + describe("malformed events", () => { + const MALFORMED_EVENT = [ + { + keys: {}, + member: { claimed_device_id: "MYDEVICE" }, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { index: 0 }, + member: { claimed_device_id: "MYDEVICE" }, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { keys: "ABCDEF" }, + member: { claimed_device_id: "MYDEVICE" }, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { keys: "ABCDEF", index: 2 }, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { keys: "ABCDEF", index: 2 }, + member: {}, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { keys: "ABCDEF", index: 2 }, + member: { claimed_device_id: "MYDEVICE" }, + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + { + keys: { keys: "ABCDEF", index: 2 }, + member: { claimed_device_id: "MYDEVICE" }, + room_id: "!room:id", + session: { application: "m.call", call_id: "", scope: "m.room" }, + }, + ]; + + test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => { + transport.start(); + + mockClient.emit( + ClientEvent.ToDeviceEvent, + makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event), + ); + + expect(mockLogger.warn).toHaveBeenCalled(); + expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0); + }); + }); +}); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index 5a485e7d41e..f20a9364efb 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -123,7 +123,7 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string export function makeMockEvent( type: string, sender: string, - roomId: string, + roomId: string | undefined, content: any, timestamp?: number, ): MatrixEvent { diff --git a/src/client.ts b/src/client.ts index 475ee77f093..6974f35fa4e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -207,7 +207,7 @@ import { import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts"; import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts"; import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts"; -import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts"; +import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts"; import { IgnoredInvites } from "./models/invites-ignorer.ts"; import { type UIARequest } from "./@types/uia.ts"; import { type LocalNotificationSettings } from "./@types/local_notifications.ts"; @@ -7942,6 +7942,29 @@ export class MatrixClient extends TypedEventEmitter { + if (!this.cryptoBackend) { + throw new Error("Cannot encrypt to device event, your client does not support encryption."); + } + const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload); + + // TODO The batch mechanism removes all possibility to get error feedbacks.. + // We might want instead to do the API call directly and pass the errors back. + await this.queueToDevice(batch); + } + /** * Sends events directly to specific devices using Matrix's to-device * messaging system. The batch will be split up into appropriately sized diff --git a/src/embedded.ts b/src/embedded.ts index 0882872e5ad..f96043795d2 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -62,17 +62,6 @@ interface IStateEventRequest { stateKey?: string; } -export interface OlmDevice { - /** - * The user ID of the device owner. - */ - userId: string; - /** - * The device ID of the device. - */ - deviceId: string; -} - export interface ICapabilities { /** * Event types that this client expects to send. @@ -464,6 +453,25 @@ export class RoomWidgetClient extends MatrixClient { return {}; } + /** + * by {@link MatrixClient.encryptAndSendToDevice}. + */ + public async encryptAndSendToDevice( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload, + ): Promise { + // map: user Id → device Id → payload + const contentMap: MapWithDefault> = new MapWithDefault(() => new Map()); + for (const { userId, deviceId } of devices) { + contentMap.getOrCreate(userId).set(deviceId, payload); + } + + await this.widgetApi + .sendToDevice(eventType, true, recursiveMapToObject(contentMap)) + .catch(timeoutToConnectionError); + } + public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise { await this.widgetApi .sendToDevice(eventType, false, recursiveMapToObject(contentMap)) @@ -495,18 +503,6 @@ export class RoomWidgetClient extends MatrixClient { .catch(timeoutToConnectionError); } - public async encryptAndSendToDevices(userDeviceInfoArr: OlmDevice[], payload: object): Promise { - // map: user Id → device Id → payload - const contentMap: MapWithDefault> = new MapWithDefault(() => new Map()); - for (const { userId, deviceId } of userDeviceInfoArr) { - contentMap.getOrCreate(userId).set(deviceId, payload); - } - - await this.widgetApi - .sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap)) - .catch(timeoutToConnectionError); - } - /** * Send an event to a specific list of devices via the widget API. Optionally encrypts the event. * diff --git a/src/matrixrtc/EncryptionManager.ts b/src/matrixrtc/EncryptionManager.ts index b97cd9dd338..8495f9920ba 100644 --- a/src/matrixrtc/EncryptionManager.ts +++ b/src/matrixrtc/EncryptionManager.ts @@ -13,10 +13,6 @@ 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 { /** * Joins the encryption manager with the provided configuration. @@ -80,8 +76,7 @@ export class EncryptionManager implements IEncryptionManager { // if it looks like a membership has been updated. private lastMembershipFingerprints: Set | undefined; - private currentEncryptionKeyIndex = -1; - + private latestGeneratedKeyIndex = -1; private joinConfig: EncryptionConfig | undefined; public constructor( @@ -254,8 +249,6 @@ export class EncryptionManager implements IEncryptionManager { if (!this.joined) return; - logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`); - const myKeys = this.getKeysForParticipant(this.userId, this.deviceId); if (!myKeys) { @@ -263,19 +256,23 @@ export class EncryptionManager implements IEncryptionManager { return; } - if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) { + if (typeof indexToSend !== "number" && this.latestGeneratedKeyIndex === -1) { logger.warn("Tried to send encryption keys event but no current key index found!"); return; } - const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex; + const keyIndexToSend = indexToSend ?? this.latestGeneratedKeyIndex; + + logger.info( + `Try sending encryption keys event. keyIndexToSend=${keyIndexToSend} (method parameter: ${indexToSend})`, + ); const keyToSend = myKeys[keyIndexToSend]; try { this.statistics.counters.roomEventEncryptionKeysSent += 1; await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships()); logger.debug( - `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`, + `sendEncryptionKeysEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.latestGeneratedKeyIndex} keyIndexToSend=${keyIndexToSend}`, this.encryptionKeys, ); } catch (error) { @@ -290,6 +287,7 @@ export class EncryptionManager implements IEncryptionManager { }; public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => { + logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`); this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp); }; @@ -302,12 +300,12 @@ export class EncryptionManager implements IEncryptionManager { } private getNewEncryptionKeyIndex(): number { - if (this.currentEncryptionKeyIndex === -1) { + if (this.latestGeneratedKeyIndex === -1) { return 0; } // maximum key index is 255 - return (this.currentEncryptionKeyIndex + 1) % 256; + return (this.latestGeneratedKeyIndex + 1) % 256; } /** @@ -332,6 +330,7 @@ export class EncryptionManager implements IEncryptionManager { timestamp: number, delayBeforeUse = false, ): void { + logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`); const keyBin = decodeBase64(encryptionKeyString); const participantId = getParticipantId(userId, deviceId); @@ -356,6 +355,15 @@ export class EncryptionManager implements IEncryptionManager { } } + if (userId === this.userId && deviceId === this.deviceId) { + // It is important to already update the latestGeneratedKeyIndex here + // NOT IN THE `delayBeforeUse` `setTimeout`. + // Even though this is where we call onEncryptionKeysChanged and set the key in EC (and livekit). + // It needs to happen here because we will send the key before the timeout has passed and sending + // the key will use latestGeneratedKeyIndex as the index. if we update it in the `setTimeout` callback + // it will use the wrong index (index - 1)! + this.latestGeneratedKeyIndex = encryptionKeyIndex; + } participantKeys[encryptionKeyIndex] = { key: keyBin, timestamp, @@ -364,17 +372,12 @@ export class EncryptionManager implements IEncryptionManager { if (delayBeforeUse) { const useKeyTimeout = setTimeout(() => { this.setNewKeyTimeouts.delete(useKeyTimeout); - logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`); - if (userId === this.userId && deviceId === this.deviceId) { - this.currentEncryptionKeyIndex = encryptionKeyIndex; - } + logger.info(`Delayed-emitting key changed event for ${participantId} index ${encryptionKeyIndex}`); + this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); }, this.useKeyDelay); this.setNewKeyTimeouts.add(useKeyTimeout); } else { - if (userId === this.userId && deviceId === this.deviceId) { - this.currentEncryptionKeyIndex = encryptionKeyIndex; - } this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); } } diff --git a/src/matrixrtc/IKeyTransport.ts b/src/matrixrtc/IKeyTransport.ts index 4548f746a0e..f577d94a611 100644 --- a/src/matrixrtc/IKeyTransport.ts +++ b/src/matrixrtc/IKeyTransport.ts @@ -45,9 +45,17 @@ export interface IKeyTransport { */ sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise; + /** Subscribe to keys from this transport. */ on(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; + /** Unsubscribe from keys from this transport. */ off(event: KeyTransportEvents.ReceivedKeys, listener: KeyTransportEventListener): this; + /** Once start is called the underlying transport will subscribe to its transport system. + * Before start is called this transport will not emit any events. + */ start(): void; + /** Once stop is called the underlying transport will unsubscribe from its transport system. + * After stop is called this transport will not emit any events. + */ stop(): void; } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 0c8cb6ee6ca..35d1e2e4076 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -28,9 +28,10 @@ import { MembershipManager } from "./NewMembershipManager.ts"; import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts"; import { LegacyMembershipManager } from "./LegacyMembershipManager.ts"; import { logDurationSync } from "../utils.ts"; -import { RoomKeyTransport } from "./RoomKeyTransport.ts"; -import { type IMembershipManager } from "./IMembershipManager.ts"; +import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; import { type Statistics } from "./types.ts"; +import { RoomKeyTransport } from "./RoomKeyTransport.ts"; +import type { IMembershipManager } from "./IMembershipManager.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -125,6 +126,11 @@ export interface MembershipConfig { * The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs. */ maximumNetworkErrorRetryCount?: number; + + /** + * If true, use the new to-device transport for sending encryption keys. + */ + useExperimentalToDeviceTransport?: boolean; } export interface EncryptionConfig { @@ -303,6 +309,9 @@ export class MatrixRTCSession extends TypedEventEmitter, private roomSubset: Pick< @@ -370,7 +379,20 @@ export class MatrixRTCSession extends TypedEventEmitter + implements IKeyTransport +{ + private readonly prefixedLogger: Logger; + + public constructor( + private userId: string, + private deviceId: string, + private roomId: string, + private client: Pick, + private statistics: Statistics, + logger: Logger, + ) { + super(); + this.prefixedLogger = logger.getChild(`[${roomId} ToDeviceKeyTransport]`); + } + + public start(): void { + this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + + public stop(): void { + this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + } + + public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise { + const content: EncryptionKeysToDeviceEventContent = { + keys: { + index: index, + key: keyBase64Encoded, + }, + room_id: this.roomId, + member: { + claimed_device_id: this.deviceId, + }, + session: { + call_id: "", + application: "m.call", + scope: "m.room", + }, + }; + + const targets = members + .filter((member) => { + // filter malformed call members + if (member.sender == undefined || member.deviceId == undefined) { + this.prefixedLogger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`); + return false; + } + // Filter out me + return !(member.sender == this.userId && member.deviceId == this.deviceId); + }) + .map((member) => { + return { + userId: member.sender!, + deviceId: member.deviceId!, + }; + }); + + if (targets.length > 0) { + await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content); + this.statistics.counters.roomEventEncryptionKeysSent += 1; + } else { + this.prefixedLogger.warn("No targets found for sending key"); + } + } + + private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void { + // The event has already been validated at this point. + + this.statistics.counters.roomEventEncryptionKeysReceived += 1; + + // What is this, and why is it needed? + // Also to device events do not have an origin server ts + const now = Date.now(); + const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now); + this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age; + + this.emit( + KeyTransportEvents.ReceivedKeys, + // TODO this is claimed information + fromUser, + // TODO: This is claimed information + content.member.claimed_device_id!, + content.keys.key, + content.keys.index, + now, + ); + } + + private onToDeviceEvent = (event: MatrixEvent): void => { + if (event.getType() !== EventType.CallEncryptionKeysPrefix) { + // Ignore this is not a call encryption event + return; + } + + // TODO: Not possible to check if the event is encrypted or not + // see https://github.com/matrix-org/matrix-rust-sdk/issues/4883 + // if (evnt.getWireType() != EventType.RoomMessageEncrypted) { + // // WARN: The call keys were sent in clear. Ignore them + // logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`); + // return; + // } + + const content = this.getValidEventContent(event); + if (!content) return; + + if (!event.getSender()) return; + + this.receiveCallKeyEvent(event.getSender()!, content); + }; + + private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined { + const content = event.getContent(); + const roomId = content.room_id; + if (!roomId) { + // Invalid event + this.prefixedLogger.warn("Malformed Event: invalid call encryption keys event, no roomId"); + return; + } + if (roomId !== this.roomId) { + this.prefixedLogger.warn("Malformed Event: Mismatch roomId"); + return; + } + + if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") { + this.prefixedLogger.warn("Malformed Event: Missing keys field"); + return; + } + + if (!content.member || !content.member.claimed_device_id) { + this.prefixedLogger.warn("Malformed Event: Missing claimed_device_id"); + return; + } + + // TODO check for session related fields once the to-device encryption uses the new format. + return content as EncryptionKeysToDeviceEventContent; + } +} diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index ee8c654bb9b..d408080dfa1 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -28,6 +28,24 @@ export interface EncryptionKeysEventContent { sent_ts?: number; } +export interface EncryptionKeysToDeviceEventContent { + keys: { index: number; key: string }; + member: { + // id: ParticipantId, + // TODO Remove that it is claimed, need to get the sealed sender from decryption info + claimed_device_id: string; + // user_id: string + }; + room_id: string; + session: { + application: string; + call_id: string; + scope: string; + }; + // Why is this needed? + sent_ts?: number; +} + export type CallNotifyType = "ring" | "notify"; export interface ICallNotifyContent { diff --git a/src/webrtc/call.ts b/src/webrtc/call.ts index f07a91ee1c6..1bff0b192da 100644 --- a/src/webrtc/call.ts +++ b/src/webrtc/call.ts @@ -2510,7 +2510,7 @@ export class MatrixCall extends TypedEventEmitter