Skip to content

Commit 5897988

Browse files
committed
MatrixRTC key distribution using to-device messaging
1 parent fd73d50 commit 5897988

File tree

4 files changed

+182
-34
lines changed

4 files changed

+182
-34
lines changed

src/matrixrtc/CallMembership.ts

+10
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Focus } from "./focus.ts";
2222
import { isLivekitFocusActive } from "./LivekitFocus.ts";
2323

2424
type CallScope = "m.room" | "m.user";
25+
2526
// Represents an entry in the memberships section of an m.call.member event as it is on the wire
2627

2728
// There are two different data interfaces. One for the Legacy types and one compliant with MSC4143
@@ -39,6 +40,8 @@ export type SessionMembershipData = {
3940

4041
// Application specific data
4142
scope?: CallScope;
43+
44+
key_distribution?: KeyDistributionMechanism;
4245
};
4346

4447
export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData =>
@@ -69,6 +72,7 @@ export type CallMembershipDataLegacy = {
6972
membershipID: string;
7073
created_ts?: number;
7174
foci_active?: Focus[];
75+
key_distribution?: KeyDistributionMechanism;
7276
} & EitherAnd<{ expires: number }, { expires_ts: number }>;
7377

7478
export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy =>
@@ -103,6 +107,8 @@ const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is Cal
103107

104108
export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;
105109

110+
type KeyDistributionMechanism = "room_event" | "to_device";
111+
106112
export class CallMembership {
107113
public static equal(a: CallMembership, b: CallMembership): boolean {
108114
return deepCompare(a.membershipData, b.membershipData);
@@ -244,4 +250,8 @@ export class CallMembership {
244250
}
245251
}
246252
}
253+
254+
public get keyDistributionMethod(): KeyDistributionMechanism {
255+
return this.membershipData.key_distribution ?? "room_event";
256+
}
247257
}

src/matrixrtc/MatrixRTCSession.ts

