Skip to content

Commit 64e27f5

Browse files
authored
MatrixRTC: Add combined toDeviceAndRoomKeyTransport (#4792)
* Add to-device and room transport * Lint * add doc string * hook up automatic toDeviceKeyTransport -> roomKeyTransport switching * lint, rename, imports * fix logging * fix test logger * use mockLogger better in tests * improve logging and reduce `EnabledTransportsChanged` emission. * fix this binding * lint * simplify `onTransportChanged` callback * refactor to construct the transports outside the RoomAndToDeviceKeyTransport * update tests to use new RoomAndToDeiviceTransport constructor * add depractaion comments
1 parent 6346518 commit 64e27f5

7 files changed

+317
-20
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
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 { type Mocked } from "jest-mock";
18+
19+
import { makeKey, makeMockEvent, makeMockRoom, membershipTemplate, mockCallMembership } from "./mocks";
20+
import { EventType, type IRoomTimelineData, type Room, RoomEvent, type MatrixClient } from "../../../src";
21+
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
22+
import {
23+
getMockClientWithEventEmitter,
24+
mockClientMethodsEvents,
25+
mockClientMethodsUser,
26+
} from "../../test-utils/client.ts";
27+
import { type Statistics } from "../../../src/matrixrtc";
28+
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
29+
import { type Logger } from "../../../src/logger.ts";
30+
import { RoomAndToDeviceEvents, RoomAndToDeviceTransport } from "../../../src/matrixrtc/RoomAndToDeviceKeyTransport.ts";
31+
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport.ts";
32+
33+
describe("RoomAndToDeviceTransport", () => {
34+
const roomId = "!room:id";
35+
36+
let mockClient: Mocked<MatrixClient>;
37+
let statistics: Statistics;
38+
let mockLogger: Mocked<Logger>;
39+
let transport: RoomAndToDeviceTransport;
40+
let mockRoom: Room;
41+
let sendEventMock: jest.Mock;
42+
let roomKeyTransport: RoomKeyTransport;
43+
let toDeviceKeyTransport: ToDeviceKeyTransport;
44+
let toDeviceSendKeySpy: jest.SpyInstance;
45+
let roomSendKeySpy: jest.SpyInstance;
46+
beforeEach(() => {
47+
sendEventMock = jest.fn();
48+
mockClient = getMockClientWithEventEmitter({
49+
encryptAndSendToDevice: jest.fn(),
50+
getDeviceId: jest.fn().mockReturnValue("MYDEVICE"),
51+
...mockClientMethodsEvents(),
52+
...mockClientMethodsUser("@alice:example.org"),
53+
sendEvent: sendEventMock,
54+
});
55+
mockRoom = makeMockRoom([]);
56+
mockLogger = {
57+
debug: jest.fn(),
58+
warn: jest.fn(),
59+
getChild: jest.fn(),
60+
} as unknown as Mocked<Logger>;
61+
mockLogger.getChild.mockReturnValue(mockLogger);
62+
statistics = {
63+
counters: {
64+
roomEventEncryptionKeysSent: 0,
65+
roomEventEncryptionKeysReceived: 0,
66+
},
67+
totals: {
68+
roomEventEncryptionKeysReceivedTotalAge: 0,
69+
},
70+
};
71+
roomKeyTransport = new RoomKeyTransport(mockRoom, mockClient, statistics);
72+
toDeviceKeyTransport = new ToDeviceKeyTransport(
73+
"@alice:example.org",
74+
"MYDEVICE",
75+
mockRoom.roomId,
76+
mockClient,
77+
statistics,
78+
);
79+
transport = new RoomAndToDeviceTransport(toDeviceKeyTransport, roomKeyTransport, mockLogger);
80+
toDeviceSendKeySpy = jest.spyOn(toDeviceKeyTransport, "sendKey");
81+
roomSendKeySpy = jest.spyOn(roomKeyTransport, "sendKey");
82+
});
83+
84+
it("should enable to device transport when starting", () => {
85+
transport.start();
86+
expect(transport.enabled.room).toBeFalsy();
87+
expect(transport.enabled.toDevice).toBeTruthy();
88+
});
89+
it("only sends to device keys when sending a key", async () => {
90+
transport.start();
91+
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
92+
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(1);
93+
expect(roomSendKeySpy).toHaveBeenCalledTimes(0);
94+
expect(transport.enabled.room).toBeFalsy();
95+
expect(transport.enabled.toDevice).toBeTruthy();
96+
});
97+
98+
it("enables room transport and disables to device transport when receiving a room key", async () => {
99+
transport.start();
100+
const onNewKeyFromTransport = jest.fn();
101+
const onTransportEnabled = jest.fn();
102+
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
103+
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
104+
mockRoom.emit(
105+
RoomEvent.Timeline,
106+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", roomId, {
107+
call_id: "",
108+
keys: [makeKey(0, "testKey")],
109+
sent_ts: Date.now(),
110+
device_id: "AAAAAAA",
111+
}),
112+
undefined,
113+
undefined,
114+
false,
115+
{} as IRoomTimelineData,
116+
);
117+
await jest.advanceTimersByTimeAsync(1);
118+
expect(transport.enabled.room).toBeTruthy();
119+
expect(transport.enabled.toDevice).toBeFalsy();
120+
121+
await transport.sendKey("1235", 0, [mockCallMembership(membershipTemplate, roomId, "@alice:example.org")]);
122+
expect(sendEventMock).toHaveBeenCalledTimes(1);
123+
expect(roomSendKeySpy).toHaveBeenCalledTimes(1);
124+
expect(toDeviceSendKeySpy).toHaveBeenCalledTimes(0);
125+
expect(onTransportEnabled).toHaveBeenCalledWith({ toDevice: false, room: true });
126+
});
127+
it("does log that it did nothing when disabled", () => {
128+
transport.start();
129+
const onNewKeyFromTransport = jest.fn();
130+
const onTransportEnabled = jest.fn();
131+
transport.on(KeyTransportEvents.ReceivedKeys, onNewKeyFromTransport);
132+
transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, onTransportEnabled);
133+
134+
transport.setEnabled({ toDevice: false, room: false });
135+
const dateNow = Date.now();
136+
roomKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "roomKey", 0, dateNow);
137+
toDeviceKeyTransport.emit(KeyTransportEvents.ReceivedKeys, "user", "device", "toDeviceKey", 0, Date.now());
138+
139+
expect(mockLogger.debug).toHaveBeenCalledWith("To Device transport is disabled, ignoring received keys");
140+
// for room key transport we will never get a disabled message because its will always just turn on
141+
expect(onTransportEnabled).toHaveBeenNthCalledWith(1, { toDevice: false, room: false });
142+
expect(onTransportEnabled).toHaveBeenNthCalledWith(2, { toDevice: false, room: true });
143+
expect(onNewKeyFromTransport).toHaveBeenCalledTimes(1);
144+
expect(onNewKeyFromTransport).toHaveBeenCalledWith("user", "device", "roomKey", 0, dateNow);
145+
});
146+
});

