Skip to content

Commit 2260c13

Browse files
committed
refactor: extract RoomKeyTransport class for key distribution
1 parent 3657eb6 commit 2260c13

File tree

5 files changed

+239
-124
lines changed

5 files changed

+239
-124
lines changed

spec/unit/matrixrtc/MatrixRTCSession.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -584,7 +584,7 @@ describe("MatrixRTCSession", () => {
584584
});
585585

586586
sess!.joinRoomSession([mockFocus], mockFocus, { manageMediaKeys: true });
587-
jest.advanceTimersByTime(10000);
587+
await jest.runAllTimersAsync();
588588

589589
await eventSentPromise;
590590

src/matrixrtc/EncryptionManager.ts

Lines changed: 42 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,12 @@
1-
import { type MatrixClient } from "../client.ts";
21
import { logger as rootLogger } from "../logger.ts";
32
import { type MatrixEvent } from "../models/event.ts";
4-
import { type Room } from "../models/room.ts";
53
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
64
import { secureRandomBase64Url } from "../randomstring.ts";
7-
import { type EncryptionKeysEventContent } from "./types.ts";
85
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
9-
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts";
6+
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
107
import { type CallMembership } from "./CallMembership.ts";
11-
import { EventType } from "../@types/event.ts";
8+
import { type IKeyTransport } from "./IKeyTransport.ts";
9+
1210
const logger = rootLogger.getChild("MatrixRTCSession");
1311

1412
/**
@@ -40,8 +38,11 @@ export type Statistics = {
4038
*/
4139
export interface IEncryptionManager {
4240
join(joinConfig: EncryptionConfig | undefined): void;
41+
4342
leave(): void;
43+
4444
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
45+
4546
/**
4647
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
4748
* This should be called each time the relevant event is received from a room timeline.
@@ -50,7 +51,9 @@ export interface IEncryptionManager {
5051
* @param event the event to process
5152
*/
5253
onCallEncryptionEventReceived(event: MatrixEvent): void;
54+
5355
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
56+
5457
statistics: Statistics;
5558
}
5659

