Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

MatrixRTC key distribution using to-device messaging #4485

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/matrixrtc/CallMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Focus } from "./focus.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";

type CallScope = "m.room" | "m.user";

// Represents an entry in the memberships section of an m.call.member event as it is on the wire

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

// Application specific data
scope?: CallScope;

key_distribution?: KeyDistributionMechanism;
};

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

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

export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData;

type KeyDistributionMechanism = "room_event" | "to_device";

export class CallMembership {
public static equal(a: CallMembership, b: CallMembership): boolean {
return deepCompare(a.membershipData, b.membershipData);
Expand Down Expand Up @@ -244,4 +250,8 @@ export class CallMembership {
}
}
}

public get keyDistributionMethod(): KeyDistributionMechanism {
return this.membershipData.key_distribution ?? "room_event";
}
}
149 changes: 126 additions & 23 deletions src/matrixrtc/MatrixRTCSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
import { TypedEventEmitter } from "../models/typed-event-emitter.ts";
import { EventTimeline } from "../models/event-timeline.ts";
import { Room } from "../models/room.ts";
import { MatrixClient } from "../client.ts";
import { MatrixClient, SendToDeviceContentMap } from "../client.ts";
import { EventType } from "../@types/event.ts";
import { UpdateDelayedEventAction } from "../@types/requests.ts";
import {
Expand All @@ -31,14 +31,15 @@
import { RoomStateEvent } from "../models/room-state.ts";
import { Focus } from "./focus.ts";
import { randomString, secureRandomBase64Url } from "../randomstring.ts";
import { EncryptionKeysEventContent } from "./types.ts";
import { EncryptionKeysEventContent, EncryptionKeysToDeviceContent } from "./types.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { KnownMembership } from "../@types/membership.ts";
import { MatrixError } from "../http-api/errors.ts";
import { MatrixEvent } from "../models/event.ts";
import { isLivekitFocusActive } from "./LivekitFocus.ts";
import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall.ts";
import { sleep } from "../utils.ts";
import type { RoomWidgetClient } from "../embedded.ts";

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

Expand Down Expand Up @@ -162,8 +163,21 @@
* The number of times we have received a room event containing encryption keys.
*/
roomEventEncryptionKeysReceived: 0,
/**
* The number of times we have sent a to-device event containing encryption keys.
*/
toDeviceEncryptionKeysSent: 0,
/**
* The number of times we have received a to-device event containing encryption keys.
*/
toDeviceEncryptionKeysReceived: 0,
},
totals: {
/**
* The total age (in milliseconds) of all to-device events containing encryption keys that we have received.
* We track the total age so that we can later calculate the average age of all keys received.
*/
toDeviceEncryptionKeysReceivedTotalAge: 0,
/**
* The total age (in milliseconds) of all room events containing encryption keys that we have received.
* We track the total age so that we can later calculate the average age of all keys received.
Expand Down Expand Up @@ -546,7 +560,7 @@
}

/**
* Requests that we resend our current keys to the room. May send a keys event immediately
* Requests that we (re)-send our current keys to the room. May send a keys event immediately
* or queue for alter if one has already been sent recently.
*/
private requestSendCurrentKey(): void {
Expand Down Expand Up @@ -602,21 +616,10 @@
const keyToSend = myKeys[keyIndexToSend];

try {
const content: EncryptionKeysEventContent = {
keys: [
{
index: keyIndexToSend,
key: encodeUnpaddedBase64(keyToSend),
},
],
device_id: deviceId,
call_id: "",
sent_ts: Date.now(),
};

this.statistics.counters.roomEventEncryptionKeysSent += 1;

await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
await Promise.all([
this.sendKeysViaRoomEvent(deviceId, keyToSend, keyIndexToSend),
this.sendKeysViaToDevice(deviceId, keyToSend, keyIndexToSend),
]);

logger.debug(
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
Expand All @@ -639,6 +642,96 @@
}
};

private async sendKeysViaRoomEvent(deviceId: string, key: Uint8Array, index: number): Promise<void> {
const membersRequiringRoomEvent = this.memberships.filter(
(m) => !this.isMyMembership(m) && m.keyDistributionMethod === "room_event",
);

if (membersRequiringRoomEvent.length === 0) {
logger.info("No members require keys via room event");
return;
}

logger.info(
`Sending encryption keys event for: ${membersRequiringRoomEvent.map((m) => `${m.sender}:${m.deviceId}`).join(", ")}`,
);

const content: EncryptionKeysEventContent = {
keys: [
{
index,
key: encodeUnpaddedBase64(key),
},
],
device_id: deviceId,
call_id: "",
sent_ts: Date.now(),
};

this.statistics.counters.roomEventEncryptionKeysSent += 1;

await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, content);
}

private async sendKeysViaToDevice(deviceId: string, key: Uint8Array, index: number): Promise<void> {
const membershipsRequiringToDevice = this.memberships.filter(
(m) => !this.isMyMembership(m) && m.sender && m.keyDistributionMethod === "to_device",
);

if (membershipsRequiringToDevice.length === 0) {
logger.info("No members require keys via to-device event");
return;
}

const content: EncryptionKeysToDeviceContent = {
keys: [{ index, key: encodeUnpaddedBase64(key) }],
device_id: deviceId,
call_id: "",
room_id: this.room.roomId,
sent_ts: Date.now(),
};

logger.info(
`Sending encryption keys to-device batch for: ${membershipsRequiringToDevice.map(({ sender, deviceId }) => `${sender}:${deviceId}`).join(", ")}`,
);

this.statistics.counters.toDeviceEncryptionKeysSent += membershipsRequiringToDevice.length;

// we don't do an instanceof due to circular dependency issues
if ("widgetApi" in this.client) {
logger.info("Sending keys via widgetApi");
// embedded mode, getCrypto() returns null and so we make some assumptions about the underlying implementation

const contentMap: SendToDeviceContentMap = new Map();

membershipsRequiringToDevice.forEach(({ sender, deviceId }) => {
if (!contentMap.has(sender!)) {
contentMap.set(sender!, new Map());
}

contentMap.get(sender!)!.set(deviceId, content);
});

await (this.client as unknown as RoomWidgetClient).sendToDeviceViaWidgetApi(
EventType.CallEncryptionKeysPrefix,
true,
contentMap,
);
} else {
const crypto = this.client.getCrypto();
if (!crypto) {
logger.error("No crypto instance available to send keys via to-device event");
return;
}

const devices = membershipsRequiringToDevice.map(({ deviceId, sender }) => ({ userId: sender!, deviceId }));

const batch = await crypto.encryptToDeviceMessages(EventType.CallEncryptionKeysPrefix, devices, content);

await this.client.queueToDevice(batch);
}
}

/**
* Sets a timer for the soonest membership expiry
*/
Expand Down Expand Up @@ -714,9 +807,17 @@
return;
}

