From 5897988a4fc0a085a5a8933895b9a8232d7bbd7a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Wed, 30 Oct 2024 10:00:32 +0000 Subject: [PATCH] MatrixRTC key distribution using to-device messaging --- src/matrixrtc/CallMembership.ts | 10 ++ src/matrixrtc/MatrixRTCSession.ts | 149 +++++++++++++++++++---- src/matrixrtc/MatrixRTCSessionManager.ts | 52 ++++++-- src/matrixrtc/types.ts | 5 + 4 files changed, 182 insertions(+), 34 deletions(-) diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 6c7efc029d6..a735480c227 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -22,6 +22,7 @@ import { Focus } from "./focus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; type CallScope = "m.room" | "m.user"; + // Represents an entry in the memberships section of an m.call.member event as it is on the wire // There are two different data interfaces. One for the Legacy types and one compliant with MSC4143 @@ -39,6 +40,8 @@ export type SessionMembershipData = { // Application specific data scope?: CallScope; + + key_distribution?: KeyDistributionMechanism; }; export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData => @@ -69,6 +72,7 @@ export type CallMembershipDataLegacy = { membershipID: string; created_ts?: number; foci_active?: Focus[]; + key_distribution?: KeyDistributionMechanism; } & EitherAnd<{ expires: number }, { expires_ts: number }>; export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy => @@ -103,6 +107,8 @@ const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is Cal export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData; +type KeyDistributionMechanism = "room_event" | "to_device"; + export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { return deepCompare(a.membershipData, b.membershipData); @@ -244,4 +250,8 @@ export class CallMembership { } } } + + public get keyDistributionMethod(): KeyDistributionMechanism { + return this.membershipData.key_distribution ?? "room_event"; + } } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 0e4b385b951..e8870ed5976 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -18,7 +18,7 @@ import { logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; import { Room } from "../models/room.ts"; -import { MatrixClient } from "../client.ts"; +import { MatrixClient, SendToDeviceContentMap } from "../client.ts"; import { EventType } from "../@types/event.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts"; import { @@ -31,7 +31,7 @@ import { import { RoomStateEvent } from "../models/room-state.ts"; import { Focus } from "./focus.ts"; import { randomString, secureRandomBase64Url } from "../randomstring.ts"; -import { EncryptionKeysEventContent } from "./types.ts"; +import { EncryptionKeysEventContent, EncryptionKeysToDeviceContent } from "./types.ts"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; import { KnownMembership } from "../@types/membership.ts"; import { MatrixError } from "../http-api/errors.ts"; @@ -39,6 +39,7 @@ import { MatrixEvent } from "../models/event.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall.ts"; import { sleep } from "../utils.ts"; +import type { RoomWidgetClient } from "../embedded.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -162,8 +163,21 @@ export class MatrixRTCSession extends TypedEventEmitter { + const membersRequiringRoomEvent = this.memberships.filter( + (m) => !this.isMyMembership(m) && m.keyDistributionMethod === "room_event", + ); + + if (membersRequiringRoomEvent.length === 0) { + logger.info("No members require keys via room event"); + return; + } + + logger.info( + `Sending encryption keys event for: ${membersRequiringRoomEvent.map((m) => `${m.sender}:${m.deviceId}`).join(", ")}`, + ); + + const content: EncryptionKeysEventContent = { + keys: [ + { + index, + key: encodeUnpaddedBase64(key), + }, + ], + device_id: deviceId, + call_id: "", + sent_ts: Date.now(), + }; + + this.statistics.counters.roomEventEncryptionKeysSent += 1; + + await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content); + } + + private async sendKeysViaToDevice(deviceId: string, key: Uint8Array, index: number): Promise { + const membershipsRequiringToDevice = this.memberships.filter( + (m) => !this.isMyMembership(m) && m.sender && m.keyDistributionMethod === "to_device", + ); + + if (membershipsRequiringToDevice.length === 0) { + logger.info("No members require keys via to-device event"); + return; + } + + const content: EncryptionKeysToDeviceContent = { + keys: [{ index, key: encodeUnpaddedBase64(key) }], + device_id: deviceId, + call_id: "", + room_id: this.room.roomId, + sent_ts: Date.now(), + }; + + logger.info( + `Sending encryption keys to-device batch for: ${membershipsRequiringToDevice.map(({ sender, deviceId }) => `${sender}:${deviceId}`).join(", ")}`, + ); + + this.statistics.counters.toDeviceEncryptionKeysSent += membershipsRequiringToDevice.length; + + // we don't do an instanceof due to circular dependency issues + if ("widgetApi" in this.client) { + logger.info("Sending keys via widgetApi"); + // embedded mode, getCrypto() returns null and so we make some assumptions about the underlying implementation + + const contentMap: SendToDeviceContentMap = new Map(); + + membershipsRequiringToDevice.forEach(({ sender, deviceId }) => { + if (!contentMap.has(sender!)) { + contentMap.set(sender!, new Map()); + } + + contentMap.get(sender!)!.set(deviceId, content); + }); + + await (this.client as unknown as RoomWidgetClient).sendToDeviceViaWidgetApi( + EventType.CallEncryptionKeysPrefix, + true, + contentMap, + ); + } else { + const crypto = this.client.getCrypto(); + if (!crypto) { + logger.error("No crypto instance available to send keys via to-device event"); + return; + } + + const devices = membershipsRequiringToDevice.map(({ deviceId, sender }) => ({ userId: sender!, deviceId })); + + const batch = await crypto.encryptToDeviceMessages(EventType.CallEncryptionKeysPrefix, devices, content); + + await this.client.queueToDevice(batch); + } + } + /** * Sets a timer for the soonest membership expiry */ @@ -714,9 +807,17 @@ export class MatrixRTCSession extends TypedEventEmitter 0) { @@ -67,6 +68,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + private onTimeline = (event: MatrixEvent): void => { + this.consumeCallEncryptionEvent(event, (event) => event.getRoomId(), false); + }; + + private onToDeviceEvent = (event: MatrixEvent): void => { + if (!event.isEncrypted()) { + logger.warn("Ignoring unencrypted to-device call encryption event", event); + return; + } + this.consumeCallEncryptionEvent( + event, + (event) => event.getContent().room_id, + false, + ); + }; + + /** + * @param event - the event to consume + * @param roomIdExtractor - the function to extract the room id from the event + * @param isRetry - whether this is a retry. If false we will retry decryption failures once + */ + private consumeCallEncryptionEvent = async ( + event: MatrixEvent, + roomIdExtractor: (event: MatrixEvent) => string | undefined, + isRetry: boolean, + ): Promise => { await this.client.decryptEventIfNeeded(event); if (event.isDecryptionFailure()) { if (!isRetry) { @@ -108,7 +136,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter this.consumeCallEncryptionEvent(event, true), 1000); + setTimeout(() => this.consumeCallEncryptionEvent(event, roomIdExtractor, true), 1000); } else { logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); } @@ -117,18 +145,20 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { - this.consumeCallEncryptionEvent(event); }; private onRoom = (room: Room): void => { diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 966300b0231..348046467cc 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -26,6 +26,11 @@ export interface EncryptionKeysEventContent { sent_ts?: number; } +export interface EncryptionKeysToDeviceContent extends EncryptionKeysEventContent { + room_id?: string; + sent_ts: number; +} + export type CallNotifyType = "ring" | "notify"; export interface ICallNotifyContent {