Skip to content

Commit 0a587e9

Browse files
committed
MatrixRTC: ToDevice distribution for media stream keys
1 parent ba71235 commit 0a587e9

File tree

6 files changed

+242
-4
lines changed

6 files changed

+242
-4
lines changed

src/client.ts

+16-1
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ import {
207207
import { M_BEACON_INFO, type MBeaconInfoEventContent } from "./@types/beacon.ts";
208208
import { NamespacedValue, UnstableValue } from "./NamespacedValue.ts";
209209
import { ToDeviceMessageQueue } from "./ToDeviceMessageQueue.ts";
210-
import { type ToDeviceBatch } from "./models/ToDeviceMessage.ts";
210+
import { type ToDeviceBatch, type ToDevicePayload } from "./models/ToDeviceMessage.ts";
211211
import { IgnoredInvites } from "./models/invites-ignorer.ts";
212212
import { type UIARequest } from "./@types/uia.ts";
213213
import { type LocalNotificationSettings } from "./@types/local_notifications.ts";
@@ -7942,6 +7942,21 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
79427942
return this.http.authedRequest(Method.Put, path, undefined, body);
79437943
}
79447944

7945+
public async encryptAndSendToDevice(
7946+
eventType: string,
7947+
devices: { userId: string; deviceId: string }[],
7948+
payload: ToDevicePayload,
7949+
): Promise<void> {
7950+
if (!this.cryptoBackend) {
7951+
throw new Error("Cannot encrypt to device event, your client does not support encryption.");
7952+
}
7953+
const batch = await this.cryptoBackend.encryptToDeviceMessages(eventType, devices, payload);
7954+
7955+
// TODO The batch mechanism removes all possibility to get error feedbacks..
7956+
// We might want instead to do the API call directly and pass the errors back.
7957+
await this.queueToDevice(batch);
7958+
}
7959+
79457960
/**
79467961
* Sends events directly to specific devices using Matrix's to-device
79477962
* messaging system. The batch will be split up into appropriately sized

src/embedded.ts

+16
Original file line numberDiff line numberDiff line change
@@ -464,6 +464,22 @@ export class RoomWidgetClient extends MatrixClient {
464464
return {};
465465
}
466466

467+
public async encryptAndSendToDevice(
468+
eventType: string,
469+
devices: { userId: string; deviceId: string }[],
470+
payload: ToDevicePayload,
471+
): Promise<void> {
472+
// map: user Id → device Id → payload
473+
const contentMap: MapWithDefault<string, Map<string, ToDevicePayload>> = new MapWithDefault(() => new Map());
474+
for (const { userId, deviceId } of devices) {
475+
contentMap.getOrCreate(userId).set(deviceId, payload);
476+
}
477+
478+
await this.widgetApi
479+
.sendToDevice(eventType, true, recursiveMapToObject(contentMap))
480+
.catch(timeoutToConnectionError);
481+
}
482+
467483
public async sendToDevice(eventType: string, contentMap: SendToDeviceContentMap): Promise<EmptyObject> {
468484
await this.widgetApi
469485
.sendToDevice(eventType, false, recursiveMapToObject(contentMap))

src/matrixrtc/EncryptionManager.ts

+1
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,7 @@ export class EncryptionManager implements IEncryptionManager {
332332
timestamp: number,
333333
delayBeforeUse = false,
334334
): void {
335+
logger.debug(`Setting encryption key for ${userId}:${deviceId} at index ${encryptionKeyIndex}`);
335336
const keyBin = decodeBase64(encryptionKeyString);
336337

337338
const participantId = getParticipantId(userId, deviceId);

src/matrixrtc/MatrixRTCSession.ts

+24-3
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,10 @@ import { MembershipManager } from "./NewMembershipManager.ts";
2828
import { EncryptionManager, type IEncryptionManager } from "./EncryptionManager.ts";
2929
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
3030
import { logDurationSync } from "../utils.ts";
31-
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
32-
import { type IMembershipManager } from "./IMembershipManager.ts";
31+
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
3332
import { type Statistics } from "./types.ts";
33+
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
34+
import type { IMembershipManager } from "./IMembershipManager.ts";
3435

3536
const logger = rootLogger.getChild("MatrixRTCSession");
3637

@@ -125,6 +126,11 @@ export interface MembershipConfig {
125126
* The maximum number of retries that the manager will do for delayed event sending/updating and state event sending when a network error occurs.
126127
*/
127128
maximumNetworkErrorRetryCount?: number;
129+
130+
/**
131+
* If true, use the new to-device transport for sending encryption keys.
132+
*/
133+
useExperimentalToDeviceTransport?: boolean;
128134
}
129135