@@ -71,9 +74,11 @@ export class EncryptionManager implements IEncryptionManager {
7174
private get updateEncryptionKeyThrottle(): number {
7275
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
7376
}
77+
7478
private get makeKeyDelay(): number {
7579
return this.joinConfig?.makeKeyDelay ?? 3_000;
7680
}
81+
7782
private get useKeyDelay(): number {
7883
return this.joinConfig?.useKeyDelay ?? 5_000;
7984
}
@@ -99,9 +104,10 @@ export class EncryptionManager implements IEncryptionManager {
99104
private joinConfig: EncryptionConfig | undefined;
100105

101106
public constructor(
102-
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">,
103-
private room: Pick<Room, "roomId">,
107+
private userId: string,
108+
private deviceId: string,
104109
private getMemberships: () => CallMembership[],
110+
private transport: IKeyTransport,
105111
private onEncryptionKeysChanged: (
106112
keyBin: Uint8Array<ArrayBufferLike>,
107113
encryptionKeyIndex: number,
@@ -112,7 +118,9 @@ export class EncryptionManager implements IEncryptionManager {
112118
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
113119
return this.encryptionKeys;
114120
}
121+
115122
private joined = false;
123+
116124
public join(joinConfig: EncryptionConfig): void {
117125
this.joinConfig = joinConfig;
118126
this.joined = true;
@@ -124,15 +132,10 @@ export class EncryptionManager implements IEncryptionManager {
124132
}
125133

126134
public leave(): void {
127-
const userId = this.client.getUserId();
128-
const deviceId = this.client.getDeviceId();
129-
130-
if (!userId) throw new Error("No userId");
131-
if (!deviceId) throw new Error("No deviceId");
132135
// clear our encryption keys as we're done with them now (we'll
133136
// make new keys if we rejoin). We leave keys for other participants
134137
// as they may still be using the same ones.
135-
this.encryptionKeys.set(getParticipantId(userId, deviceId), []);
138+
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
136139

137140
if (this.makeNewKeyTimeout !== undefined) {
138141
clearTimeout(this.makeNewKeyTimeout);
@@ -146,9 +149,9 @@ export class EncryptionManager implements IEncryptionManager {
146149
this.manageMediaKeys = false;
147150
this.joined = false;
148151
}
152+
149153
// TODO deduplicate this method. It also is in MatrixRTCSession.
150-
private isMyMembership = (m: CallMembership): boolean =>
151-
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
154+
private isMyMembership = (m: CallMembership): boolean => m.sender === this.userId && m.deviceId === this.deviceId;
152155

153156
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
154157
if (this.manageMediaKeys && this.joined) {
@@ -204,16 +207,17 @@ export class EncryptionManager implements IEncryptionManager {
204207
* @returns The index of the new key
205208
*/
206209
private makeNewSenderKey(delayBeforeUse = false): number {
207-
const userId = this.client.getUserId();
208-
const deviceId = this.client.getDeviceId();
209-
210-
if (!userId) throw new Error("No userId");
211-
if (!deviceId) throw new Error("No deviceId");
212-
213210
const encryptionKey = secureRandomBase64Url(16);
214211
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
215212
logger.info("Generated new key at index " + encryptionKeyIndex);
216-
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
213+
this.setEncryptionKey(
214+
this.userId,
215+
this.deviceId,
216+
encryptionKeyIndex,
217+
encryptionKey,
218+
Date.now(),
219+
delayBeforeUse,
220+
);
217221
return encryptionKeyIndex;
218222
}
219223

@@ -266,13 +270,7 @@ export class EncryptionManager implements IEncryptionManager {
266270

267271
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
268272

269-
const userId = this.client.getUserId();
270-
const deviceId = this.client.getDeviceId();
271-
272-
if (!userId) throw new Error("No userId");
273-
if (!deviceId) throw new Error("No deviceId");
274-
275-
const myKeys = this.getKeysForParticipant(userId, deviceId);
273+
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
276274

277275
if (!myKeys) {
278276
logger.warn("Tried to send encryption keys event but no keys found!");
@@ -288,35 +286,15 @@ export class EncryptionManager implements IEncryptionManager {
288286
const keyToSend = myKeys[keyIndexToSend];
289287

290288
try {
291-
const content: EncryptionKeysEventContent = {
292-
keys: [
293-
{
294-
index: keyIndexToSend,
295-
key: encodeUnpaddedBase64(keyToSend),
296-
},
297-
],
298-
device_id: deviceId,
299-
call_id: "",
300-
sent_ts: Date.now(),
301-
};
302-
303289
this.statistics.counters.roomEventEncryptionKeysSent += 1;
304-
305-
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
306-
290+
await this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend);
307291
logger.debug(
308-
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
292+
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
309293
this.encryptionKeys,
310294
);
311295
} catch (error) {
312-
const matrixError = error as MatrixError;
313-
if (matrixError.event) {
314-
// cancel the pending event: we'll just generate a new one with our latest
315-
// keys when we resend
316-
this.client.cancelPendingEvent(matrixError.event);
317-
}
318296
if (this.keysEventUpdateTimeout === undefined) {
319-
const resendDelay = safeGetRetryAfterMs(matrixError, 5000);
297+
const resendDelay = safeGetRetryAfterMs(error, 5000);
320298
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
321299
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
322300
} else {
@@ -326,74 +304,15 @@ export class EncryptionManager implements IEncryptionManager {
326304
};
327305

328306
public onCallEncryptionEventReceived = (event: MatrixEvent): void => {
329-
const userId = event.getSender();
330-
const content = event.getContent<EncryptionKeysEventContent>();
331-
332-
const deviceId = content["device_id"];
333-
const callId = content["call_id"];
334-
335-
if (!userId) {
336-
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
337-
return;
338-
}
339-
340-
// We currently only handle callId = "" (which is the default for room scoped calls)
341-
if (callId !== "") {
342-
logger.warn(
343-
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
344-
);
345-
return;
346-
}
347-
348-
if (!Array.isArray(content.keys)) {
349-
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
350-
return;
351-
}
352-
353-
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
354-
// We store our own sender key in the same set along with keys from others, so it's
355-
// important we don't allow our own keys to be set by one of these events (apart from
356-
// the fact that we don't need it anyway because we already know our own keys).
357-
logger.info("Ignoring our own keys event");
358-
return;
359-
}
360-
361-
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
362-
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
363-
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
364-
365-
for (const key of content.keys) {
366-
if (!key) {
367-
logger.info("Ignoring false-y key in keys event");
368-
continue;
369-
}
370-
371-
const encryptionKey = key.key;
372-
const encryptionKeyIndex = key.index;
373-
374-
if (
375-
!encryptionKey ||
376-
encryptionKeyIndex === undefined ||
377-
encryptionKeyIndex === null ||
378-
callId === undefined ||
379-
callId === null ||
380-
typeof deviceId !== "string" ||
381-
typeof callId !== "string" ||
382-
typeof encryptionKey !== "string" ||
383-
typeof encryptionKeyIndex !== "number"
384-
) {
385-
logger.warn(
386-
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
387-
);
388-
} else {
389-
logger.debug(
390-
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
391-
this.encryptionKeys,
392-
);
393-
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
394-
}
395-
}
307+
this.transport.receiveRoomEvent(
308+
event,
309+
this.statistics,
310+
(userId, deviceId, encryptionKeyIndex, encryptionKeyString, timestamp) => {
311+
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKeyString, timestamp);
312+
},
313+
);
396314
};
315+
397316
private storeLastMembershipFingerprints(): void {
398317
this.lastMembershipFingerprints = new Set(
399318
this.getMemberships()
@@ -466,14 +385,14 @@ export class EncryptionManager implements IEncryptionManager {
466385
const useKeyTimeout = setTimeout(() => {
467386
this.setNewKeyTimeouts.delete(useKeyTimeout);
468387
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
469-
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
388+
if (userId === this.userId && deviceId === this.deviceId) {
470389
this.currentEncryptionKeyIndex = encryptionKeyIndex;
471390
}
472391
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
473392
}, this.useKeyDelay);
474393
this.setNewKeyTimeouts.add(useKeyTimeout);
475394
} else {
476-
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
395+
if (userId === this.userId && deviceId === this.deviceId) {
477396
this.currentEncryptionKeyIndex = encryptionKeyIndex;
478397
}
479398
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
@@ -493,8 +412,10 @@ export class EncryptionManager implements IEncryptionManager {
493412
}
494413

495414
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
415+
496416
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
497417
if (a === b) return true;
498418
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
499419
}
420+
500421
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);

src/matrixrtc/IKeyTransport.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
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 MatrixEvent } from "../models/event.ts";
18+
import { type Statistics } from "./EncryptionManager.ts";
19+
20+
/**
21+
* Generic interface for the transport used to share room keys.
22+
* Keys can be shared using different transports, e.g. to-device messages or room messages.
23+
*/
24+
export interface IKeyTransport {
25+
/**
26+
* Sends the current user media key.
27+
* @param keyBase64Encoded
28+
* @param index
29+
*/
30+
sendKey(keyBase64Encoded: string, index: number): Promise<void>;
31+
32+
/**
33+
* Takes an incoming event from the transport and extracts the key information.
34+
* @param event
35+
* @param statistics
36+
* @param callback
37+
*/
38+
receiveRoomEvent(
39+
event: MatrixEvent,
40+
statistics: Statistics,
41+
callback: (
42+
userId: string,
43+
deviceId: string,
44+
encryptionKeyIndex: number,
45+
encryptionKeyString: string,
46+
timestamp: number,
47+
) => void,
48+
): void;
49+
}

src/matrixrtc/MatrixRTCSession.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { EncryptionManager, type IEncryptionManager, type Statistics } from "./E
3030
import { LegacyMembershipManager } from "./LegacyMembershipManager.ts";
3131
import { logDurationSync } from "../utils.ts";
3232
import type { IMembershipManager } from "./types.ts";
33+
import { RoomKeyTransport } from "./RoomKeyTransport.ts";
3334

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

@@ -306,10 +307,13 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
306307
// TODO: double check if this is actually needed. Should be covered by refreshRoom in MatrixRTCSessionManager
307308
roomState?.on(RoomStateEvent.Members, this.onRoomMemberUpdate);
308309
this.setExpiryTimer();
310+
311+
const transport = new RoomKeyTransport(this.roomSubset.roomId, this.client);
309312
this.encryptionManager = new EncryptionManager(
310-
this.client,
311-
this.roomSubset,
313+
this.client.getUserId()!,
314+
this.client.getDeviceId()!,
312315
() => this.memberships,
316+
transport,
313317
(keyBin: Uint8Array<ArrayBufferLike>, encryptionKeyIndex: number, participantId: string) => {
314318
this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId);
315319
},

0 commit comments

Comments
 (0)