Skip to content

Commit ee6747f

Browse files
committed
WIP on valere/matrix_rtc_key_transport
1 parent 8ea291a commit ee6747f

File tree

7 files changed

+243
-6
lines changed

7 files changed

+243
-6
lines changed

src/client.ts

+18-2
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, 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,7 +7942,23 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
79427942
return this.http.authedRequest(Method.Put, path, undefined, body);
79437943
}
79447944

7945-
/**
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+
7960+
7961+
/**
79467962
* Sends events directly to specific devices using Matrix's to-device
79477963
* messaging system. The batch will be split up into appropriately sized
79487964
* batches for sending and stored in the store so they can be retried

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/http-api/fetch.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -218,7 +218,7 @@ export class FetchHttpApi<O extends IHttpOpts> {
218218
* On success, sets new access and refresh tokens in opts.
219219
* @returns Promise that resolves to a boolean - true when token was refreshed successfully
220220
*/
221-
@singleAsyncExecution
221+
// @singleAsyncExecution
222222
private async tryRefreshToken(): Promise<TokenRefreshOutcome> {
223223
if (!this.opts.refreshToken || !this.opts.tokenRefreshFunction) {
224224
return TokenRefreshOutcome.Logout;

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 { 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

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

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)