src/matrixrtc/EncryptionManager.ts

+13
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import { safeGetRetryAfterMs } from "../http-api/errors.ts";
66
import { type CallMembership } from "./CallMembership.ts";
77
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
88
import { isMyMembership, type Statistics } from "./types.ts";
9+
import {
10+
type EnabledTransports,
11+
RoomAndToDeviceEvents,
12+
RoomAndToDeviceTransport,
13+
} from "./RoomAndToDeviceKeyTransport.ts";
914

1015
/**
1116
* This interface is for testing and for making it possible to interchange the encryption manager.
@@ -105,6 +110,10 @@ export class EncryptionManager implements IEncryptionManager {
105110
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
106111

107112
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
113+
// Deprecate RoomKeyTransport: this can get removed.
114+
if (this.transport instanceof RoomAndToDeviceTransport) {
115+
this.transport.on(RoomAndToDeviceEvents.EnabledTransportsChanged, this.onTransportChanged);
116+
}
108117
this.transport.start();
109118
if (this.joinConfig?.manageMediaKeys) {
110119
this.makeNewSenderKey();
@@ -287,6 +296,10 @@ export class EncryptionManager implements IEncryptionManager {
287296
}
288297
};
289298

299+
private onTransportChanged: (enabled: EnabledTransports) => void = () => {
300+
this.requestSendCurrentKey();
301+
};
302+
290303
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
291304
this.logger.debug(`Received key over key transport ${userId}:${deviceId} at index ${index}`);
292305
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);

src/matrixrtc/IKeyTransport.ts

+4-4
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export enum KeyTransportEvents {
2020
ReceivedKeys = "received_keys",
2121
}
2222

23+
export type KeyTransportEventsHandlerMap = {
24+
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
25+
};
26+
2327
export type KeyTransportEventListener = (
2428
userId: string,
2529
deviceId: string,
@@ -28,10 +32,6 @@ export type KeyTransportEventListener = (
2832
timestamp: number,
2933
) => void;
3034

31-
export type KeyTransportEventsHandlerMap = {
32-
[KeyTransportEvents.ReceivedKeys]: KeyTransportEventListener;
33-
};
34-
3535
/**
3636
* Generic interface for the transport used to share room keys.
3737
* Keys can be shared using different transports, e.g. to-device messages or room messages.

src/matrixrtc/MatrixRTCSession.ts

+25-11
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,16 @@ 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 { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
3231
import { type Statistics } from "./types.ts";
3332
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
3433
import type { IMembershipManager } from "./IMembershipManager.ts";
34+
import {
35+
RoomAndToDeviceEvents,
36+
type RoomAndToDeviceEventsHandlerMap,
37+
RoomAndToDeviceTransport,
38+
} from "./RoomAndToDeviceKeyTransport.ts";
39+
import { TypedReEmitter } from "../ReEmitter.ts";
40+
import { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
3541

3642
export enum MatrixRTCSessionEvent {
3743
// A member joined, left, or updated a property of their membership.
@@ -162,7 +168,10 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig;
162168
* A MatrixRTCSession manages the membership & properties of a MatrixRTC session.
163169
* This class doesn't deal with media at all, just membership & properties of a session.
164170
*/
165-
export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, MatrixRTCSessionEventHandlerMap> {
171+
export class MatrixRTCSession extends TypedEventEmitter<
172+
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
173+
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
174+
> {
166175
private membershipManager?: IMembershipManager;
167176
private encryptionManager?: IEncryptionManager;
168177
// The session Id of the call, this is the call_id of the call Member event.
@@ -348,6 +357,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
348357
const roomState = this.roomSubset.getLiveTimeline().getState(EventTimeline.FORWARDS);
349358
roomState?.off(RoomStateEvent.Members, this.onRoomMemberUpdate);
350359
}
360+
private reEmitter = new TypedReEmitter<
361+
MatrixRTCSessionEvent | RoomAndToDeviceEvents,
362+
MatrixRTCSessionEventHandlerMap & RoomAndToDeviceEventsHandlerMap
363+
>(this);
351364

352365
/**
353366
* Announces this user and device as joined to the MatrixRTC session,
@@ -385,15 +398,16 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
385398
// Create Encryption manager
386399
let transport;
387400
if (joinConfig?.useExperimentalToDeviceTransport) {
388-
this.logger.info("Using experimental to-device transport for encryption keys");
389-
transport = new ToDeviceKeyTransport(
390-
this.client.getUserId()!,
391-
this.client.getDeviceId()!,
392-
this.roomSubset.roomId,
393-
this.client,
394-
this.statistics,
395-
this.logger,
396-
);
401+
this.logger.info("Using to-device with room fallback transport for encryption keys");
402+
const [uId, dId] = [this.client.getUserId()!, this.client.getDeviceId()!];
403+
const [room, client, statistics] = [this.roomSubset, this.client, this.statistics];
404+
// Deprecate RoomKeyTransport: only ToDeviceKeyTransport is needed once deprecated
405+
const roomKeyTransport = new RoomKeyTransport(room, client, statistics);
406+
const toDeviceTransport = new ToDeviceKeyTransport(uId, dId, room.roomId, client, statistics);
407+
transport = new RoomAndToDeviceTransport(toDeviceTransport, roomKeyTransport, this.logger);
408+
409+
// Expose the changes so the ui can display the currently used transport.
410+
this.reEmitter.reEmit(transport, [RoomAndToDeviceEvents.EnabledTransportsChanged]);
397411
} else {
398412
transport = new RoomKeyTransport(this.roomSubset, this.client, this.statistics);
399413
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
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 { logger as rootLogger, type Logger } from "../logger.ts";
18+
import { KeyTransportEvents, type KeyTransportEventsHandlerMap, type IKeyTransport } from "./IKeyTransport.ts";
19+
import { type CallMembership } from "./CallMembership.ts";
20+
import type { RoomKeyTransport } from "./RoomKeyTransport.ts";
21+
import type { ToDeviceKeyTransport } from "./ToDeviceKeyTransport.ts";
22+
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
23+
24+
// Deprecate RoomAndToDeviceTransport: This whole class is only a stop gap until we remove RoomKeyTransport.
25+
export interface EnabledTransports {
26+
toDevice: boolean;
27+
room: boolean;
28+
}
29+
30+
export enum RoomAndToDeviceEvents {
31+
EnabledTransportsChanged = "enabled_transports_changed",
32+
}
33+
export type RoomAndToDeviceEventsHandlerMap = {
34+
[RoomAndToDeviceEvents.EnabledTransportsChanged]: (enabledTransports: EnabledTransports) => void;
35+
};
36+
/**
37+
* A custom transport that subscribes to room key events (via `RoomKeyTransport`) and to device key events (via: `ToDeviceKeyTransport`)
38+
* The public setEnabled method allows to turn one or the other on or off on the fly.
39+
* It will emit `RoomAndToDeviceEvents.EnabledTransportsChanged` if the enabled transport changes to allow comminitcating this to
40+
* the user in the ui.
41+
*
42+
* Since it will always subscribe to both (room and to device) but only emit for the enabled ones, it can detect
43+
* if a room key event was received and autoenable it.
44+
*/
45+
export class RoomAndToDeviceTransport
46+
extends TypedEventEmitter<
47+
KeyTransportEvents | RoomAndToDeviceEvents,
48+
KeyTransportEventsHandlerMap & RoomAndToDeviceEventsHandlerMap
49+
>
50+
implements IKeyTransport
51+
{
52+
private readonly logger: Logger;
53+
private _enabled: EnabledTransports = { toDevice: true, room: false };
54+
public constructor(
55+
private toDeviceTransport: ToDeviceKeyTransport,
56+
private roomKeyTransport: RoomKeyTransport,
57+
parentLogger?: Logger,
58+
) {
59+
super();
60+
this.logger = (parentLogger ?? rootLogger).getChild(`[RoomAndToDeviceTransport]`);
61+
// update parent loggers for the sub transports so filtering for `RoomAndToDeviceTransport` contains their logs too
62+
this.toDeviceTransport.setParentLogger(this.logger);
63+
this.roomKeyTransport.setParentLogger(this.logger);
64+
65+
this.roomKeyTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
66+
// Turn on the room transport if we receive a roomKey from another participant
67+
// and disable the toDevice transport.
68+
if (!this._enabled.room) {
69+
this.logger.debug("Received room key, enabling room key transport, disabling toDevice transport");
70+
this.setEnabled({ toDevice: false, room: true });
71+
}
72+
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
73+
});
74+
this.toDeviceTransport.on(KeyTransportEvents.ReceivedKeys, (...props) => {
75+
if (this._enabled.toDevice) {
76+
this.emit(KeyTransportEvents.ReceivedKeys, ...props);
77+
} else {
78+
this.logger.debug("To Device transport is disabled, ignoring received keys");
79+
}
80+
});
81+
}
82+
83+
/** Set which transport type should be used to send and receive keys.*/
84+
public setEnabled(enabled: { toDevice: boolean; room: boolean }): void {
85+
if (this.enabled.toDevice !== enabled.toDevice || this.enabled.room !== enabled.room) {
86+
this._enabled = enabled;
87+
this.emit(RoomAndToDeviceEvents.EnabledTransportsChanged, enabled);
88+
}
89+
}
90+
91+
/** The currently enabled transports that are used to send and receive keys.*/
92+
public get enabled(): EnabledTransports {
93+
return this._enabled;
94+
}
95+
96+
public start(): void {
97+
// always start the underlying transport since we need to enable room transport
98+
// when someone else sends us a room key. (we need to listen to roomKeyTransport)
99+
this.roomKeyTransport.start();
100+
this.toDeviceTransport.start();
101+
}
102+
103+
public stop(): void {
104+
// always stop since it is always running
105+
this.roomKeyTransport.stop();
106+
this.toDeviceTransport.stop();
107+
}
108+
109+
public async sendKey(keyBase64Encoded: string, index: number, members: CallMembership[]): Promise<void> {
110+
this.logger.debug(
111+
`Sending key with index ${index} to call members (count=${members.length}) via:` +
112+
(this._enabled.room ? "room transport" : "") +
113+
(this._enabled.room && this._enabled.toDevice ? "and" : "") +
114+
(this._enabled.toDevice ? "to device transport" : ""),
115+
);
116+
if (this._enabled.room) await this.roomKeyTransport.sendKey(keyBase64Encoded, index, members);
117+
if (this._enabled.toDevice) await this.toDeviceTransport.sendKey(keyBase64Encoded, index, members);
118+
}
119+
}

0 commit comments

Comments
 (0)