this.statistics.counters.roomEventEncryptionKeysReceived += 1;
const age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
let age: number;

if (event.getRoomId()) {

Check failure on line 812 in src/matrixrtc/MatrixRTCSession.ts

View workflow job for this annotation

GitHub Actions / Jest [unit] (Node lts/*)

MatrixRTCSession › key management › receiving › collects keys from encryption events

TypeError: event.getRoomId is not a function at MatrixRTCSession.getRoomId [as onCallEncryption] (src/matrixrtc/MatrixRTCSession.ts:812:19) at Object.onCallEncryption (spec/unit/matrixrtc/MatrixRTCSession.spec.ts:1179:22)

Check failure on line 812 in src/matrixrtc/MatrixRTCSession.ts

View workflow job for this annotation

GitHub Actions / Jest [unit] (Node 22)

MatrixRTCSession › key management › receiving › collects keys from encryption events

TypeError: event.getRoomId is not a function at MatrixRTCSession.getRoomId [as onCallEncryption] (src/matrixrtc/MatrixRTCSession.ts:812:19) at Object.onCallEncryption (spec/unit/matrixrtc/MatrixRTCSession.spec.ts:1179:22)
this.statistics.counters.roomEventEncryptionKeysReceived += 1;
age = Date.now() - (typeof content.sent_ts === "number" ? content.sent_ts : event.getTs());
this.statistics.totals.roomEventEncryptionKeysReceivedTotalAge += age;
} else {
this.statistics.counters.toDeviceEncryptionKeysReceived += 1;
age = Date.now() - (content as EncryptionKeysToDeviceContent).sent_ts;
this.statistics.totals.toDeviceEncryptionKeysReceivedTotalAge += age;
}

for (const key of content.keys) {
if (!key) {
Expand Down Expand Up @@ -795,8 +896,8 @@
logger.debug(`Member(s) have left: queueing sender key rotation`);
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, MAKE_KEY_DELAY);
} else if (anyJoined) {
logger.debug(`New member(s) have joined: re-sending keys`);
this.requestSendCurrentKey();
logger.debug(`New member(s) have joined: rotating keys`);
this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, MAKE_KEY_DELAY);
} else if (oldFingerprints) {
// does it look like any of the members have updated their memberships?
const newFingerprints = this.lastMembershipFingerprints!;
Expand Down Expand Up @@ -849,6 +950,7 @@
foci_active: this.ownFociPreferred,
membershipID: this.membershipId,
...(createdTs ? { created_ts: createdTs } : {}),
key_distribution: "to_device",
};
}
/**
Expand All @@ -862,6 +964,7 @@
device_id: deviceId,
focus_active: { type: "livekit", focus_selection: "oldest_membership" },
foci_preferred: this.ownFociPreferred ?? [],
key_distribution: "to_device",
};
}

Expand Down
52 changes: 41 additions & 11 deletions src/matrixrtc/MatrixRTCSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { RoomState, RoomStateEvent } from "../models/room-state.ts";
import { MatrixEvent } from "../models/event.ts";
import { MatrixRTCSession } from "./MatrixRTCSession.ts";
import { EventType } from "../@types/event.ts";
import { EncryptionKeysToDeviceContent } from "./types.ts";

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

Expand Down Expand Up @@ -56,7 +57,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM

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

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

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

private async consumeCallEncryptionEvent(event: MatrixEvent, isRetry = false): Promise<void> {
private onTimeline = (event: MatrixEvent): void => {
this.consumeCallEncryptionEvent(event, (event) => event.getRoomId(), false);
};

private onToDeviceEvent = (event: MatrixEvent): void => {
if (!event.isEncrypted()) {
logger.warn("Ignoring unencrypted to-device call encryption event", event);
return;
}
this.consumeCallEncryptionEvent(
event,
(event) => event.getContent<EncryptionKeysToDeviceContent>().room_id,
false,
);
};

/**
* @param event - the event to consume
* @param roomIdExtractor - the function to extract the room id from the event
* @param isRetry - whether this is a retry. If false we will retry decryption failures once
*/
private consumeCallEncryptionEvent = async (
event: MatrixEvent,
roomIdExtractor: (event: MatrixEvent) => string | undefined,
isRetry: boolean,
): Promise<void> => {
await this.client.decryptEventIfNeeded(event);
if (event.isDecryptionFailure()) {
if (!isRetry) {
logger.warn(
`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`,
);
// retry after 1 second. After this we give up.
setTimeout(() => this.consumeCallEncryptionEvent(event, true), 1000);
setTimeout(() => this.consumeCallEncryptionEvent(event, roomIdExtractor, true), 1000);
} else {
logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`);
}
Expand All @@ -117,18 +145,20 @@ export class MatrixRTCSessionManager extends TypedEventEmitter<MatrixRTCSessionM
logger.info(`Decryption succeeded for event ${event.getId()} after retry`);
}

if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve();
if (event.getType() !== EventType.CallEncryptionKeysPrefix) return;
const roomId = roomIdExtractor(event);
if (!roomId) {
logger.error("Received call encryption event with no room_id!");
return;
}

const room = this.client.getRoom(roomId);

const room = this.client.getRoom(event.getRoomId());
if (!room) {
logger.error(`Got room state event for unknown room ${event.getRoomId()}!`);
return Promise.resolve();
logger.error(`Got encryption event for unknown room ${roomId}!`);
return;
}

this.getRoomSession(room).onCallEncryption(event);
}
private onTimeline = (event: MatrixEvent): void => {
this.consumeCallEncryptionEvent(event);
};

private onRoom = (room: Room): void => {
Expand Down
5 changes: 5 additions & 0 deletions src/matrixrtc/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export interface EncryptionKeysEventContent {
sent_ts?: number;
}

export interface EncryptionKeysToDeviceContent extends EncryptionKeysEventContent {
room_id?: string;
sent_ts: number;
}

export type CallNotifyType = "ring" | "notify";

export interface ICallNotifyContent {
Expand Down
Loading