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/BasicEncryptionManager.ts b/src/matrixrtc/BasicEncryptionManager.ts new file mode 100644 index 00000000000..313255bc199 --- /dev/null +++ b/src/matrixrtc/BasicEncryptionManager.ts @@ -0,0 +1,258 @@ +/* +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 { IEncryptionManager } from "./EncryptionManager.ts"; +import { EncryptionConfig } from "./MatrixRTCSession.ts"; + +import { CallMembership } from "./CallMembership.ts"; +import { decodeBase64, encodeBase64 } from "../base64.ts"; +import { type KeyTransportEventListener, KeyTransportEvents } from "./IKeyTransport.ts"; +import { Logger, logger } from "../logger.ts"; +import { defer } from "../utils.ts"; +import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts"; +import { type Statistics } from "./types.ts"; + +type DeviceInfo = { + userId: string; + deviceId: string; +}; + +export type ParticipantId = string; + +type OutboundEncryptionSession = { + key: Uint8Array; + creationTS: number; + sharedWith: Array; + // This is an index acting as the id of the key + keyId: number; + dirty: boolean; +}; + +type InboundEncryptionSession = { + key: Uint8Array; + participantId: ParticipantId; + keyId: number; + creationTS: number; +}; + +/** + * A simple encryption manager. + * This manager is basic becasue it will rotate the keys for any membership change. + * There is no ratchetting, or time based rotation. + * It works with to-device transport. + */ +export class BasicEncryptionManager implements IEncryptionManager { + // The current per-sender media key for this device + private outboundSession: OutboundEncryptionSession | null = null; + + /** + * Ensures that there is only one distribute operation at a time for that call. + */ + private currentKeyDistributionPromise: Promise | null = null; + + // The store that holds all the keys for the other participants. + // It is possible that we have multiple keys for a candidate as usually after a rotation the new key is + // distributed prior to being used to give all recipient to get the key first/. + // TODO replace the inner Record with a circular buffer + private keyStore: Map> = new Map(); + + private logger: Logger; + + public constructor( + private userId: string, + private deviceId: string, + private getMemberships: () => CallMembership[], + private transport: ToDeviceKeyTransport, + private statistics: Statistics, + private onEncryptionKeysChanged: ( + keyBin: Uint8Array, + encryptionKeyIndex: number, + participantId: ParticipantId, + ) => void, + ) { + this.logger = logger.getChild("BasicEncryptionManager"); + } + + getEncryptionKeys(): Map> { + // TODO what is this timestamp and why? + // Do that more efficiently + const map = new Map>(); + this.keyStore.forEach((values, participantId) => { + map.set( + participantId, + values.map((inbound) => { + return { key: inbound.key, timestamp: inbound.creationTS }; + }), + ); + }); + return map; + } + + // TODO would be nice to make this async + join(joinConfig: EncryptionConfig | undefined): void { + this.logger.info(`Joining room ${joinConfig}`); + this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); + this.transport.start(); + + this.ensureMediaKey(); + } + + /** + * Will ensure that a new key is distributed and used to encrypt our media. + * If this function is called repeatidly, the calls will be buffered to a single key rotation. + */ + private ensureMediaKey(): void { + this.logger.info(`ensureMediaKey... `); + if (this.currentKeyDistributionPromise == null) { + this.logger.info(`No rollout, start a new one`); + // start a rollout + this.currentKeyDistributionPromise = this.rolloutOutboundKey().then(() => { + this.logger.info(`Rollout completed`); + this.currentKeyDistributionPromise = null; + if (this.outboundSession?.dirty) { + this.logger.info(`New Rollout needed`); + // rollout a new one + this.ensureMediaKey(); + } + }); + } else { + // There is a rollout in progress, but membership has changed and a new rollout is needed. + // Mark this key as dirty so that a new one is rolled out immediatly after the current one + this.logger.info(`Rollout in progress, mark outbound as dirty`); + this.outboundSession!.dirty = true; + } + } + + leave(): void { + // Drop key material + this.keyStore.clear(); + this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived); + this.transport.stop(); + } + + public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => { + this.logger.info(`Received key over transport ${userId}:${deviceId} at index ${index}`); + + // We have a new key notify the video layer of this new key so that it can decrypt the frames properly. + // We also store a copy of the key in the key store as we might need to re-emit them to the decoding layer. + const participantId = getParticipantId(userId, deviceId); + const keyBin = decodeBase64(keyBase64Encoded); + const newKey: InboundEncryptionSession = { + key: keyBin, + participantId, + keyId: index, + creationTS: timestamp, + }; + + const existingKey = this.keyStore.get(participantId)?.[index]; + if (existingKey) { + // We already have key for that index. + // This can happen in some edge cases: + // Like if the participant joined then left and rejoined. In that case he would have distributed + // two keys of index 0 (just after the joins). And as there is no guarantee for order of to device messages + // just keep the last one. + if (timestamp > existingKey.creationTS) { + // We have a new key, update it + this.keyStore.get(participantId)![index] = newKey; + } else { + // this key is outdated, ignore it + return; + } + } + + this.onEncryptionKeysChanged(keyBin, index, participantId); + }; + + onMembershipsUpdate(oldMemberships: CallMembership[]): void { + this.logger.info(`onMembershipsUpdate`); + + // This encryption manager is very basic, it will rotate the key for any membership change + // Request rotation of the key + this.ensureMediaKey(); + } + + private async rolloutOutboundKey(): Promise { + const hasExistingKey = this.outboundSession != null; + + // Create a new key + const newOutboundKey: OutboundEncryptionSession = { + key: this.generateRandomKey(), + creationTS: Date.now(), + sharedWith: [], + keyId: this.nextKeyId(), + dirty: false, + }; + + this.logger.info(`creating new key index:${newOutboundKey.keyId} key:${encodeBase64(newOutboundKey.key)}`); + // Set this new key has the current one + this.outboundSession = newOutboundKey; + const toShareWith = this.getMemberships(); + + try { + this.logger.info(`Sending key...`); + await this.transport.sendKey(encodeBase64(newOutboundKey.key), newOutboundKey.keyId, toShareWith); + this.statistics.counters.roomEventEncryptionKeysSent += 1; + newOutboundKey.sharedWith = toShareWith.map((ms) => { + return { + userId: ms.sender ?? "", + deviceId: ms.deviceId ?? "", + }; + }); + this.logger.info(`key index:${newOutboundKey.keyId} sent to ${newOutboundKey.sharedWith}`); + if (!hasExistingKey) { + this.logger.info(`Rollout immediatly`); + // rollout imediatly + this.onEncryptionKeysChanged( + newOutboundKey.key, + newOutboundKey.keyId, + getParticipantId(this.userId, this.deviceId), + ); + } else { + // Delay a bit using this key + const rolledOut = defer(); + this.logger.info(`Delay Rollout...`); + setTimeout(() => { + this.logger.info(`...Delayed rollout of index:${newOutboundKey.keyId} `); + // Start encrypting with that key now that there was time to distibute it + this.onEncryptionKeysChanged( + newOutboundKey.key, + newOutboundKey.keyId, + getParticipantId(this.userId, this.deviceId), + ); + rolledOut.resolve(); + }, 1000); + return rolledOut.promise; + } + } catch (err) { + this.logger.error(`Failed to rollout key`, err); + } + } + + private nextKeyId(): number { + if (this.outboundSession) { + return (this.outboundSession!.keyId + 1) % 256; + } + return 0; + } + + private generateRandomKey(): Uint8Array { + const key = new Uint8Array(16); + globalThis.crypto.getRandomValues(key); + return key; + } +} + +const getParticipantId = (userId: string, deviceId: string): ParticipantId => `${userId}:${deviceId}`; 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..a3b0dd96f08 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -28,9 +28,12 @@ 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"; +import { encodeBase64 } from "../base64.ts"; +import { BasicEncryptionManager } from "./BasicEncryptionManager.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -125,6 +128,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 +311,9 @@ export class MatrixRTCSession extends TypedEventEmitter, private roomSubset: Pick< @@ -370,17 +381,58 @@ export class MatrixRTCSession extends TypedEventEmitter this.memberships, - transport, - this.statistics, - (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { - this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); - }, - ); + let transport; + if (joinConfig?.useExperimentalToDeviceTransport) { + logger.info("Using experimental to-device transport for encryption keys"); + transport = new ToDeviceKeyTransport( + this.client.getUserId()!, + this.client.getDeviceId()!, + this.roomSubset.roomId, + this.client, + this.statistics, + logger, + ); + this.encryptionManager = new BasicEncryptionManager( + this.client.getUserId()!, + this.client.getDeviceId()!, + () => this.memberships, + transport, + this.statistics, + (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { + // TODO do no commmit + logger.info( + `Encryption key changed for ${participantId}: ${encodeBase64(keyBin)} index:${encryptionKeyIndex}`, + ); + this.emit( + MatrixRTCSessionEvent.EncryptionKeyChanged, + keyBin, + encryptionKeyIndex, + participantId, + ); + }, + ); + } else { + transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics); + this.encryptionManager = new EncryptionManager( + this.client.getUserId()!, + this.client.getDeviceId()!, + () => this.memberships, + transport, + this.statistics, + (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { + // TODO do no commmit + logger.info( + `Encryption key changed for ${participantId}: ${encodeBase64(keyBin)} index:${encryptionKeyIndex}`, + ); + this.emit( + MatrixRTCSessionEvent.EncryptionKeyChanged, + keyBin, + encryptionKeyIndex, + participantId, + ); + }, + ); + } } // Join! diff --git a/src/matrixrtc/RoomKeyTransport.ts b/src/matrixrtc/RoomKeyTransport.ts index f255eb2a14a..e98207433be 100644 --- a/src/matrixrtc/RoomKeyTransport.ts +++ b/src/matrixrtc/RoomKeyTransport.ts @@ -168,7 +168,7 @@ export class RoomKeyTransport ); } else { logger.debug( - `Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`, + `onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`, ); this.emit( KeyTransportEvents.ReceivedKeys, diff --git a/src/matrixrtc/ToDeviceKeyTransport.ts b/src/matrixrtc/ToDeviceKeyTransport.ts new file mode 100644 index 00000000000..671d41d6cfa --- /dev/null +++ b/src/matrixrtc/ToDeviceKeyTransport.ts @@ -0,0 +1,169 @@ +/* +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 { TypedEventEmitter } from "../models/typed-event-emitter.ts"; +import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts"; +import { type Logger } from "../logger.ts"; +import type { CallMembership } from "./CallMembership.ts"; +import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts"; +import { ClientEvent, type MatrixClient } from "../client.ts"; +import type { MatrixEvent } from "../models/event.ts"; +import { EventType } from "../@types/event.ts"; + +/** + * ToDeviceKeyTransport is used to send MatrixRTC keys to other devices using the + * to-device CS-API. + */ +export class ToDeviceKeyTransport + 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