diff --git a/spec/unit/base64.spec.ts b/spec/unit/base64.spec.ts index 0639f785a..4646fbd84 100644 --- a/spec/unit/base64.spec.ts +++ b/spec/unit/base64.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import { TextEncoder, TextDecoder } from "util"; import NodeBuffer from "node:buffer"; -import { decodeBase64, encodeBase64, encodeUnpaddedBase64 } from "../../src/base64"; +import { decodeBase64, encodeBase64, encodeUnpaddedBase64, encodeUnpaddedBase64Url } from "../../src/base64"; describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => { let origBuffer = Buffer; @@ -43,19 +43,27 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => { global.btoa = undefined; }); - it("Should decode properly encoded data", async () => { + it("Should decode properly encoded data", () => { const decoded = new TextDecoder().decode(decodeBase64("ZW5jb2RpbmcgaGVsbG8gd29ybGQ=")); expect(decoded).toStrictEqual("encoding hello world"); }); - it("Should decode URL-safe base64", async () => { + it("Should encode unpadded URL-safe base64", () => { + const toEncode = "?????"; + const data = new TextEncoder().encode(toEncode); + + const encoded = encodeUnpaddedBase64Url(data); + expect(encoded).toEqual("Pz8_Pz8"); + }); + + it("Should decode URL-safe base64", () => { const decoded = new TextDecoder().decode(decodeBase64("Pz8_Pz8=")); expect(decoded).toStrictEqual("?????"); }); - it("Encode unpadded should not have padding", async () => { + it("Encode unpadded should not have padding", () => { const toEncode = "encoding hello world"; const data = new TextEncoder().encode(toEncode); @@ -68,7 +76,7 @@ describe.each(["browser", "node"])("Base64 encoding (%s)", (env) => { expect(padding).toStrictEqual("="); }); - it("Decode should be indifferent to padding", async () => { + it("Decode should be indifferent to padding", () => { const withPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ="; const withoutPadding = "ZW5jb2RpbmcgaGVsbG8gd29ybGQ"; diff --git a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts index f8c229c9a..046dea947 100644 --- a/spec/unit/matrixrtc/MatrixRTCSession.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSession.spec.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { EventTimeline, EventType, MatrixClient, Room } from "../../../src"; +import { EventTimeline, EventType, MatrixClient, MatrixError, MatrixEvent, Room } from "../../../src"; import { CallMembershipData } from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSession, MatrixRTCSessionEvent } from "../../../src/matrixrtc/MatrixRTCSession"; import { randomString } from "../../../src/randomstring"; -import { makeMockRoom, mockRTCEvent } from "./mocks"; +import { makeMockRoom, makeMockRoomState, mockRTCEvent } from "./mocks"; const membershipTemplate: CallMembershipData = { call_id: "", @@ -184,8 +184,15 @@ describe("MatrixRTCSession", () => { describe("joining", () => { let mockRoom: Room; + let sendStateEventMock: jest.Mock; + let sendEventMock: jest.Mock; beforeEach(() => { + sendStateEventMock = jest.fn(); + sendEventMock = jest.fn(); + client.sendStateEvent = sendStateEventMock; + client.sendEvent = sendEventMock; + mockRoom = makeMockRoom([]); sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); }); @@ -205,8 +212,6 @@ describe("MatrixRTCSession", () => { }); it("sends a membership event when joining a call", () => { - client.sendStateEvent = jest.fn(); - sess!.joinRoomSession([mockFocus]); expect(client.sendStateEvent).toHaveBeenCalledWith( @@ -230,9 +235,6 @@ describe("MatrixRTCSession", () => { }); it("does nothing if join called when already joined", () => { - const sendStateEventMock = jest.fn(); - client.sendStateEvent = sendStateEventMock; - sess!.joinRoomSession([mockFocus]); expect(client.sendStateEvent).toHaveBeenCalledTimes(1); @@ -299,6 +301,188 @@ describe("MatrixRTCSession", () => { jest.useRealTimers(); } }); + + it("creates a key when joining", () => { + sess!.joinRoomSession([mockFocus], true); + const keys = sess?.getKeysForParticipant("@alice:example.org", "AAAAAAA"); + expect(keys).toHaveLength(1); + + const allKeys = sess!.getEncryptionKeys(); + expect(allKeys).toBeTruthy(); + expect(Array.from(allKeys)).toHaveLength(1); + }); + + it("sends keys when joining", async () => { + const eventSentPromise = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); + + sess!.joinRoomSession([mockFocus], true); + + await eventSentPromise; + + expect(sendEventMock).toHaveBeenCalledWith(expect.stringMatching(".*"), "io.element.call.encryption_keys", { + call_id: "", + device_id: "AAAAAAA", + keys: [ + { + index: 0, + key: expect.stringMatching(".*"), + }, + ], + }); + }); + + it("retries key sends", async () => { + jest.useFakeTimers(); + let firstEventSent = false; + + try { + const eventSentPromise = new Promise((resolve) => { + sendEventMock.mockImplementation(() => { + if (!firstEventSent) { + jest.advanceTimersByTime(10000); + + firstEventSent = true; + const e = new Error() as MatrixError; + e.data = {}; + throw e; + } else { + resolve(); + } + }); + }); + + sess!.joinRoomSession([mockFocus], true); + jest.advanceTimersByTime(10000); + + await eventSentPromise; + + expect(sendEventMock).toHaveBeenCalledTimes(2); + } finally { + jest.useRealTimers(); + } + }); + + it("cancels key send event that fail", async () => { + const eventSentinel = {} as unknown as MatrixEvent; + + client.cancelPendingEvent = jest.fn(); + sendEventMock.mockImplementation(() => { + const e = new Error() as MatrixError; + e.data = {}; + e.event = eventSentinel; + throw e; + }); + + sess!.joinRoomSession([mockFocus], true); + + expect(client.cancelPendingEvent).toHaveBeenCalledWith(eventSentinel); + }); + + it("Re-sends key if a new member joins", async () => { + jest.useFakeTimers(); + try { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); + + sess.joinRoomSession([mockFocus], true); + await keysSentPromise1; + + sendEventMock.mockClear(); + jest.advanceTimersByTime(10000); + + const keysSentPromise2 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); + + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); + + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined)); + sess.onMembershipUpdate(); + + await keysSentPromise2; + + expect(sendEventMock).toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + + it("Doesn't re-send key immediately", async () => { + const realSetImmediate = setImmediate; + jest.useFakeTimers(); + try { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const keysSentPromise1 = new Promise((resolve) => { + sendEventMock.mockImplementation(resolve); + }); + + sess.joinRoomSession([mockFocus], true); + await keysSentPromise1; + + sendEventMock.mockClear(); + + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + + const member2 = Object.assign({}, membershipTemplate, { + device_id: "BBBBBBB", + }); + + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([membershipTemplate, member2], mockRoom.roomId, undefined)); + sess.onMembershipUpdate(); + + await new Promise((resolve) => { + realSetImmediate(resolve); + }); + + expect(sendEventMock).not.toHaveBeenCalled(); + } finally { + jest.useRealTimers(); + } + }); + }); + + it("Does not emits if no membership changes", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + sess.onMembershipUpdate(); + + expect(onMembershipsChanged).not.toHaveBeenCalled(); + }); + + it("Emits on membership changes", () => { + const mockRoom = makeMockRoom([membershipTemplate]); + sess = MatrixRTCSession.roomSessionForRoom(client, mockRoom); + + const onMembershipsChanged = jest.fn(); + sess.on(MatrixRTCSessionEvent.MembershipsChanged, onMembershipsChanged); + + mockRoom.getLiveTimeline().getState = jest + .fn() + .mockReturnValue(makeMockRoomState([], mockRoom.roomId, undefined)); + sess.onMembershipUpdate(); + + expect(onMembershipsChanged).toHaveBeenCalled(); }); it("emits an event at the time a membership event expires", () => { @@ -409,4 +593,54 @@ describe("MatrixRTCSession", () => { "@alice:example.org", ); }); + + it("collects keys from encryption events", () => { + 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: "bobsphone", + call_id: "", + keys: [ + { + index: 0, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + } as unknown as MatrixEvent); + + const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!; + expect(bobKeys).toHaveLength(1); + expect(bobKeys[0]).toEqual(Buffer.from("this is the key", "utf-8")); + }); + + it("collects keys at non-zero indices", () => { + 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: "bobsphone", + call_id: "", + keys: [ + { + index: 4, + key: "dGhpcyBpcyB0aGUga2V5", + }, + ], + }), + getSender: jest.fn().mockReturnValue("@bob:example.org"), + } as unknown as MatrixEvent); + + const bobKeys = sess.getKeysForParticipant("@bob:example.org", "bobsphone")!; + expect(bobKeys).toHaveLength(5); + expect(bobKeys[0]).toBeFalsy(); + expect(bobKeys[1]).toBeFalsy(); + expect(bobKeys[2]).toBeFalsy(); + expect(bobKeys[3]).toBeFalsy(); + expect(bobKeys[4]).toEqual(Buffer.from("this is the key", "utf-8")); + }); }); diff --git a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts index 6a240831e..8784ab48b 100644 --- a/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts +++ b/spec/unit/matrixrtc/MatrixRTCSessionManager.spec.ts @@ -14,7 +14,15 @@ See the License for the specific language governing permissions and limitations under the License. */ -import { ClientEvent, EventTimeline, MatrixClient } from "../../../src"; +import { + ClientEvent, + EventTimeline, + EventType, + IRoomTimelineData, + MatrixClient, + MatrixEvent, + RoomEvent, +} from "../../../src"; import { RoomStateEvent } from "../../../src/models/room-state"; import { CallMembershipData } from "../../../src/matrixrtc/CallMembership"; import { MatrixRTCSessionManagerEvents } from "../../../src/matrixrtc/MatrixRTCSessionManager"; @@ -78,4 +86,26 @@ describe("MatrixRTCSessionManager", () => { expect(onEnded).toHaveBeenCalledWith(room1.roomId, client.matrixRTC.getActiveRoomSession(room1)); }); + + it("Calls onCallEncryption on encryption keys event", () => { + const room1 = makeMockRoom([membershipTemplate]); + jest.spyOn(client, "getRooms").mockReturnValue([room1]); + jest.spyOn(client, "getRoom").mockReturnValue(room1); + + client.emit(ClientEvent.Room, room1); + const onCallEncryptionMock = jest.fn(); + client.matrixRTC.getRoomSession(room1).onCallEncryption = onCallEncryptionMock; + + const timelineEvent = { + getType: jest.fn().mockReturnValue(EventType.CallEncryptionKeysPrefix), + getContent: jest.fn().mockReturnValue({}), + getSender: jest.fn().mockReturnValue("@mock:user.example"), + getRoomId: jest.fn().mockReturnValue("!room:id"), + sender: { + userId: "@mock:user.example", + }, + } as unknown as MatrixEvent; + client.emit(RoomEvent.Timeline, timelineEvent, undefined, undefined, false, {} as IRoomTimelineData); + expect(onCallEncryptionMock).toHaveBeenCalled(); + }); }); diff --git a/spec/unit/matrixrtc/mocks.ts b/spec/unit/matrixrtc/mocks.ts index fa7d948e6..f710c49ab 100644 --- a/spec/unit/matrixrtc/mocks.ts +++ b/spec/unit/matrixrtc/mocks.ts @@ -31,7 +31,11 @@ export function makeMockRoom( } as unknown as Room; } -function makeMockRoomState(memberships: CallMembershipData[], roomId: string, getLocalAge: (() => number) | undefined) { +export function makeMockRoomState( + memberships: CallMembershipData[], + roomId: string, + getLocalAge: (() => number) | undefined, +) { return { getStateEvents: (_: string, stateKey: string) => { const event = mockRTCEvent(memberships, roomId, getLocalAge); diff --git a/spec/unit/randomstring.spec.ts b/spec/unit/randomstring.spec.ts new file mode 100644 index 000000000..526edfacf --- /dev/null +++ b/spec/unit/randomstring.spec.ts @@ -0,0 +1,66 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import { decodeBase64 } from "../../src/base64"; +import { + randomLowercaseString, + randomString, + randomUppercaseString, + secureRandomBase64Url, +} from "../../src/randomstring"; + +describe("Random strings", () => { + it.each([8, 16, 32])("secureRandomBase64 generates %i valid base64 bytes", (n: number) => { + const randb641 = secureRandomBase64Url(n); + const randb642 = secureRandomBase64Url(n); + + expect(randb641).not.toEqual(randb642); + + const decoded = decodeBase64(randb641); + expect(decoded).toHaveLength(n); + }); + + it.each([8, 16, 32])("randomString generates string of %i characters", (n: number) => { + const rand1 = randomString(n); + const rand2 = randomString(n); + + expect(rand1).not.toEqual(rand2); + + expect(rand1).toHaveLength(n); + }); + + it.each([8, 16, 32])("randomLowercaseString generates lowercase string of %i characters", (n: number) => { + const rand1 = randomLowercaseString(n); + const rand2 = randomLowercaseString(n); + + expect(rand1).not.toEqual(rand2); + + expect(rand1).toHaveLength(n); + + expect(rand1.toLowerCase()).toEqual(rand1); + }); + + it.each([8, 16, 32])("randomUppercaseString generates lowercase string of %i characters", (n: number) => { + const rand1 = randomUppercaseString(n); + const rand2 = randomUppercaseString(n); + + expect(rand1).not.toEqual(rand2); + + expect(rand1).toHaveLength(n); + + expect(rand1.toUpperCase()).toEqual(rand1); + }); +}); diff --git a/src/@types/event.ts b/src/@types/event.ts index 14b5b6402..2111e3988 100644 --- a/src/@types/event.ts +++ b/src/@types/event.ts @@ -55,6 +55,7 @@ export enum EventType { CallReplaces = "m.call.replaces", CallAssertedIdentity = "m.call.asserted_identity", CallAssertedIdentityPrefix = "org.matrix.call.asserted_identity", + CallEncryptionKeysPrefix = "io.element.call.encryption_keys", KeyVerificationRequest = "m.key.verification.request", KeyVerificationStart = "m.key.verification.start", KeyVerificationCancel = "m.key.verification.cancel", diff --git a/src/base64.ts b/src/base64.ts index 5a4c5c87a..79bc5a493 100644 --- a/src/base64.ts +++ b/src/base64.ts @@ -54,7 +54,16 @@ export function encodeUnpaddedBase64(uint8Array: ArrayBuffer | Uint8Array): stri } /** - * Decode a base64 string to a typed array of uint8. + * Encode a typed array of uint8 as unpadded base64 using the URL-safe encoding. + * @param uint8Array - The data to encode. + * @returns The unpadded base64. + */ +export function encodeUnpaddedBase64Url(uint8Array: ArrayBuffer | Uint8Array): string { + return encodeUnpaddedBase64(uint8Array).replace("+", "-").replace("/", "_"); +} + +/** + * Decode a base64 (or base64url) string to a typed array of uint8. * @param base64 - The base64 to decode. * @returns The decoded data. */ diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index 15c55f69f..43348ff3e 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -22,12 +22,24 @@ import { MatrixClient } from "../client"; import { EventType } from "../@types/event"; import { CallMembership, CallMembershipData } from "./CallMembership"; import { Focus } from "./focus"; -import { MatrixEvent } from "../matrix"; -import { randomString } from "../randomstring"; +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; + +const getParticipantId = (userId: string, deviceId: string): string => `${userId}:${deviceId}`; +const getParticipantIdFromMembership = (m: CallMembership): string => getParticipantId(m.sender!, m.deviceId); + +function keysEqual(a: Uint8Array, b: Uint8Array): 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. @@ -36,6 +48,8 @@ export enum MatrixRTCSessionEvent { // separate from MembershipsChanged, ie. independent of whether our member event // has succesfully gone through. JoinStateChanged = "join_state_changed", + // The key used to encrypt media has changed + EncryptionKeyChanged = "encryption_key_changed", } export type MatrixRTCSessionEventHandlerMap = { @@ -44,6 +58,11 @@ export type MatrixRTCSessionEventHandlerMap = { newMemberships: CallMembership[], ) => void; [MatrixRTCSessionEvent.JoinStateChanged]: (isJoined: boolean) => void; + [MatrixRTCSessionEvent.EncryptionKeyChanged]: ( + key: Uint8Array, + encryptionKeyIndex: number, + participantId: string, + ) => void; }; /** @@ -65,12 +84,18 @@ export class MatrixRTCSession extends TypedEventEmitter; private expiryTimeout?: ReturnType; + private keysEventUpdateTimeout?: ReturnType; private activeFoci: Focus[] | undefined; private updateCallMembershipRunning = false; private needCallMembershipUpdate = false; + private manageMediaKeys = false; + // userId:deviceId => array of keys + private encryptionKeys = new Map>(); + private lastEncryptionKeyUpdateRequest?: number; + /** * Returns all the call memberships for a room, oldest first */ @@ -175,18 +200,28 @@ export class MatrixRTCSession extends TypedEventEmitter resolve(false)); } + 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), []); + logger.info(`Leaving call session in room ${this.room.roomId}`); this.relativeExpiry = undefined; this.activeFoci = undefined; + this.manageMediaKeys = false; this.membershipId = undefined; this.emit(MatrixRTCSessionEvent.JoinStateChanged, false); @@ -228,6 +275,142 @@ export class MatrixRTCSession extends TypedEventEmitter | undefined { + return this.encryptionKeys.get(getParticipantId(userId, deviceId)); + } + + /** + * 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 + */ + public getEncryptionKeys(): IterableIterator<[string, Array]> { + return this.encryptionKeys.entries(); + } + + private getNewEncryptionKeyIndex(): 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!"); + + return (this.getKeysForParticipant(userId, deviceId)?.length ?? 0) % 16; + } + + private setEncryptionKey( + userId: string, + deviceId: string, + encryptionKeyIndex: number, + encryptionKeyString: string, + ): void { + const keyBin = decodeBase64(encryptionKeyString); + + const participantId = getParticipantId(userId, deviceId); + const encryptionKeys = this.encryptionKeys.get(participantId) ?? []; + + if (keysEqual(encryptionKeys[encryptionKeyIndex], keyBin)) return; + + encryptionKeys[encryptionKeyIndex] = keyBin; + this.encryptionKeys.set(participantId, encryptionKeys); + this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); + } + + /** + * Generate a new sender key and add it at the next available index + */ + private makeNewSenderKey(): 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"); + + const encryptionKey = secureRandomBase64Url(16); + const encryptionKeyIndex = this.getNewEncryptionKeyIndex(); + logger.info("Generated new key at index " + encryptionKeyIndex); + this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey); + } + + /** + * Requests that we resend our keys to the room. May send a keys event immediately + * or queue for alter if one has already been sent recently. + */ + private requestKeyEventSend(): void { + if (!this.manageMediaKeys) return; + + if ( + this.lastEncryptionKeyUpdateRequest && + this.lastEncryptionKeyUpdateRequest + UPDATE_ENCRYPTION_KEY_THROTTLE > Date.now() + ) { + logger.info("Last encryption key event sent too recently: postponing"); + if (this.keysEventUpdateTimeout === undefined) { + this.keysEventUpdateTimeout = setTimeout(this.sendEncryptionKeysEvent, UPDATE_ENCRYPTION_KEY_THROTTLE); + } + return; + } + + this.sendEncryptionKeysEvent(); + } + + /** + * Re-sends the encryption keys room event + */ + private sendEncryptionKeysEvent = async (): Promise => { + if (this.keysEventUpdateTimeout !== undefined) { + clearTimeout(this.keysEventUpdateTimeout); + this.keysEventUpdateTimeout = undefined; + } + this.lastEncryptionKeyUpdateRequest = Date.now(); + + logger.info("Sending encryption keys event"); + + if (!this.isJoined()) return; + + 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; + } + + try { + await this.client.sendEvent(this.room.roomId, EventType.CallEncryptionKeysPrefix, { + keys: myKeys.map((key, index) => { + return { + index, + key: encodeUnpaddedBase64(key), + }; + }), + device_id: deviceId, + call_id: "", + } as EncryptionKeysEventContent); + + logger.debug( + `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numSent=${myKeys.length}`, + ); + } 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 = matrixError.data?.retry_after_ms ?? 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 */ @@ -254,6 +437,64 @@ 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 = "" + 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; + } + + for (const key of content.keys) { + if (!key.key || !isNumber(key.index)) { + logger.warn(`Received m.call.encryption_keys with invalid entry: callId=${callId}`); + 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}`, + this.encryptionKeys, + ); + this.setEncryptionKey(userId, deviceId, encryptionKeyIndex, encryptionKey); + } + } + }; + public onMembershipUpdate = (): void => { const oldMemberships = this.memberships; this.memberships = MatrixRTCSession.callMembershipsForRoom(this.room); @@ -267,6 +508,24 @@ 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(); + } + this.setExpiryTimer(); }; diff --git a/src/matrixrtc/MatrixRTCSessionManager.ts b/src/matrixrtc/MatrixRTCSessionManager.ts index 6f643a264..ba1eb0fa1 100644 --- a/src/matrixrtc/MatrixRTCSessionManager.ts +++ b/src/matrixrtc/MatrixRTCSessionManager.ts @@ -17,10 +17,11 @@ limitations under the License. import { logger } from "../logger"; import { MatrixClient, ClientEvent } from "../client"; import { TypedEventEmitter } from "../models/typed-event-emitter"; -import { Room } from "../models/room"; +import { Room, RoomEvent } from "../models/room"; import { RoomState, RoomStateEvent } from "../models/room-state"; import { MatrixEvent } from "../models/event"; import { MatrixRTCSession } from "./MatrixRTCSession"; +import { EventType } from "../@types/event"; export enum MatrixRTCSessionManagerEvents { // A member has joined the MatrixRTC session, creating an active session in a room where there wasn't previously @@ -62,6 +63,7 @@ export class MatrixRTCSessionManager extends TypedEventEmitter { + if (event.getType() !== EventType.CallEncryptionKeysPrefix) return; + + const room = this.client.getRoom(event.getRoomId()); + if (!room) { + logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); + return; + } + + this.getRoomSession(room).onCallEncryption(event); + }; + private onRoom = (room: Room): void => { this.refreshRoom(room); }; diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts new file mode 100644 index 000000000..21a55f460 --- /dev/null +++ b/src/matrixrtc/types.ts @@ -0,0 +1,26 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +export interface EncryptionKeyEntry { + index: number; + key: string; +} + +export interface EncryptionKeysEventContent { + keys: EncryptionKeyEntry[]; + device_id: string; + call_id: string; +} diff --git a/src/randomstring.ts b/src/randomstring.ts index 0ed46fb38..36a6e7482 100644 --- a/src/randomstring.ts +++ b/src/randomstring.ts @@ -15,10 +15,20 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { encodeUnpaddedBase64Url } from "./base64"; +import { crypto } from "./crypto/crypto"; + const LOWERCASE = "abcdefghijklmnopqrstuvwxyz"; const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; const DIGITS = "0123456789"; +export function secureRandomBase64Url(len: number): string { + const key = new Uint8Array(len); + crypto.getRandomValues(key); + + return encodeUnpaddedBase64Url(key); +} + export function randomString(len: number): string { return randomStringFrom(len, UPPERCASE + LOWERCASE + DIGITS); }