diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index 046dea947a1..545c3923b85 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -17,6 +17,7 @@ limitations under the License. import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src"; import { CallMembershipData } from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; +import { EncryptionKeysEventContent } from "../../../src/matrixrtc/types"; import { randomString } from "../../../src/randomstring"; import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks"; @@ -420,6 +421,55 @@ describe("MatrixRTCSession", () => { } }); + it("Rotates key if a member leaves", async () => { + jest.useFakeTimers(); + try { + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); + const mockRoom = makeMockRoom([membershipTemplate, member2]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const onMyEncryptionKeyChanged = jest.fn(); + sess.on( + MatrixRTCSessionEvent.EncryptionKeyChanged, + (_key: Uint8Array, _idx: number, participantId: string) => { + if (participantId === `${client.getUserId()}:${client.getDeviceId()}`) { + onMyEncryptionKeyChanged(); + } + }, + ); + + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); + + sess.joinRoomSession([mockFocus], true); + const firstKeysPayload = await keysSentPromise1; + expect(firstKeysPayload.keys).toHaveLength(1); + + sendEventMock.mockClear(); + + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation((_roomId, _evType, payload) => resolve(payload)); + }); + + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate], mockRoom.roomId, undefined)); + sess.onMembershipUpdate(); + + jest.advanceTimersByTime(10000); + + const secondKeysPayload = await keysSentPromise2; + + expect(secondKeysPayload.keys).toHaveLength(2); + expect(onMyEncryptionKeyChanged).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + it("Doesn't re-send key immediately", async () => { const realSetImmediate = setImmediate; jest.useFakeTimers(); @@ -643,4 +693,26 @@ describe("MatrixRTCSession", () => { expect(bobKeys[3]).toBeFalsy(); expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8")); }); + + it("ignores keys event for the local participant", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + sess.onCallEncryption({ + getType: jest.fn().mockReturnValue("io.element.call.encryption_keys"), + getContent: jest.fn().mockReturnValue({ + device_id: client.getDeviceId(), + call_id: "", + keys: [ + { + index: 4, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue(client.getUserId()), + } as unknown as MatrixEvent); + + const myKeys = sess.getKeysForParticipant(client.getUserId()!, client.getDeviceId()!)!; + expect(myKeys).toBeFalsy(); + }); }); diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 43348ff3ec2..bba60704379 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -26,13 +26,21 @@ import { MatrixError, MatrixEvent } from "../matrix"; import { randomString, secureRandomBase64Url } from "../randomstring"; import { EncryptionKeysEventContent } from "./types"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64"; -import { isNumber } from "../utils"; const MEMBERSHIP_EXPIRY_TIME = 60 * 60 * 1000; const MEMBER_EVENT_CHECK_PERIOD = 2 * 60 * 1000; // How often we check to see if we need to re-send our member event const CALL_MEMBER_EVENT_RETRY_DELAY_MIN = 3000; const UPDATE_ENCRYPTION_KEY_THROTTLE = 3000; +// A delay after a member leaves before we create and publish a new key, because people +// tend to leave calls at the same time +const MAKE_KEY_DELAY = 3000; +// The delay between creating and sending a new key and starting to encrypt with it. This gives others +// a chance to receive the new key to minimise the chance they don't get media they can't decrypt. +// The total time between a member leaving and the call switching to new keys is therefore +// MAKE_KEY_DELAY + SEND_KEY_DELAY +const USE_KEY_DELAY = 5000; + const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId); @@ -85,6 +93,8 @@ export class MatrixRTCSession extends TypedEventEmitter; private expiryTimeout?: ReturnType; private keysEventUpdateTimeout?: ReturnType; + private makeNewKeyTimeout?: ReturnType; + private setNewKeyTimeouts = new Set>(); private activeFoci: Focus[] | undefined; @@ -253,6 +263,15 @@ export class MatrixRTCSession extends TypedEventEmitter { + this.setNewKeyTimeouts.delete(useKeyTimeout); + logger.info(`Delayed-emitting key changed event for ${participantId} idx ${encryptionKeyIndex}`); + this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); + }, USE_KEY_DELAY); + this.setNewKeyTimeouts.add(useKeyTimeout); + } else { + 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 settign the key for the + * media encryptor to use. If false, set the key immediately. */ - private makeNewSenderKey(): void { + private makeNewSenderKey(delayBeforeUse = false): void { const userId = this.client.getUserId(); const deviceId = this.client.getDeviceId(); @@ -328,7 +371,7 @@ export class MatrixRTCSession extends TypedEventEmitter m.sender === this.client.getUserId() && m.deviceId === this.client.getDeviceId(); - const callMembersChanged = - oldMemberships - .filter((m) => !isMyMembership(m)) - .map(getParticipantIdFromMembership) - .sort() - .join() !== - this.memberships - .filter((m) => !isMyMembership(m)) - .map(getParticipantIdFromMembership) - .sort() - .join(); - - if (callMembersChanged && this.isJoined()) { - this.requestKeyEventSend(); + + if (this.isJoined() && this.makeNewKeyTimeout === undefined) { + const oldMebershipIds = new Set( + oldMemberships.filter((m) => !isMyMembership(m)).map(getParticipantIdFromMembership), + ); + const newMebershipIds = new Set( + this.memberships.filter((m) => !isMyMembership(m)).map(getParticipantIdFromMembership), + ); + + const anyLeft = Array.from(oldMebershipIds).some((x) => !newMebershipIds.has(x)); + const anyJoined = Array.from(newMebershipIds).some((x) => !oldMebershipIds.has(x)); + + if (anyLeft) { + 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.requestKeyEventSend(); + } } this.setExpiryTimer(); @@ -708,4 +765,13 @@ export class MatrixRTCSession extends TypedEventEmitter { + this.makeNewKeyTimeout = undefined; + logger.info("Making new sender key for key rotation"); + 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(); + }; } diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index ba1eb0fa1dd..e0ca3142b69 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -121,7 +121,9 @@ export class MatrixRTCSessionManager extends TypedEventEmitter