diff --git a/src/matrixrtc/EncryptionManager.ts b/src/matrixrtc/EncryptionManager.ts new file mode 100644 index 0000000000..033fc32721 --- /dev/null +++ b/src/matrixrtc/EncryptionManager.ts @@ -0,0 +1,500 @@ +import { type MatrixClient } from "../client.ts"; +import { logger as rootLogger } from "../logger.ts"; +import { MatrixEvent } from "../models/event.ts"; +import { Room } from "../models/room.ts"; +import { EncryptionConfig } from "./MatrixRTCSession.ts"; +import { secureRandomBase64Url } from "../randomstring.ts"; +import { EncryptionKeysEventContent } from "./types.ts"; +import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; +import { MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts"; +import { CallMembership } from "./CallMembership.ts"; +import { EventType } from "../@types/event.ts"; +const logger = rootLogger.getChild("MatrixRTCSession"); + +/** + * 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; + }; +}; + +/** + * This interface is for testing and for making it possible to interchange the encryption manager. + * @internal + */ +export interface IEncryptionManager { + join(joinConfig: EncryptionConfig | undefined): void; + leave(): void; + onMembershipsUpdate(oldMemberships: CallMembership[]): Promise; + /** + * 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. + * + * @param event the event to process + */ + onCallEncryptionEventReceived(event: MatrixEvent): void; + getEncryptionKeys(): Map>; + statistics: Statistics; +} + +/** + * This class implements the IEncryptionManager interface, + * and takes care of managing the encryption keys of all rtc members: + * - generate new keys for the local user and send them to other participants + * - track all keys of all other members and update livekit. + * + * @internal + */ +export class EncryptionManager implements IEncryptionManager { + private manageMediaKeys = false; + private keysEventUpdateTimeout?: ReturnType; + private makeNewKeyTimeout?: ReturnType; + private setNewKeyTimeouts = new Set>(); + + 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; + } + + private encryptionKeys = new Map>(); + private lastEncryptionKeyUpdateRequest?: number; + + // We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys + // if it looks like a membership has been updated. + private lastMembershipFingerprints: Set | undefined; + + private currentEncryptionKeyIndex = -1; + + public statistics: Statistics = { + counters: { + roomEventEncryptionKeysSent: 0, + roomEventEncryptionKeysReceived: 0, + }, + totals: { + roomEventEncryptionKeysReceivedTotalAge: 0, + }, + }; + private joinConfig: EncryptionConfig | undefined; + + public constructor( + private client: Pick, + private room: Pick, + private getMemberships: () => CallMembership[], + private onEncryptionKeysChanged: ( + keyBin: Uint8Array, + encryptionKeyIndex: number, + participantId: string, + ) => void, + ) {} + + public getEncryptionKeys(): Map> { + return this.encryptionKeys; + } + private joined = false; + public join(joinConfig: EncryptionConfig): void { + this.joinConfig = joinConfig; + this.joined = true; + this.manageMediaKeys = this.joinConfig?.manageMediaKeys ?? this.manageMediaKeys; + if (this.joinConfig?.manageMediaKeys) { + this.makeNewSenderKey(); + this.requestSendCurrentKey(); + } + } + + 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), []); + + if (this.makeNewKeyTimeout !== undefined) { + clearTimeout(this.makeNewKeyTimeout); + this.makeNewKeyTimeout = undefined; + } + for (const t of this.setNewKeyTimeouts) { + clearTimeout(t); + } + this.setNewKeyTimeouts.clear(); + + 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 async onMembershipsUpdate(oldMemberships: CallMembership[]): Promise { + if (this.manageMediaKeys && this.joined) { + const oldMembershipIds = new Set( + oldMemberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership), + ); + const newMembershipIds = new Set( + this.getMemberships() + .filter((m) => !this.isMyMembership(m)) + .map(getParticipantIdFromMembership), + ); + + // We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference + // for this once available + const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x)); + const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x)); + + const oldFingerprints = this.lastMembershipFingerprints; + // always store the fingerprints of these latest memberships + this.storeLastMembershipFingerprints(); + + if (anyLeft) { + if (this.makeNewKeyTimeout) { + // existing rotation in progress, so let it complete + } else { + logger.debug(`Member(s) have left: queueing sender key rotation`); + this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay); + } + } else if (anyJoined) { + logger.debug(`New member(s) have joined: re-sending keys`); + this.requestSendCurrentKey(); + } else if (oldFingerprints) { + // does it look like any of the members have updated their memberships? + const newFingerprints = this.lastMembershipFingerprints!; + + // We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference + // for this once available + const candidateUpdates = + Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) || + Array.from(newFingerprints).some((x) => !oldFingerprints.has(x)); + if (candidateUpdates) { + logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`); + this.requestSendCurrentKey(); + } + } + } + } + + /** + * Generate a new sender key and add it at the next available index + * @param delayBeforeUse - If true, wait for a short period before setting the key for the + * media encryptor to use. If false, set the key immediately. + * @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); + return encryptionKeyIndex; + } + + /** + * Requests that we resend 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 { + if (!this.manageMediaKeys) return; + + if ( + this.lastEncryptionKeyUpdateRequest && + this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now() + ) { + logger.info("Last encryption key event sent too recently: postponing"); + if (this.keysEventUpdateTimeout === undefined) { + this.keysEventUpdateTimeout = setTimeout( + this.sendEncryptionKeysEvent, + this.updateEncryptionKeyThrottle, + ); + } + return; + } + + this.sendEncryptionKeysEvent(); + } + + /** + * Get the known encryption keys for a given participant device. + * + * @param userId the user ID of the participant + * @param deviceId the device ID of the participant + * @returns The encryption keys for the given participant, or undefined if they are not known. + */ + private getKeysForParticipant(userId: string, deviceId: string): Array | undefined { + return this.encryptionKeys.get(getParticipantId(userId, deviceId))?.map((entry) => entry.key); + } + + /** + * Re-sends the encryption keys room event + */ + private sendEncryptionKeysEvent = async (indexToSend?: number): Promise => { + if (this.keysEventUpdateTimeout !== undefined) { + clearTimeout(this.keysEventUpdateTimeout); + this.keysEventUpdateTimeout = undefined; + } + this.lastEncryptionKeyUpdateRequest = Date.now(); + + if (!this.joined) return; + + 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); + + if (!myKeys) { + logger.warn("Tried to send encryption keys event but no keys found!"); + return; + } + + if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) { + logger.warn("Tried to send encryption keys event but no current key index found!"); + return; + } + + const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex; + 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); + + logger.debug( + `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${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); + logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error); + this.keysEventUpdateTimeout = setTimeout(this.sendEncryptionKeysEvent, resendDelay); + } else { + logger.info("Not scheduling key resend as another re-send is already pending"); + } + } + }; + + public onCallEncryptionEventReceived = (event: MatrixEvent): void => { + const userId = event.getSender(); + const content = event.getContent(); + + 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()); + } + } + }; + private storeLastMembershipFingerprints(): void { + this.lastMembershipFingerprints = new Set( + this.getMemberships() + .filter((m) => !this.isMyMembership(m)) + .map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`), + ); + } + + private getNewEncryptionKeyIndex(): number { + if (this.currentEncryptionKeyIndex === -1) { + return 0; + } + + // maximum key index is 255 + return (this.currentEncryptionKeyIndex + 1) % 256; + } + + /** + * Sets an encryption key at a specified index for a participant. + * The encryption keys for the local participant are also stored here under the + * user and device ID of the local participant. + * If the key is older than the existing key at the index, it will be ignored. + * @param userId - The user ID of the participant + * @param deviceId - Device ID of the participant + * @param encryptionKeyIndex - The index of the key to set + * @param encryptionKeyString - The string representation of the key to set in base64 + * @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device. + * @param delayBeforeUse - If true, delay before emitting a key changed event. Useful when setting + * encryption keys for the local participant to allow time for the key to + * be distributed. + */ + private setEncryptionKey( + userId: string, + deviceId: string, + encryptionKeyIndex: number, + encryptionKeyString: string, + timestamp: number, + delayBeforeUse = false, + ): void { + const keyBin = decodeBase64(encryptionKeyString); + + const participantId = getParticipantId(userId, deviceId); + if (!this.encryptionKeys.has(participantId)) { + this.encryptionKeys.set(participantId, []); + } + const participantKeys = this.encryptionKeys.get(participantId)!; + + const existingKeyAtIndex = participantKeys[encryptionKeyIndex]; + + if (existingKeyAtIndex) { + if (existingKeyAtIndex.timestamp > timestamp) { + logger.info( + `Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`, + ); + return; + } + + if (keysEqual(existingKeyAtIndex.key, keyBin)) { + existingKeyAtIndex.timestamp = timestamp; + return; + } + } + + participantKeys[encryptionKeyIndex] = { + key: keyBin, + timestamp, + }; + + if (delayBeforeUse) { + 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()) { + this.currentEncryptionKeyIndex = encryptionKeyIndex; + } + this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); + }, this.useKeyDelay); + this.setNewKeyTimeouts.add(useKeyTimeout); + } else { + if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { + this.currentEncryptionKeyIndex = encryptionKeyIndex; + } + this.onEncryptionKeysChanged(keyBin, encryptionKeyIndex, participantId); + } + } + + private onRotateKeyTimeout = (): void => { + if (!this.manageMediaKeys) return; + + this.makeNewKeyTimeout = undefined; + logger.info("Making new sender key for key rotation"); + const newKeyIndex = this.makeNewSenderKey(true); + // send immediately: if we're about to start sending with a new key, it's + // important we get it out to others as soon as we can. + this.sendEncryptionKeysEvent(newKeyIndex); + }; +} + +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); diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 7f3665f3eb..0540c6207b 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -23,24 +23,13 @@ import { EventType } from "../@types/event.ts"; import { CallMembership } from "./CallMembership.ts"; import { RoomStateEvent } from "../models/room-state.ts"; import { Focus } from "./focus.ts"; -import { secureRandomBase64Url } from "../randomstring.ts"; -import { EncryptionKeysEventContent } from "./types.ts"; -import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; import { KnownMembership } from "../@types/membership.ts"; -import { MatrixError, safeGetRetryAfterMs } from "../http-api/errors.ts"; import { MatrixEvent } from "../models/event.ts"; import { LegacyMembershipManager, IMembershipManager } from "./MembershipManager.ts"; +import { EncryptionManager, IEncryptionManager, Statistics } from "./EncryptionManager.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); -const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; -const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.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]); -} - export enum MatrixRTCSessionEvent { // A member joined, left, or updated a property of their membership. MembershipsChanged = "memberships_changed", @@ -133,63 +122,23 @@ export type JoinSessionConfig = MembershipConfig & EncryptionConfig; */ export class MatrixRTCSession extends TypedEventEmitter { private membershipManager?: IMembershipManager; - + private encryptionManager: IEncryptionManager; // The session Id of the call, this is the call_id of the call Member event. private _callId: string | undefined; - // undefined means not yet joined - private joinConfig?: JoinSessionConfig; - - 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; - } - + /** + * This timeout is responsible to track any expiration. We need to know when we have to start + * to ignore other call members. There is no callback for this. This timeout will always be configured to + * emit when the next membership expires. + */ private expiryTimeout?: ReturnType; - private keysEventUpdateTimeout?: ReturnType; - private makeNewKeyTimeout?: ReturnType; - private setNewKeyTimeouts = new Set>(); - - private manageMediaKeys = false; - // userId:deviceId => array of (key, timestamp) - private encryptionKeys = new Map>(); - private lastEncryptionKeyUpdateRequest?: number; - - // We use this to store the last membership fingerprints we saw, so we can proactively re-send encryption keys - // if it looks like a membership has been updated. - private lastMembershipFingerprints: Set | undefined; - - private currentEncryptionKeyIndex = -1; /** * The statistics for this session. */ - public statistics = { - counters: { - /** - * The number of times we have sent a room event containing encryption keys. - */ - roomEventEncryptionKeysSent: 0, - /** - * The number of times we have received a room event containing encryption keys. - */ - roomEventEncryptionKeysReceived: 0, - }, - 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: 0, - }, - }; + public get statistics(): Statistics { + return this.encryptionManager.statistics; + } /** * The callId (sessionId) of the call. @@ -200,6 +149,7 @@ export class MatrixRTCSession extends TypedEventEmitter this.memberships, + (keyBin: Uint8Array, encryptionKeyIndex: number, participantId: string) => { + this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); + }, + ); } /* @@ -324,7 +282,7 @@ export class MatrixRTCSession extends TypedEventEmitter { + this.encryptionManager.getEncryptionKeys().forEach((keys, participantId) => { keys.forEach((key, index) => { this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, key.key, index, participantId); }); }); } - /** - * Get the known encryption keys for a given participant device. - * - * @param userId the user ID of the participant - * @param deviceId the device ID of the participant - * @returns The encryption keys for the given participant, or undefined if they are not known. - * - * @deprecated This will be made private in a future release. - */ - public getKeysForParticipant(userId: string, deviceId: string): Array | undefined { - return this.getKeysForParticipantInternal(userId, deviceId); - } - - private getKeysForParticipantInternal(userId: string, deviceId: string): Array | undefined { - return this.encryptionKeys.get(getParticipantId(userId, deviceId))?.map((entry) => entry.key); - } - /** * A map of keys used to encrypt and decrypt (we are using a symmetric * cipher) given participant's media. This also includes our own key @@ -431,207 +371,15 @@ export class MatrixRTCSession extends TypedEventEmitter]> { + const keys = + this.encryptionManager.getEncryptionKeys() ?? + new Map>(); // the returned array doesn't contain the timestamps - return Array.from(this.encryptionKeys.entries()) + return Array.from(keys.entries()) .map(([participantId, keys]): [string, Uint8Array[]] => [participantId, keys.map((k) => k.key)]) .values(); } - private getNewEncryptionKeyIndex(): number { - if (this.currentEncryptionKeyIndex === -1) { - return 0; - } - - // maximum key index is 255 - return (this.currentEncryptionKeyIndex + 1) % 256; - } - - /** - * Sets an encryption key at a specified index for a participant. - * The encryption keys for the local participant are also stored here under the - * user and device ID of the local participant. - * If the key is older than the existing key at the index, it will be ignored. - * @param userId - The user ID of the participant - * @param deviceId - Device ID of the participant - * @param encryptionKeyIndex - The index of the key to set - * @param encryptionKeyString - The string representation of the key to set in base64 - * @param timestamp - The timestamp of the key. We assume that these are monotonic for each participant device. - * @param delayBeforeUse - If true, delay before emitting a key changed event. Useful when setting - * encryption keys for the local participant to allow time for the key to - * be distributed. - */ - private setEncryptionKey( - userId: string, - deviceId: string, - encryptionKeyIndex: number, - encryptionKeyString: string, - timestamp: number, - delayBeforeUse = false, - ): void { - const keyBin = decodeBase64(encryptionKeyString); - - const participantId = getParticipantId(userId, deviceId); - if (!this.encryptionKeys.has(participantId)) { - this.encryptionKeys.set(participantId, []); - } - const participantKeys = this.encryptionKeys.get(participantId)!; - - const existingKeyAtIndex = participantKeys[encryptionKeyIndex]; - - if (existingKeyAtIndex) { - if (existingKeyAtIndex.timestamp > timestamp) { - logger.info( - `Ignoring new key at index ${encryptionKeyIndex} for ${participantId} as it is older than existing known key`, - ); - return; - } - - if (keysEqual(existingKeyAtIndex.key, keyBin)) { - existingKeyAtIndex.timestamp = timestamp; - return; - } - } - - participantKeys[encryptionKeyIndex] = { - key: keyBin, - timestamp, - }; - - if (delayBeforeUse) { - 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()) { - this.currentEncryptionKeyIndex = encryptionKeyIndex; - } - this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); - }, this.useKeyDelay); - this.setNewKeyTimeouts.add(useKeyTimeout); - } else { - if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { - this.currentEncryptionKeyIndex = encryptionKeyIndex; - } - this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); - } - } - - /** - * Generate a new sender key and add it at the next available index - * @param delayBeforeUse - If true, wait for a short period before setting the key for the - * media encryptor to use. If false, set the key immediately. - * @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); - return encryptionKeyIndex; - } - - /** - * Requests that we resend 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 { - if (!this.manageMediaKeys) return; - - if ( - this.lastEncryptionKeyUpdateRequest && - this.lastEncryptionKeyUpdateRequest + this.updateEncryptionKeyThrottle > Date.now() - ) { - logger.info("Last encryption key event sent too recently: postponing"); - if (this.keysEventUpdateTimeout === undefined) { - this.keysEventUpdateTimeout = setTimeout( - this.sendEncryptionKeysEvent, - this.updateEncryptionKeyThrottle, - ); - } - return; - } - - this.sendEncryptionKeysEvent(); - } - - /** - * Re-sends the encryption keys room event - */ - private sendEncryptionKeysEvent = async (indexToSend?: number): Promise => { - if (this.keysEventUpdateTimeout !== undefined) { - clearTimeout(this.keysEventUpdateTimeout); - this.keysEventUpdateTimeout = undefined; - } - this.lastEncryptionKeyUpdateRequest = Date.now(); - - if (!this.isJoined()) return; - - 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); - - if (!myKeys) { - logger.warn("Tried to send encryption keys event but no keys found!"); - return; - } - - if (typeof indexToSend !== "number" && this.currentEncryptionKeyIndex === -1) { - logger.warn("Tried to send encryption keys event but no current key index found!"); - return; - } - - const keyIndexToSend = indexToSend ?? this.currentEncryptionKeyIndex; - 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); - - logger.debug( - `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${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); - logger.warn(`Failed to send m.call.encryption_key, retrying in ${resendDelay}`, error); - this.keysEventUpdateTimeout = setTimeout(this.sendEncryptionKeysEvent, resendDelay); - } else { - logger.info("Not scheduling key resend as another re-send is already pending"); - } - } - }; - /** * Sets a timer for the soonest membership expiry */ @@ -656,24 +404,6 @@ export class MatrixRTCSession extends TypedEventEmitter { - const userId = event.getSender(); - const content = event.getContent(); - - 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()); - } - } + this.encryptionManager.onCallEncryptionEventReceived(event); }; - private isMyMembership = (m: CallMembership): boolean => - m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); - /** * @deprecated use onRoomMemberUpdate or onRTCSessionMemberUpdate instead. this should be called when any membership in the call is updated * the old name might have implied to only need to call this when your own membership changes. @@ -799,69 +460,10 @@ export class MatrixRTCSession extends TypedEventEmitter !this.isMyMembership(m)).map(getParticipantIdFromMembership), - ); - const newMembershipIds = new Set( - this.memberships.filter((m) => !this.isMyMembership(m)).map(getParticipantIdFromMembership), - ); - - // We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference - // for this once available - const anyLeft = Array.from(oldMembershipIds).some((x) => !newMembershipIds.has(x)); - const anyJoined = Array.from(newMembershipIds).some((x) => !oldMembershipIds.has(x)); - - const oldFingerprints = this.lastMembershipFingerprints; - // always store the fingerprints of these latest memberships - this.storeLastMembershipFingerprints(); - - if (anyLeft) { - if (this.makeNewKeyTimeout) { - // existing rotation in progress, so let it complete - } else { - logger.debug(`Member(s) have left: queueing sender key rotation`); - this.makeNewKeyTimeout = setTimeout(this.onRotateKeyTimeout, this.makeKeyDelay); - } - } else if (anyJoined) { - logger.debug(`New member(s) have joined: re-sending keys`); - this.requestSendCurrentKey(); - } else if (oldFingerprints) { - // does it look like any of the members have updated their memberships? - const newFingerprints = this.lastMembershipFingerprints!; - - // We can use https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set/symmetricDifference - // for this once available - const candidateUpdates = - Array.from(oldFingerprints).some((x) => !newFingerprints.has(x)) || - Array.from(newFingerprints).some((x) => !oldFingerprints.has(x)); - if (candidateUpdates) { - logger.debug(`Member(s) have updated/reconnected: re-sending keys to everyone`); - this.requestSendCurrentKey(); - } - } - } + // This also needs to be done if `changed` = false + // A member might have updated their fingerprint (created_ts) + this.encryptionManager.onMembershipsUpdate(oldMemberships); this.setExpiryTimer(); }; - - private storeLastMembershipFingerprints(): void { - this.lastMembershipFingerprints = new Set( - this.memberships - .filter((m) => !this.isMyMembership(m)) - .map((m) => `${getParticipantIdFromMembership(m)}:${m.createdTs()}`), - ); - } - - private onRotateKeyTimeout = (): void => { - if (!this.manageMediaKeys) return; - - this.makeNewKeyTimeout = undefined; - logger.info("Making new sender key for key rotation"); - const newKeyIndex = this.makeNewSenderKey(true); - // send immediately: if we're about to start sending with a new key, it's - // important we get it out to others as soon as we can. - this.sendEncryptionKeysEvent(newKeyIndex); - }; }