+126-23
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { logger as rootLogger } from "../logger.ts";
1818
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
1919
import { EventTimeline } from "../models/event-timeline.ts";
2020
import { Room } from "../models/room.ts";
21-
import { MatrixClient } from "../client.ts";
21+
import { MatrixClient, SendToDeviceContentMap } from "../client.ts";
2222
import { EventType } from "../@types/event.ts";
2323
import { UpdateDelayedEventAction } from "../@types/requests.ts";
2424
import {
@@ -31,14 +31,15 @@ import {
3131
import { RoomStateEvent } from "../models/room-state.ts";
3232
import { Focus } from "./focus.ts";
3333
import { randomString, secureRandomBase64Url } from "../randomstring.ts";
34-
import { EncryptionKeysEventContent } from "./types.ts";
34+
import { EncryptionKeysEventContent, EncryptionKeysToDeviceContent } from "./types.ts";
3535
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
3636
import { KnownMembership } from "../@types/membership.ts";
3737
import { MatrixError } from "../http-api/errors.ts";
3838
import { MatrixEvent } from "../models/event.ts";
3939
import { isLivekitFocusActive } from "./LivekitFocus.ts";
4040
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall.ts";
4141
import { sleep } from "../utils.ts";
42+
import type { RoomWidgetClient } from "../embedded.ts";
4243

4344
const logger = rootLogger.getChild("MatrixRTCSession");
4445

@@ -162,8 +163,21 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
162163
* The number of times we have received a room event containing encryption keys.
163164
*/
164165
roomEventEncryptionKeysReceived: 0,
166+
/**
167+
* The number of times we have sent a to-device event containing encryption keys.
168+
*/
169+
toDeviceEncryptionKeysSent: 0,
170+
/**
171+
* The number of times we have received a to-device event containing encryption keys.
172+
*/
173+
toDeviceEncryptionKeysReceived: 0,
165174
},
166175
totals: {
176+
/**
177+
* The total age (in milliseconds) of all to-device events containing encryption keys that we have received.
178+
* We track the total age so that we can later calculate the average age of all keys received.
179+
*/
180+
toDeviceEncryptionKeysReceivedTotalAge: 0,
167181
/**
168182
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
169183
* We track the total age so that we can later calculate the average age of all keys received.
@@ -546,7 +560,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
546560
}
547561

548562
/**
549-
* Requests that we resend our current keys to the room. May send a keys event immediately
563+
* Requests that we (re)-send our current keys to the room. May send a keys event immediately
550564
* or queue for alter if one has already been sent recently.
551565
*/
552566
private requestSendCurrentKey(): void {
@@ -602,21 +616,10 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
602616
const keyToSend = myKeys[keyIndexToSend];
603617

604618
try {
605-
const content: EncryptionKeysEventContent = {
606-
keys: [
607-
{
608-
index: keyIndexToSend,
609-
key: encodeUnpaddedBase64(keyToSend),
610-
},
611-
],
612-
device_id: deviceId,
613-
call_id: "",
614-
sent_ts: Date.now(),
615-
};
616-
617-
this.statistics.counters.roomEventEncryptionKeysSent += 1;
618-
619-
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
619+
await Promise.all([
620+
this.sendKeysViaRoomEvent(deviceId, keyToSend, keyIndexToSend),
621+
this.sendKeysViaToDevice(deviceId, keyToSend, keyIndexToSend),
622+
]);
620623

621624
logger.debug(
622625
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
@@ -639,6 +642,96 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
639642
}
640643
};
641644

645+
private async sendKeysViaRoomEvent(deviceId: string, key: Uint8Array, index: number): Promise<void> {
646+
const membersRequiringRoomEvent = this.memberships.filter(
647+
(m) => !this.isMyMembership(m) && m.keyDistributionMethod === "room_event",
648+
);
649+
650+
if (membersRequiringRoomEvent.length === 0) {
651+
logger.info("No members require keys via room event");
652+
return;
653+
}
654+
655+
logger.info(
656+
`Sending encryption keys event for: ${membersRequiringRoomEvent.map((m) => `${m.sender}:${m.deviceId}`).join(", ")}`,
657+
);
658+
659+
const content: EncryptionKeysEventContent = {
660+
keys: [
661+
{
662+
index,
663+
key: encodeUnpaddedBase64(key),
664+
},
665+
],
666+
device_id: deviceId,
667+
call_id: "",
668+
sent_ts: Date.now(),
669+
};
670+
671+
this.statistics.counters.roomEventEncryptionKeysSent += 1;
672+
673+
await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
674+
}
675+
676+
private async sendKeysViaToDevice(deviceId: string, key: Uint8Array, index: number): Promise<void> {
677+
const membershipsRequiringToDevice = this.memberships.filter(
678+
(m) => !this.isMyMembership(m) && m.sender && m.keyDistributionMethod === "to_device",
679+
);
680+
681+
if (membershipsRequiringToDevice.length === 0) {
682+
logger.info("No members require keys via to-device event");
683+
return;
684+
}
685+
686+
const content: EncryptionKeysToDeviceContent = {
687+
keys: [{ index, key: encodeUnpaddedBase64(key) }],
688+
device_id: deviceId,
689+
call_id: "",
690+
room_id: this.room.roomId,
691+
sent_ts: Date.now(),
692+
};
693+
694+
logger.info(
695+
`Sending encryption keys to-device batch for: ${membershipsRequiringToDevice.map(({ sender, deviceId }) => `${sender}:${deviceId}`).join(", ")}`,
696+
);
697+
698+
this.statistics.counters.toDeviceEncryptionKeysSent += membershipsRequiringToDevice.length;
699+
700+
// we don't do an instanceof due to circular dependency issues
701+
if ("widgetApi" in this.client) {
702+
logger.info("Sending keys via widgetApi");
703+
// embedded mode, getCrypto() returns null and so we make some assumptions about the underlying implementation
704+
705+
const contentMap: SendToDeviceContentMap = new Map();
706+
707+
membershipsRequiringToDevice.forEach(({ sender, deviceId }) => {
708+
if (!contentMap.has(sender!)) {
709+
contentMap.set(sender!, new Map());
710+
}
711+
712+
contentMap.get(sender!)!.set(deviceId, content);
713+
});
714+
715+
await (this.client as unknown as RoomWidgetClient).sendToDeviceViaWidgetApi(
716+
EventType.CallEncryptionKeysPrefix,
717+
true,
718+
contentMap,
719+
);
720+
} else {
721+
const crypto = this.client.getCrypto();
722+
if (!crypto) {
723+
logger.error("No crypto instance available to send keys via to-device event");
724+
return;
725+
}
726+
727+
const devices = membershipsRequiringToDevice.map(({ deviceId, sender }) => ({ userId: sender!, deviceId }));
728+
729+
const batch = await crypto.encryptToDeviceMessages(EventType.CallEncryptionKeysPrefix, devices, content);
730+
731+
await this.client.queueToDevice(batch);
732+
}
733+
}
734+
642735
/**
643736
* Sets a timer for the soonest membership expiry
644737
*/
@@ -714,9 +807,17 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
714807
return;
715808
}
716809

717-
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
718-
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
719-
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
810+
let age: number;
811+
812+
if (event.getRoomId()) {
813+
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
814+
age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
815+
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
816+
} else {
817+
this.statistics.counters.toDeviceEncryptionKeysReceived += 1;
818+
age = Date.now() - (content as EncryptionKeysToDeviceContent).sent_ts;
819+
this.statistics.totals.toDeviceEncryptionKeysReceivedTotalAge += age;
820+
}
720821

721822
for (const key of content.keys) {
722823
if (!key) {
@@ -795,8 +896,8 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
795896
logger.debug(`Member(s) have left: queueing sender key rotation`);
796897
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, MAKE_KEY_DELAY);
797898
} else if (anyJoined) {
798-
logger.debug(`New member(s) have joined: re-sending keys`);
799-
this.requestSendCurrentKey();
899+
logger.debug(`New member(s) have joined: rotating keys`);
900+
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, MAKE_KEY_DELAY);
800901
} else if (oldFingerprints) {
801902
// does it look like any of the members have updated their memberships?
802903
const newFingerprints = this.lastMembershipFingerprints!;
@@ -849,6 +950,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
849950
foci_active: this.ownFociPreferred,
850951
membershipID: this.membershipId,
851952
...(createdTs ? { created_ts: createdTs } : {}),
953+
key_distribution: "to_device",
852954
};
853955
}
854956
/**
@@ -862,6 +964,7 @@ export class MatrixRTCSession extends TypedEventEmitter<MatrixRTCSessionEvent, M
862964
device_id: deviceId,
863965
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
864966
foci_preferred: this.ownFociPreferred ?? [],
967+
key_distribution: "to_device",
865968
};
866969
}
867970

src/matrixrtc/MatrixRTCSessionManager.ts

+41-11
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { RoomState, RoomStateEvent } from "../models/room-state.ts";
2222
import { MatrixEvent } from "../models/event.ts";
2323
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
2424
import { EventType } from "../@types/event.ts";
25+
import { EncryptionKeysToDeviceContent } from "./types.ts";
2526

2627
const logger = rootLogger.getChild("MatrixRTCSessionManager");
2728

@@ -56,7 +57,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
5657

5758
public start(): void {
5859
// We shouldn't need to null-check here, but matrix-client.spec.ts mocks getRooms
59-
// returing nothing, and breaks tests if you change it to return an empty array :'(
60+
// returning nothing, and breaks tests if you change it to return an empty array :'(
6061
for (const room of this.client.getRooms() ?? []) {
6162
const session = MatrixRTCSession.roomSessionForRoom(this.client, room);
6263
if (session.memberships.length > 0) {
@@ -67,6 +68,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
6768
this.client.on(ClientEvent.Room, this.onRoom);
6869
this.client.on(RoomEvent.Timeline, this.onTimeline);
6970
this.client.on(RoomStateEvent.Events, this.onRoomState);
71+
this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
7072
}
7173

7274
public stop(): void {
@@ -78,6 +80,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
7880
this.client.off(ClientEvent.Room, this.onRoom);
7981
this.client.off(RoomEvent.Timeline, this.onTimeline);
8082
this.client.off(RoomStateEvent.Events, this.onRoomState);
83+
this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent);
8184
}
8285

8386
/**
@@ -100,15 +103,40 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
100103
return this.roomSessions.get(room.roomId)!;
101104
}
102105

103-
private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
106+
private onTimeline = (event: MatrixEvent): void => {
107+
this.consumeCallEncryptionEvent(event, (event) => event.getRoomId(), false);
108+
};
109+
110+
private onToDeviceEvent = (event: MatrixEvent): void => {
111+
if (!event.isEncrypted()) {
112+
logger.warn("Ignoring unencrypted to-device call encryption event", event);
113+
return;
114+
}
115+
this.consumeCallEncryptionEvent(
116+
event,
117+
(event) => event.getContent<EncryptionKeysToDeviceContent>().room_id,
118+
false,
119+
);
120+
};
121+
122+
/**
123+
* @param event - the event to consume
124+
* @param roomIdExtractor - the function to extract the room id from the event
125+
* @param isRetry - whether this is a retry. If false we will retry decryption failures once
126+
*/
127+
private consumeCallEncryptionEvent = async (
128+
event: MatrixEvent,
129+
roomIdExtractor: (event: MatrixEvent) => string | undefined,
130+
isRetry: boolean,
131+
): Promise<void> => {
104132
await this.client.decryptEventIfNeeded(event);
105133
if (event.isDecryptionFailure()) {
106134
if (!isRetry) {
107135
logger.warn(
108136
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
109137
);
110138
// retry after 1 second. After this we give up.
111-
setTimeout(() => this.consumeCallEncryptionEvent(event, true), 1000);
139+
setTimeout(() => this.consumeCallEncryptionEvent(event, roomIdExtractor, true), 1000);
112140
} else {
113141
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
114142
}
@@ -117,18 +145,20 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
117145
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
118146
}
119147

120-
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
148+
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return;
149+
const roomId = roomIdExtractor(event);
150+
if (!roomId) {
151+
logger.error("Received call encryption event with no room_id!");
152+
return;
153+
}
154+
155+
const room = this.client.getRoom(roomId);
121156

122-
const room = this.client.getRoom(event.getRoomId());
123157
if (!room) {
124-
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
125-
return Promise.resolve();
158+
logger.error(`Got encryption event for unknown room ${roomId}!`);
159+
return;
126160
}
127-
128161
this.getRoomSession(room).onCallEncryption(event);
129-
}
130-
private onTimeline = (event: MatrixEvent): void => {
131-
this.consumeCallEncryptionEvent(event);
132162
};
133163

134164
private onRoom = (room: Room): void => {

src/matrixrtc/types.ts

+5
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ export interface EncryptionKeysEventContent {
2626
sent_ts?: number;
2727
}
2828

29+
export interface EncryptionKeysToDeviceContent extends EncryptionKeysEventContent {
30+
room_id?: string;
31+
sent_ts: number;
32+
}
33+
2934
export type CallNotifyType = "ring" | "notify";
3035

3136
export interface ICallNotifyContent {

0 commit comments

Comments
 (0)