130136
export interface EncryptionConfig {
@@ -303,6 +309,9 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
303309
| "_unstable_updateDelayedEvent"
304310
| "sendEvent"
305311
| "cancelPendingEvent"
312+
| "encryptAndSendToDevice"
313+
| "off"
314+
| "on"
306315
| "decryptEventIfNeeded"
307316
>,
308317
private roomSubset: Pick<
@@ -370,7 +379,19 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
370379
);
371380
}
372381
// Create Encryption manager
373-
const transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
382+
let transport;
383+
if (joinConfig?.useExperimentalToDeviceTransport == true) {
384+
logger.info("Using experimental to-device transport for encryption keys");
385+
transport = new ToDeviceKeyTransport(
386+
this.client.getUserId()!,
387+
this.client.getDeviceId()!,
388+
this.roomSubset.roomId,
389+
this.client,
390+
this.statistics,
391+
);
392+
} else {
393+
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
394+
}
374395
this.encryptionManager = new EncryptionManager(
375396
this.client.getUserId()!,
376397
this.client.getDeviceId()!,

src/matrixrtc/ToDeviceKeyTransport.ts

+167
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
Copyright 2025 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
18+
import { type IKeyTransport, KeyTransportEvents, type KeyTransportEventsHandlerMap } from "./IKeyTransport.ts";
19+
import { type Logger, logger } from "../logger.ts";
20+
import type { CallMembership } from "./CallMembership.ts";
21+
import type { EncryptionKeysToDeviceEventContent, Statistics } from "./types.ts";
22+
import { ClientEvent, type MatrixClient } from "../client.ts";
23+
import type { MatrixEvent } from "../models/event.ts";
24+
import { EventType } from "../@types/event.ts";
25+
26+
export class ToDeviceKeyTransport
27+
extends TypedEventEmitter<KeyTransportEvents, KeyTransportEventsHandlerMap>
28+
implements IKeyTransport
29+
{
30+
private readonly prefixedLogger: Logger;
31+
32+
public constructor(
33+
private userId: string,
34+
private deviceId: string,
35+
private roomId: string,
36+
private client: Pick<MatrixClient, "encryptAndSendToDevice" | "on" | "off">,
37+
private statistics: Statistics,
38+
) {
39+
super();
40+
this.prefixedLogger = logger.getChild(`[RTC: ${roomId} ToDeviceKeyTransport]`);
41+
}
42+
43+
public start(): void {
44+
this.client.on(ClientEvent.ToDeviceEvent, (ev) => this.onToDeviceEvent(ev));
45+
}
46+
47+
public stop(): void {
48+
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
49+
}
50+
51+
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
52+
const content: EncryptionKeysToDeviceEventContent = {
53+
keys: {
54+
index: index,
55+
key: keyBase64Encoded,
56+
},
57+
roomId: this.roomId,
58+
member: {
59+
claimed_device_id: this.deviceId,
60+
},
61+
session: {
62+
call_id: "",
63+
application: "m.call",
64+
scope: "m.room",
65+
},
66+
};
67+
68+
const targets = members
69+
.filter((member) => {
70+
// filter malformed call members
71+
if (member.sender == undefined || member.deviceId == undefined) {
72+
logger.warn(`Malformed call member: ${member.sender}|${member.deviceId}`);
73+
return false;
74+
}
75+
// Filter out me
76+
return !(member.sender == this.userId && member.deviceId == this.deviceId);
77+
})
78+
.map((member) => {
79+
return {
80+
userId: member.sender!,
81+
deviceId: member.deviceId!,
82+
};
83+
});
84+
85+
if (targets.length > 0) {
86+
await this.client.encryptAndSendToDevice(EventType.CallEncryptionKeysPrefix, targets, content);
87+
} else {
88+
this.prefixedLogger.warn("No targets found for sending key");
89+
}
90+
}
91+
92+
private receiveCallKeyEvent(fromUser: string, content: EncryptionKeysToDeviceEventContent): void {
93+
// The event has already been validated at this point.
94+
95+
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
96+
97+
// What is this, and why is it needed?
98+
// Also to device events do not have an origin server ts
99+
const now = Date.now();
100+
const age = now - (typeof content.sent_ts === "number" ? content.sent_ts : now);
101+
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
102+
103+
this.emit(
104+
KeyTransportEvents.ReceivedKeys,
105+
// TODO this is claimed information
106+
fromUser,
107+
// TODO: This is claimed information
108+
content.member.claimed_device_id!,
109+
content.keys.key,
110+
content.keys.index,
111+
age,
112+
);
113+
}
114+
115+
private onToDeviceEvent = (event: MatrixEvent): void => {
116+
if (event.getType() !== EventType.CallEncryptionKeysPrefix) {
117+
// Ignore this is not a call encryption event
118+
return;
119+
}
120+
121+
// TODO: Not possible to check if the event is encrypted or not
122+
// see https://github.com/matrix-org/matrix-rust-sdk/issues/4883
123+
// if (evnt.getWireType() != EventType.RoomMessageEncrypted) {
124+
// // WARN: The call keys were sent in clear. Ignore them
125+
// logger.warn(`Call encryption keys sent in clear from: ${event.getSender()}`);
126+
// return;
127+
// }
128+
129+
const content = this.getValidEventContent(event);
130+
if (!content) return;
131+
132+
if (!event.getSender()) return;
133+
134+
this.receiveCallKeyEvent(event.getSender()!, content);
135+
};
136+
137+
private getValidEventContent(event: MatrixEvent): EncryptionKeysToDeviceEventContent | undefined {
138+
const content = event.getContent<EncryptionKeysToDeviceEventContent>();
139+
const roomId = content.roomId;
140+
if (!roomId) {
141+
// Invalid event
142+
this.prefixedLogger.warn("Malformed Event: invalid call encryption keys event, no roomId");
143+
return;
144+
}
145+
if (roomId !== this.roomId) {
146+
this.prefixedLogger.warn("Malformed Event: Mismatch roomId");
147+
return;
148+
}
149+
150+
if (!content.keys || !content.keys.key || typeof content.keys.index !== "number") {
151+
this.prefixedLogger.warn("Malformed Event: Missing keys field");
152+
return;
153+
}
154+
155+
if (!content.member || !content.member.claimed_device_id) {
156+
this.prefixedLogger.warn("Malformed Event: Missing claimed_device_id");
157+
return;
158+
}
159+
160+
// TODO session is not used so far
161+
// if (!content.session || !content.session.call_id || !content.session.scope || !content.session.application) {
162+
// this.prefixedLogger.warn("Malformed Event: Missing/Malformed content.session", content.session);
163+
// return;
164+
// }
165+
return content;
166+
}
167+
}

src/matrixrtc/types.ts

+18
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,24 @@ export interface EncryptionKeysEventContent {
2828
sent_ts?: number;
2929
}
3030

31+
export interface EncryptionKeysToDeviceEventContent {
32+
keys: { index: number; key: string };
33+
member: {
34+
// id: ParticipantId,
35+
// TODO Remove that it is claimed, need to get the sealed sender from decryption info
36+
claimed_device_id: string;
37+
// user_id: string
38+
};
39+
roomId: string;
40+
session: {
41+
application: string;
42+
call_id: string;
43+
scope: string;
44+
};
45+
// Why is this needed?
46+
sent_ts?: number;
47+
}
48+
3149
export type CallNotifyType = "ring" | "notify";
3250

3351
export interface ICallNotifyContent {

0 commit comments

Comments
 (0)