1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-23 17:02:25 +03:00

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 <toger5@hotmail.de>
This commit is contained in:
Valere Fedronic
2025-04-07 10:30:10 +02:00
committed by GitHub
parent d6ede767c9
commit ba71235539
14 changed files with 819 additions and 671 deletions

View File

@@ -1,57 +1,50 @@
import { type MatrixClient } from "../client.ts";
import { logger as rootLogger } from "../logger.ts";
import { type MatrixEvent } from "../models/event.ts";
import { type Room } from "../models/room.ts";
import { type EncryptionConfig } from "./MatrixRTCSession.ts";
import { secureRandomBase64Url } from "../randomstring.ts";
import { type EncryptionKeysEventContent } from "./types.ts";
import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts";
import { type MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts";
import { safeGetRetryAfterMs } from "../http-api/errors.ts";
import { type CallMembership } from "./CallMembership.ts";
import { EventType } from "../@types/event.ts";
const logger = rootLogger.getChild("MatrixRTCSession");
import { type KeyTransportEventListener, KeyTransportEvents, type IKeyTransport } from "./IKeyTransport.ts";
import { isMyMembership, type Statistics } from "./types.ts";
/**
* A type collecting call encryption statistics for a session.
*/
export type Statistics = {
counters: {
/**
* The number of times we have sent a room event containing encryption keys.
*/
roomEventEncryptionKeysSent: number;
/**
* The number of times we have received a room event containing encryption keys.
*/
roomEventEncryptionKeysReceived: number;
};
totals: {
/**
* 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.
*/
roomEventEncryptionKeysReceivedTotalAge: number;
};
};
const logger = rootLogger.getChild("MatrixRTCSession");
/**
* This interface is for testing and for making it possible to interchange the encryption manager.
* @internal
*/
/**
* Interface representing an encryption manager for handling encryption-related
* operations in a real-time communication context.
*/
export interface IEncryptionManager {
join(joinConfig: EncryptionConfig | undefined): void;
leave(): void;
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
/**
* Process `m.call.encryption_keys` events to track the encryption keys for call participants.
* This should be called each time the relevant event is received from a room timeline.
* If the event is malformed then it will be logged and ignored.
* Joins the encryption manager with the provided configuration.
*
* @param event the event to process
* @param joinConfig - The configuration for joining encryption, or undefined
* if no specific configuration is provided.
*/
join(joinConfig: EncryptionConfig | undefined): void;
/**
* Leaves the encryption manager, cleaning up any associated resources.
*/
leave(): void;
/**
* Called from the MatrixRTCSession when the memberships in this session updated.
*
* @param oldMemberships - The previous state of call memberships before the update.
*/
onMembershipsUpdate(oldMemberships: CallMembership[]): void;
/**
* Retrieves the encryption keys currently managed by the encryption manager.
*
* @returns A map where the keys are identifiers and the values are arrays of
* objects containing encryption keys and their associated timestamps.
*/
onCallEncryptionEventReceived(event: MatrixEvent): void;
getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>>;
statistics: Statistics;
}
/**
@@ -71,9 +64,11 @@ export class EncryptionManager implements IEncryptionManager {
private get updateEncryptionKeyThrottle(): number {
return this.joinConfig?.updateEncryptionKeyThrottle ?? 3_000;
}
private get makeKeyDelay(): number {
return this.joinConfig?.makeKeyDelay ?? 3_000;
}
private get useKeyDelay(): number {
return this.joinConfig?.useKeyDelay ?? 5_000;
}
@@ -87,21 +82,14 @@ export class EncryptionManager implements IEncryptionManager {
private currentEncryptionKeyIndex = -1;
public statistics: Statistics = {
counters: {
roomEventEncryptionKeysSent: 0,
roomEventEncryptionKeysReceived: 0,
},
totals: {
roomEventEncryptionKeysReceivedTotalAge: 0,
},
};
private joinConfig: EncryptionConfig | undefined;
public constructor(
private client: Pick<MatrixClient, "sendEvent" | "getDeviceId" | "getUserId" | "cancelPendingEvent">,
private room: Pick<Room, "roomId">,
private userId: string,
private deviceId: string,
private getMemberships: () => CallMembership[],
private transport: IKeyTransport,
private statistics: Statistics,
private onEncryptionKeysChanged: (
keyBin: Uint8Array<ArrayBufferLike>,
encryptionKeyIndex: number,
@@ -112,11 +100,16 @@ export class EncryptionManager implements IEncryptionManager {
public getEncryptionKeys(): Map<string, Array<{ key: Uint8Array; timestamp: number }>> {
return this.encryptionKeys;
}
private joined = false;
public join(joinConfig: EncryptionConfig): void {
this.joinConfig = joinConfig;
this.joined = true;
this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys;
this.transport.on(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.start();
if (this.joinConfig?.manageMediaKeys) {
this.makeNewSenderKey();
this.requestSendCurrentKey();
@@ -124,15 +117,12 @@ export class EncryptionManager implements IEncryptionManager {
}
public leave(): void {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
// clear our encryption keys as we're done with them now (we'll
// make new keys if we rejoin). We leave keys for other participants
// as they may still be using the same ones.
this.encryptionKeys.set(getParticipantId(userId, deviceId), []);
this.encryptionKeys.set(getParticipantId(this.userId, this.deviceId), []);
this.transport.off(KeyTransportEvents.ReceivedKeys, this.onNewKeyReceived);
this.transport.stop();
if (this.makeNewKeyTimeout !== undefined) {
clearTimeout(this.makeNewKeyTimeout);
@@ -146,18 +136,17 @@ export class EncryptionManager implements IEncryptionManager {
this.manageMediaKeys = false;
this.joined = false;
}
// TODO deduplicate this method. It also is in MatrixRTCSession.
private isMyMembership = (m: CallMembership): boolean =>
m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId();
public onMembershipsUpdate(oldMemberships: CallMembership[]): void {
if (this.manageMediaKeys && this.joined) {
const oldMembershipIds = new Set(
oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership),
oldMemberships
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership),
);
const newMembershipIds = new Set(
this.getMemberships()
.filter((m) => !this.isMyMembership(m))
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map(getParticipantIdFromMembership),
);
@@ -204,16 +193,17 @@ export class EncryptionManager implements IEncryptionManager {
* @returns The index of the new key
*/
private makeNewSenderKey(delayBeforeUse = false): number {
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
const encryptionKey = secureRandomBase64Url(16);
const encryptionKeyIndex = this.getNewEncryptionKeyIndex();
logger.info("Generated new key at index " + encryptionKeyIndex);
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, Date.now(), delayBeforeUse);
this.setEncryptionKey(
this.userId,
this.deviceId,
encryptionKeyIndex,
encryptionKey,
Date.now(),
delayBeforeUse,
);
return encryptionKeyIndex;
}
@@ -266,13 +256,7 @@ export class EncryptionManager implements IEncryptionManager {
logger.info(`Sending encryption keys event. indexToSend=${indexToSend}`);
const userId = this.client.getUserId();
const deviceId = this.client.getDeviceId();
if (!userId) throw new Error("No userId");
if (!deviceId) throw new Error("No deviceId");
const myKeys = this.getKeysForParticipant(userId, deviceId);
const myKeys = this.getKeysForParticipant(this.userId, this.deviceId);
if (!myKeys) {
logger.warn("Tried to send encryption keys event but no keys found!");
@@ -288,35 +272,15 @@ export class EncryptionManager implements IEncryptionManager {
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 this.transport.sendKey(encodeUnpaddedBase64(keyToSend), keyIndexToSend, this.getMemberships());
logger.debug(
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
`Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${this.userId}:${this.deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`,
this.encryptionKeys,
);
} catch (error) {
const matrixError = error as MatrixError;
if (matrixError.event) {
// cancel the pending event: we'll just generate a new one with our latest
// keys when we resend
this.client.cancelPendingEvent(matrixError.event);
}
if (this.keysEventUpdateTimeout === undefined) {
const resendDelay = safeGetRetryAfterMs(matrixError, 5000);
const resendDelay = safeGetRetryAfterMs(error, 5000);
logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error);
this.keysEventUpdateTimeout = setTimeout(() => void this.sendEncryptionKeysEvent(), resendDelay);
} else {
@@ -325,79 +289,14 @@ export class EncryptionManager implements IEncryptionManager {
}
};
public onCallEncryptionEventReceived = (event: MatrixEvent): void => {
const userId = event.getSender();
const content = event.getContent<EncryptionKeysEventContent>();
const deviceId = content["device_id"];
const callId = content["call_id"];
if (!userId) {
logger.warn(`Received m.call.encryption_keys with no userId: callId=${callId}`);
return;
}
// We currently only handle callId = "" (which is the default for room scoped calls)
if (callId !== "") {
logger.warn(
`Received m.call.encryption_keys with unsupported callId: userId=${userId}, deviceId=${deviceId}, callId=${callId}`,
);
return;
}
if (!Array.isArray(content.keys)) {
logger.warn(`Received m.call.encryption_keys where keys wasn't an array: callId=${callId}`);
return;
}
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
// We store our own sender key in the same set along with keys from others, so it's
// important we don't allow our own keys to be set by one of these events (apart from
// the fact that we don't need it anyway because we already know our own keys).
logger.info("Ignoring our own keys event");
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;
for (const key of content.keys) {
if (!key) {
logger.info("Ignoring false-y key in keys event");
continue;
}
const encryptionKey = key.key;
const encryptionKeyIndex = key.index;
if (
!encryptionKey ||
encryptionKeyIndex === undefined ||
encryptionKeyIndex === null ||
callId === undefined ||
callId === null ||
typeof deviceId !== "string" ||
typeof callId !== "string" ||
typeof encryptionKey !== "string" ||
typeof encryptionKeyIndex !== "number"
) {
logger.warn(
`Malformed call encryption_key: userId=${userId}, deviceId=${deviceId}, encryptionKeyIndex=${encryptionKeyIndex} callId=${callId}`,
);
} else {
logger.debug(
`Embedded-E2EE-LOG onCallEncryption userId=${userId}:${deviceId} encryptionKeyIndex=${encryptionKeyIndex} age=${age}ms`,
this.encryptionKeys,
);
this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey, event.getTs());
}
}
public onNewKeyReceived: KeyTransportEventListener = (userId, deviceId, keyBase64Encoded, index, timestamp) => {
this.setEncryptionKey(userId, deviceId, index, keyBase64Encoded, timestamp);
};
private storeLastMembershipFingerprints(): void {
this.lastMembershipFingerprints = new Set(
this.getMemberships()
.filter((m) => !this.isMyMembership(m))
.filter((m) => !isMyMembership(m, this.userId, this.deviceId))
.map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`),
);
}
@@ -466,14 +365,14 @@ export class EncryptionManager implements IEncryptionManager {
const useKeyTimeout = setTimeout(() => {
this.setNewKeyTimeouts.delete(useKeyTimeout);
logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`);
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex;
}
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
}, this.useKeyDelay);
this.setNewKeyTimeouts.add(useKeyTimeout);
} else {
if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) {
if (userId === this.userId && deviceId === this.deviceId) {
this.currentEncryptionKeyIndex = encryptionKeyIndex;
}
this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId);
@@ -493,8 +392,10 @@ export class EncryptionManager implements IEncryptionManager {
}
const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`;
function keysEqual(a: Uint8Array | undefined, b: Uint8Array | undefined): boolean {
if (a === b) return true;
return !!a && !!b && a.length === b.length && a.every((x, i) => x === b[i]);
}
const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId);