Skip to content

Commit ba71235

Browse files
BillCarsonFrtoger5
andauthored
MatrixRTC: Introduce key transport abstraction as prep work for to-device encryption (#4773)
* refactor: extract RoomKeyTransport class for key distribution * refact: Call key transport, pass the target recipients to sendKey * update IKeyTransport interface to event emitter. * fix not subscribing to KeyTransportEvents in the EncryptionManager + cleanup * fix one test and broken bits needed for the test (mostly statistics wrangling) * fix tests * add back decryptEventIfNeeded * move and fix room transport tests * dedupe isMyMembership * move type declarations around to be at more reasonable places * remove deprecated `onMembershipUpdate` * fix imports * only start keytransport when session is joined * use makeKey to reduce test loc * fix todo comment -> note comment --------- Co-authored-by: Timo <[email protected]>
1 parent d6ede76 commit ba71235

14 files changed

+802
-654
lines changed

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

+151-201
Large diffs are not rendered by default.

spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts

+1-122
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,7 @@ limitations under the License.
1616

1717
import { type Mock } from "jest-mock";
1818

19-
import {
20-
ClientEvent,
21-
EventTimeline,
22-
EventType,
23-
type IRoomTimelineData,
24-
MatrixClient,
25-
type MatrixEvent,
26-
RoomEvent,
27-
} from "../../../src";
19+
import { ClientEvent, EventTimeline, MatrixClient } from "../../../src";
2820
import { RoomStateEvent } from "../../../src/models/room-state";
2921
import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager";
3022
import { makeMockRoom, makeMockRoomState, membershipTemplate } from "./mocks";
@@ -77,117 +69,4 @@ describe("MatrixRTCSessionManager", () => {
7769

7870
expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1));
7971
});
80-
81-
it("Calls onCallEncryption on encryption keys event", async () => {
82-
const room1 = makeMockRoom([membershipTemplate]);
83-
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
84-
jest.spyOn(client, "getRoom").mockReturnValue(room1);
85-
86-
client.emit(ClientEvent.Room, room1);
87-
const onCallEncryptionMock = jest.fn();
88-
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
89-
client.decryptEventIfNeeded = () => Promise.resolve();
90-
const timelineEvent = {
91-
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
92-
getContent: jest.fn().mockReturnValue({}),
93-
getSender: jest.fn().mockReturnValue("@mock:user.example"),
94-
getRoomId: jest.fn().mockReturnValue("!room:id"),
95-
isDecryptionFailure: jest.fn().mockReturnValue(false),
96-
sender: {
97-
userId: "@mock:user.example",
98-
},
99-
} as unknown as MatrixEvent;
100-
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
101-
await new Promise(process.nextTick);
102-
expect(onCallEncryptionMock).toHaveBeenCalled();
103-
});
104-
105-
describe("event decryption", () => {
106-
it("Retries decryption and processes success", async () => {
107-
try {
108-
jest.useFakeTimers();
109-
const room1 = makeMockRoom([membershipTemplate]);
110-
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
111-
jest.spyOn(client, "getRoom").mockReturnValue(room1);
112-
113-
client.emit(ClientEvent.Room, room1);
114-
const onCallEncryptionMock = jest.fn();
115-
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
116-
let isDecryptionFailure = true;
117-
client.decryptEventIfNeeded = jest
118-
.fn()
119-
.mockReturnValueOnce(Promise.resolve())
120-
.mockImplementation(() => {
121-
isDecryptionFailure = false;
122-
return Promise.resolve();
123-
});
124-
const timelineEvent = {
125-
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
126-
getContent: jest.fn().mockReturnValue({}),
127-
getSender: jest.fn().mockReturnValue("@mock:user.example"),
128-
getRoomId: jest.fn().mockReturnValue("!room:id"),
129-
isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure),
130-
getId: jest.fn().mockReturnValue("event_id"),
131-
sender: {
132-
userId: "@mock:user.example",
133-
},
134-
} as unknown as MatrixEvent;
135-
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
136-
137-
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
138-
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
139-
140-
// should retry after one second:
141-
await jest.advanceTimersByTimeAsync(1500);
142-
143-
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
144-
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
145-
} finally {
146-
jest.useRealTimers();
147-
}
148-
});
149-
150-
it("Retries decryption and processes failure", async () => {
151-
try {
152-
jest.useFakeTimers();
153-
const room1 = makeMockRoom([membershipTemplate]);
154-
jest.spyOn(client, "getRooms").mockReturnValue([room1]);
155-
jest.spyOn(client, "getRoom").mockReturnValue(room1);
156-
157-
client.emit(ClientEvent.Room, room1);
158-
const onCallEncryptionMock = jest.fn();
159-
client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock;
160-
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
161-
const timelineEvent = {
162-
getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix),
163-
getContent: jest.fn().mockReturnValue({}),
164-
getSender: jest.fn().mockReturnValue("@mock:user.example"),
165-
getRoomId: jest.fn().mockReturnValue("!room:id"),
166-
isDecryptionFailure: jest.fn().mockReturnValue(true), // always fail
167-
getId: jest.fn().mockReturnValue("event_id"),
168-
sender: {
169-
userId: "@mock:user.example",
170-
},
171-
} as unknown as MatrixEvent;
172-
client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
173-
174-
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
175-
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
176-
177-
// should retry after one second:
178-
await jest.advanceTimersByTimeAsync(1500);
179-
180-
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
181-
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
182-
183-
// doesn't retry again:
184-
await jest.advanceTimersByTimeAsync(1500);
185-
186-
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
187-
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
188-
} finally {
189-
jest.useRealTimers();
190-
}
191-
});
192-
});
19372
});
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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 { makeMockEvent, makeMockRoom, membershipTemplate, makeKey } from "./mocks";
18+
import { RoomKeyTransport } from "../../../src/matrixrtc/RoomKeyTransport";
19+
import { KeyTransportEvents } from "../../../src/matrixrtc/IKeyTransport";
20+
import { EventType, MatrixClient, RoomEvent } from "../../../src";
21+
import type { IRoomTimelineData, MatrixEvent, Room } from "../../../src";
22+
23+
describe("RoomKyTransport", () => {
24+
let client: MatrixClient;
25+
let room: Room & {
26+
emitTimelineEvent: (event: MatrixEvent) => void;
27+
};
28+
let transport: RoomKeyTransport;
29+
const onCallEncryptionMock = jest.fn();
30+
beforeEach(() => {
31+
onCallEncryptionMock.mockReset();
32+
const statistics = {
33+
counters: {
34+
roomEventEncryptionKeysSent: 0,
35+
roomEventEncryptionKeysReceived: 0,
36+
},
37+
totals: {
38+
roomEventEncryptionKeysReceivedTotalAge: 0,
39+
},
40+
};
41+
room = makeMockRoom([membershipTemplate]);
42+
client = new MatrixClient({ baseUrl: "base_url" });
43+
client.matrixRTC.start();
44+
transport = new RoomKeyTransport(room, client, statistics);
45+
transport.on(KeyTransportEvents.ReceivedKeys, (...p) => {
46+
onCallEncryptionMock(...p);
47+
});
48+
transport.start();
49+
});
50+
51+
afterEach(() => {
52+
client.stopClient();
53+
client.matrixRTC.stop();
54+
transport.stop();
55+
});
56+
57+
it("Calls onCallEncryption on encryption keys event", async () => {
58+
client.decryptEventIfNeeded = () => Promise.resolve();
59+
const timelineEvent = makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
60+
call_id: "",
61+
keys: [makeKey(0, "testKey")],
62+
sent_ts: Date.now(),
63+
device_id: "AAAAAAA",
64+
});
65+
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
66+
await new Promise(process.nextTick);
67+
expect(onCallEncryptionMock).toHaveBeenCalled();
68+
});
69+
70+
describe("event decryption", () => {
71+
it("Retries decryption and processes success", async () => {
72+
jest.useFakeTimers();
73+
let isDecryptionFailure = true;
74+
client.decryptEventIfNeeded = jest
75+
.fn()
76+
.mockReturnValueOnce(Promise.resolve())
77+
.mockImplementation(() => {
78+
isDecryptionFailure = false;
79+
return Promise.resolve();
80+
});
81+
82+
const timelineEvent = Object.assign(
83+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
84+
call_id: "",
85+
keys: [makeKey(0, "testKey")],
86+
sent_ts: Date.now(),
87+
device_id: "AAAAAAA",
88+
}),
89+
{ isDecryptionFailure: jest.fn().mockImplementation(() => isDecryptionFailure) },
90+
);
91+
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
92+
93+
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
94+
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
95+
96+
// should retry after one second:
97+
await jest.advanceTimersByTimeAsync(1500);
98+
99+
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
100+
expect(onCallEncryptionMock).toHaveBeenCalledTimes(1);
101+
jest.useRealTimers();
102+
});
103+
104+
it("Retries decryption and processes failure", async () => {
105+
try {
106+
jest.useFakeTimers();
107+
const onCallEncryptionMock = jest.fn();
108+
client.decryptEventIfNeeded = jest.fn().mockReturnValue(Promise.resolve());
109+
110+
const timelineEvent = Object.assign(
111+
makeMockEvent(EventType.CallEncryptionKeysPrefix, "@mock:user.example", "!room:id", {
112+
call_id: "",
113+
keys: [makeKey(0, "testKey")],
114+
sent_ts: Date.now(),
115+
device_id: "AAAAAAA",
116+
}),
117+
{ isDecryptionFailure: jest.fn().mockReturnValue(true) },
118+
);
119+
120+
room.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData);
121+
122+
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(1);
123+
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
124+
125+
// should retry after one second:
126+
await jest.advanceTimersByTimeAsync(1500);
127+
128+
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
129+
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
130+
131+
// doesn't retry again:
132+
await jest.advanceTimersByTimeAsync(1500);
133+
134+
expect(client.decryptEventIfNeeded).toHaveBeenCalledTimes(2);
135+
expect(onCallEncryptionMock).toHaveBeenCalledTimes(0);
136+
} finally {
137+
jest.useRealTimers();
138+
}
139+
});
140+
});
141+
});

