Skip to content

Commit 3f03c1d

Browse files
BillCarsonFrtoger5
andauthored
MatrixRTC: ToDevice distribution for media stream keys (#4785)
* MatrixRTC: ToDevice distribution for media stream keys * test: Add RTC to device transport test * lint * fix key indexing * fix indexing take two - use correct value for: `onEncryptionKeysChanged` - only update `latestGeneratedKeyIndex` for "this user" key * test: add test for join config `useExperimentalToDeviceTransport` * update test to fail without the fixed encryption key index * review * review (dave) --------- Co-authored-by: Timo <[email protected]>
1 parent eb793aa commit 3f03c1d

14 files changed

+584
-57
lines changed

spec/unit/embedded.spec.ts

+4-3
Original file line numberDiff line numberDiff line change
@@ -722,13 +722,14 @@ describe("RoomWidgetClient", () => {
722722
expect(widgetApi.sendToDevice).toHaveBeenCalledWith("org.example.foo", false, expectedRequestData);
723723
});
724724

725-
it("sends encrypted (encryptAndSendToDevices)", async () => {
725+
it("sends encrypted (encryptAndSendToDevice)", async () => {
726726
await makeClient({ sendToDevice: ["org.example.foo"] });
727727
expect(widgetApi.requestCapabilityToSendToDevice).toHaveBeenCalledWith("org.example.foo");
728728

729-
const payload = { type: "org.example.foo", hello: "world" };
729+
const payload = { hello: "world" };
730730
const embeddedClient = client as RoomWidgetClient;
731-
await embeddedClient.encryptAndSendToDevices(
731+
await embeddedClient.encryptAndSendToDevice(
732+
"org.example.foo",
732733
[
733734
{ userId: "@alice:example.org", deviceId: "aliceWeb" },
734735
{ userId: "@bob:example.org", deviceId: "bobDesktop" },

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

+41-3
Original file line numberDiff line numberDiff line change
@@ -486,14 +486,17 @@ describe("MatrixRTCSession", () => {
486486
let sendStateEventMock: jest.Mock;
487487
let sendDelayedStateMock: jest.Mock;
488488
let sendEventMock: jest.Mock;
489+
let sendToDeviceMock: jest.Mock;
489490

490491
beforeEach(() => {
491492
sendStateEventMock = jest.fn();
492493
sendDelayedStateMock = jest.fn();
493494
sendEventMock = jest.fn();
495+
sendToDeviceMock = jest.fn();
494496
client.sendStateEvent = sendStateEventMock;
495497
client._unstable_sendDelayedStateEvent = sendDelayedStateMock;
496498
client.sendEvent = sendEventMock;
499+
client.encryptAndSendToDevice = sendToDeviceMock;
497500

498501
mockRoom = makeMockRoom([]);
499502
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
@@ -832,6 +835,7 @@ describe("MatrixRTCSession", () => {
832835
it("rotates key if a member leaves", async () => {
833836
jest.useFakeTimers();
834837
try {
838+
const KEY_DELAY = 3000;
835839
const member2 = Object.assign({}, membershipTemplate, {
836840
device_id: "BBBBBBB",
837841
});
@@ -852,7 +856,8 @@ describe("MatrixRTCSession", () => {
852856
sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload));
853857
});
854858

855-
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
859+
sess.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true, makeKeyDelay: KEY_DELAY });
860+
const sendKeySpy = jest.spyOn((sess as unknown as any).encryptionManager.transport, "sendKey");
856861
const firstKeysPayload = await keysSentPromise1;
857862
expect(firstKeysPayload.keys).toHaveLength(1);
858863
expect(firstKeysPayload.keys[0].index).toEqual(0);
@@ -869,14 +874,24 @@ describe("MatrixRTCSession", () => {
869874
.mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId));
870875
sess.onRTCSessionMemberUpdate();
871876

872-
jest.advanceTimersByTime(10000);
877+
jest.advanceTimersByTime(KEY_DELAY);
878+
expect(sendKeySpy).toHaveBeenCalledTimes(1);
879+
// check that we send the key with index 1 even though the send gets delayed when leaving.
880+
// this makes sure we do not use an index that is one too old.
881+
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
882+
// fake a condition in which we send another encryption key event.
883+
// this could happen do to someone joining the call.
884+
(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();
885+
expect(sendKeySpy).toHaveBeenLastCalledWith(expect.any(String), 1, sess.memberships);
886+
jest.advanceTimersByTime(7000);
873887

874888
const secondKeysPayload = await keysSentPromise2;
875889

876890
expect(secondKeysPayload.keys).toHaveLength(1);
877891
expect(secondKeysPayload.keys[0].index).toEqual(1);
878892
expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2);
879-
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(2);
893+
// initial, on leave and the fake one we do with: `(sess as unknown as any).encryptionManager.sendEncryptionKeysEvent();`
894+
expect(sess!.statistics.counters.roomEventEncryptionKeysSent).toEqual(3);
880895
} finally {
881896
jest.useRealTimers();
882897
}
@@ -965,6 +980,29 @@ describe("MatrixRTCSession", () => {
965980
jest.useRealTimers();
966981
}
967982
});
983+
984+
it("send key as to device", async () => {
985+
jest.useFakeTimers();
986+
try {
987+
const keySentPromise = new Promise((resolve) => {
988+
sendToDeviceMock.mockImplementation(resolve);
989+
});
990+
991+
const mockRoom = makeMockRoom([membershipTemplate]);
992+
sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom);
993+
994+
sess!.joinRoomSession([mockFocus], mockFocus, {
995+
manageMediaKeys: true,
996+
useExperimentalToDeviceTransport: true,
997+
});
998+
999+
await keySentPromise;
1000+
1001+
expect(sendToDeviceMock).toHaveBeenCalled();
1002+
} finally {
1003+
jest.useRealTimers();
1004+
}
1005+
});
9681006
});
9691007

9701008
describe("receiving", () => {

spec/unit/matrixrtc/RoomKeyTransport.spec.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
2020
import { EventType, MatrixClient, RoomEvent } from "../../../src";
2121
import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src";
2222

23-
describe("RoomKyTransport", () => {
23+
describe("RoomKeyTransport", () => {
2424
let client: MatrixClient;
2525
let room: Room & {
2626
emitTimelineEvent: (event: MatrixEvent) => void;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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 { makeMockEvent, membershipTemplate, mockCallMembership } from "./mocks";
20+
import { ClientEvent, EventType, type MatrixClient } from "../../../src";
21+
import { ToDeviceKeyTransport } from "../../../src/matrixrtc/ToDeviceKeyTransport.ts";
22+
import { getMockClientWithEventEmitter } from "../../test-utils/client.ts";
23+
import { type Statistics } from "../../../src/matrixrtc";
24+
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport.ts";
25+
import { defer } from "../../../src/utils.ts";
26+
import { type Logger } from "../../../src/logger.ts";
27+
28+
describe("ToDeviceKeyTransport", () => {
29+
const roomId = "!room:id";
30+
31+
let mockClient: Mocked<MatrixClient>;
32+
let statistics: Statistics;
33+
let mockLogger: Mocked<Logger>;
34+
let transport: ToDeviceKeyTransport;
35+
36+
beforeEach(() => {
37+
mockClient = getMockClientWithEventEmitter({
38+
encryptAndSendToDevice: jest.fn(),
39+
});
40+
mockLogger = {
41+
debug: jest.fn(),
42+
warn: jest.fn(),
43+
} as unknown as Mocked<Logger>;
44+
statistics = {
45+
counters: {
46+
roomEventEncryptionKeysSent: 0,
47+
roomEventEncryptionKeysReceived: 0,
48+
},
49+
totals: {
50+
roomEventEncryptionKeysReceivedTotalAge: 0,
51+
},
52+
};
53+
54+
transport = new ToDeviceKeyTransport("@alice:example.org", "MYDEVICE", roomId, mockClient, statistics, {
55+
getChild: jest.fn().mockReturnValue(mockLogger),
56+
} as unknown as Mocked<Logger>);
57+
});
58+
59+
it("should send my keys on via to device", async () => {
60+
transport.start();
61+
62+
const keyBase64Encoded = "ABCDEDF";
63+
const keyIndex = 2;
64+
await transport.sendKey(keyBase64Encoded, keyIndex, [
65+
mockCallMembership(
66+
Object.assign({}, membershipTemplate, { device_id: "BOBDEVICE" }),
67+
roomId,
68+
"@bob:example.org",
69+
),
70+
mockCallMembership(
71+
Object.assign({}, membershipTemplate, { device_id: "CARLDEVICE" }),
72+
roomId,
73+
"@carl:example.org",
74+
),
75+
mockCallMembership(
76+
Object.assign({}, membershipTemplate, { device_id: "MATDEVICE" }),
77+
roomId,
78+
"@mat:example.org",
79+
),
80+
]);
81+
82+
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(1);
83+
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledWith(
84+
"io.element.call.encryption_keys",
85+
[
86+
{ userId: "@bob:example.org", deviceId: "BOBDEVICE" },
87+
{ userId: "@carl:example.org", deviceId: "CARLDEVICE" },
88+
{ userId: "@mat:example.org", deviceId: "MATDEVICE" },
89+
],
90+
{
91+
keys: {
92+
index: keyIndex,
93+
key: keyBase64Encoded,
94+
},
95+
member: {
96+
claimed_device_id: "MYDEVICE",
97+
},
98+
room_id: roomId,
99+
session: {
100+
application: "m.call",
101+
call_id: "",
102+
scope: "m.room",
103+
},
104+
},
105+
);
106+
107+
expect(statistics.counters.roomEventEncryptionKeysSent).toBe(1);
108+
});
109+
110+
it("should emit when a key is received", async () => {
111+
const deferred = defer<{ userId: string; deviceId: string; keyBase64Encoded: string; index: number }>();
112+
transport.on(KeyTransportEvents.ReceivedKeys, (userId, deviceId, keyBase64Encoded, index, timestamp) => {
113+
deferred.resolve({ userId, deviceId, keyBase64Encoded, index });
114+
});
115+
transport.start();
116+
117+
const testEncoded = "ABCDEDF";
118+
const testKeyIndex = 2;
119+
120+
mockClient.emit(
121+
ClientEvent.ToDeviceEvent,
122+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
123+
keys: {
124+
index: testKeyIndex,
125+
key: testEncoded,
126+
},
127+
member: {
128+
claimed_device_id: "BOBDEVICE",
129+
},
130+
room_id: roomId,
131+
session: {
132+
application: "m.call",
133+
call_id: "",
134+
scope: "m.room",
135+
},
136+
}),
137+
);
138+
139+
const { userId, deviceId, keyBase64Encoded, index } = await deferred.promise;
140+
expect(userId).toBe("@bob:example.org");
141+
expect(deviceId).toBe("BOBDEVICE");
142+
expect(keyBase64Encoded).toBe(testEncoded);
143+
expect(index).toBe(testKeyIndex);
144+
145+
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(1);
146+
});
147+
148+
it("should not sent to ourself", async () => {
149+
const keyBase64Encoded = "ABCDEDF";
150+
const keyIndex = 2;
151+
await transport.sendKey(keyBase64Encoded, keyIndex, [
152+
mockCallMembership(
153+
Object.assign({}, membershipTemplate, { device_id: "MYDEVICE" }),
154+
roomId,
155+
"@alice:example.org",
156+
),
157+
]);
158+
159+
transport.start();
160+
161+
expect(mockClient.encryptAndSendToDevice).toHaveBeenCalledTimes(0);
162+
});
163+
164+
it("should warn when there is a room mismatch", () => {
165+
transport.start();
166+
167+
const testEncoded = "ABCDEDF";
168+
const testKeyIndex = 2;
169+
170+
mockClient.emit(
171+
ClientEvent.ToDeviceEvent,
172+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, {
173+
keys: {
174+
index: testKeyIndex,
175+
key: testEncoded,
176+
},
177+
member: {
178+
claimed_device_id: "BOBDEVICE",
179+
},
180+
room_id: "!anotherroom:id",
181+
session: {
182+
application: "m.call",
183+
call_id: "",
184+
scope: "m.room",
185+
},
186+
}),
187+
);
188+
189+
expect(mockLogger.warn).toHaveBeenCalledWith("Malformed Event: Mismatch roomId");
190+
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
191+
});
192+
193+
describe("malformed events", () => {
194+
const MALFORMED_EVENT = [
195+
{
196+
keys: {},
197+
member: { claimed_device_id: "MYDEVICE" },
198+
room_id: "!room:id",
199+
session: { application: "m.call", call_id: "", scope: "m.room" },
200+
},
201+
{
202+
keys: { index: 0 },
203+
member: { claimed_device_id: "MYDEVICE" },
204+
room_id: "!room:id",
205+
session: { application: "m.call", call_id: "", scope: "m.room" },
206+
},
207+
{
208+
keys: { keys: "ABCDEF" },
209+
member: { claimed_device_id: "MYDEVICE" },
210+
room_id: "!room:id",
211+
session: { application: "m.call", call_id: "", scope: "m.room" },
212+
},
213+
{
214+
keys: { keys: "ABCDEF", index: 2 },
215+
room_id: "!room:id",
216+
session: { application: "m.call", call_id: "", scope: "m.room" },
217+
},
218+
{
219+
keys: { keys: "ABCDEF", index: 2 },
220+
member: {},
221+
room_id: "!room:id",
222+
session: { application: "m.call", call_id: "", scope: "m.room" },
223+
},
224+
{
225+
keys: { keys: "ABCDEF", index: 2 },
226+
member: { claimed_device_id: "MYDEVICE" },
227+
session: { application: "m.call", call_id: "", scope: "m.room" },
228+
},
229+
{
230+
keys: { keys: "ABCDEF", index: 2 },
231+
member: { claimed_device_id: "MYDEVICE" },
232+
room_id: "!room:id",
233+
session: { application: "m.call", call_id: "", scope: "m.room" },
234+
},
235+
];
236+
237+
test.each(MALFORMED_EVENT)("should warn on malformed event %j", (event) => {
238+
transport.start();
239+
240+
mockClient.emit(
241+
ClientEvent.ToDeviceEvent,
242+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@bob:example.org", undefined, event),
243+
);
244+
245+
expect(mockLogger.warn).toHaveBeenCalled();
246+
expect(statistics.counters.roomEventEncryptionKeysReceived).toBe(0);
247+
});
248+
});
249+
});

spec/unit/matrixrtc/mocks.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
123123
export function makeMockEvent(
124124
type: string,
125125
sender: string,
126-
roomId: string,
126+
roomId: string | undefined,
127127
content: any,
128128
timestamp?: number,
129129
): MatrixEvent {

0 commit comments

Comments
 (0)