spec/unit/matrixrtc/mocks.ts

+36-10
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ See the License for the specific language governing permissions and
1414
limitations under the License.
1515
*/
1616

17-
import { EventType, type MatrixClient, type MatrixEvent, type Room } from "../../../src";
17+
import { EventEmitter } from "stream";
18+
19+
import { EventType, type Room, RoomEvent, type MatrixClient, type MatrixEvent } from "../../../src";
1820
import { CallMembership, type SessionMembershipData } from "../../../src/matrixrtc/CallMembership";
1921
import { secureRandomString } from "../../../src/randomstring";
2022

@@ -65,19 +67,24 @@ export function makeMockClient(userId: string, deviceId: string): MockClient {
6567
};
6668
}
6769

68-
export function makeMockRoom(membershipData: MembershipData): Room {
70+
export function makeMockRoom(
71+
membershipData: MembershipData,
72+
): Room & { emitTimelineEvent: (event: MatrixEvent) => void } {
6973
const roomId = secureRandomString(8);
7074
// Caching roomState here so it does not get recreated when calling `getLiveTimeline.getState()`
7175
const roomState = makeMockRoomState(membershipData, roomId);
72-
const room = {
76+
const room = Object.assign(new EventEmitter(), {
7377
roomId: roomId,
7478
hasMembershipState: jest.fn().mockReturnValue(true),
7579
getLiveTimeline: jest.fn().mockReturnValue({
7680
getState: jest.fn().mockReturnValue(roomState),
7781
}),
7882
getVersion: jest.fn().mockReturnValue("default"),
79-
} as unknown as Room;
80-
return room;
83+
}) as unknown as Room;
84+
return Object.assign(room, {
85+
emitTimelineEvent: (event: MatrixEvent) =>
86+
room.emit(RoomEvent.Timeline, event, room, undefined, false, {} as any),
87+
});
8188
}
8289

8390
export function makeMockRoomState(membershipData: MembershipData, roomId: string) {
@@ -113,17 +120,36 @@ export function makeMockRoomState(membershipData: MembershipData, roomId: string
113120
};
114121
}
115122

116-
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
117-
const sender = customSender ?? "@mock:user.example";
123+
export function makeMockEvent(
124+
type: string,
125+
sender: string,
126+
roomId: string,
127+
content: any,
128+
timestamp?: number,
129+
): MatrixEvent {
118130
return {
119-
getType: jest.fn().mockReturnValue(EventType.GroupCallMemberPrefix),
120-
getContent: jest.fn().mockReturnValue(membershipData),
131+
getType: jest.fn().mockReturnValue(type),
132+
getContent: jest.fn().mockReturnValue(content),
121133
getSender: jest.fn().mockReturnValue(sender),
122-
getTs: jest.fn().mockReturnValue(Date.now()),
134+
getTs: jest.fn().mockReturnValue(timestamp ?? Date.now()),
123135
getRoomId: jest.fn().mockReturnValue(roomId),
136+
getId: jest.fn().mockReturnValue(secureRandomString(8)),
124137
isDecryptionFailure: jest.fn().mockReturnValue(false),
125138
} as unknown as MatrixEvent;
126139
}
140+
141+
export function mockRTCEvent(membershipData: MembershipData, roomId: string, customSender?: string): MatrixEvent {
142+
const sender = customSender ?? "@mock:user.example";
143+
return makeMockEvent(EventType.GroupCallMemberPrefix, sender, roomId, membershipData);
144+
}
145+
127146
export function mockCallMembership(membershipData: MembershipData, roomId: string, sender?: string): CallMembership {
128147
return new CallMembership(mockRTCEvent(membershipData, roomId, sender), membershipData);
129148
}
149+
150+
export function makeKey(id: number, key: string): { key: string; index: number } {
151+
return {
152+
key: key,
153+
index: id,
154+
};
155+
}

0 commit comments

Comments
 (0)