From bf81c4bfebd52532d67d30a66e651e3658c8aaad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C5=A0imon=20Brandner?= Date: Tue, 31 Oct 2023 17:01:46 +0100 Subject: [PATCH] Add E2EE for embedded mode of Element Call (#3667) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * WIP refactor for removing m.call events * Always remember rtcsessions since we need to only have one instance * Fix tests * Fix import loop * Fix more cyclic imports & tests * Test session joining * Attempt to make tests happy * Always leave calls in the tests to clean up * comment + desperate attempt to work out what's failing * More test debugging * Okay, so these ones are fine? * Stop more timers and hopefully have happy tests * Test no rejoin * Test malformed m.call.member events * Test event emitting and also move some code to a more sensible place in the file * Test getActiveFoci() * Test event emitting (and also fix it) * Test membership updating & pruning on join * Test getOldestMembership() * Test member event renewal * Don't start the rtc manager until the client has synced Then we can initialise from the state once it's completed. * Fix type * Remove listeners added in constructor * Stop the client here too * Stop the client here also also * ARGH. Disable tests to work out which one is causing the exception * Disable everything * Re-jig to avoid setting listeners in the constructor and re-enable tests * No need to rename this anymore * argh, remove the right listener * Is it this test??? * Re-enable some tests * Try mocking getRooms to return something valid * Re-enable other tests * Give up trying to get the tests to work sensibly and deal with getRooms() returning nothing * Oops, don't enable the ones that were skipped before * One more try at the sensible way * Didn't work, go back to the hack way. * Log when we manage to send the member event update * Support `getOpenIdToken()` in embedded mode (#3676) * Call `sendContentLoaded()` (#3677) * Start MatrixRTC in embedded mode (#3679) * Reschedule the membership event check * Bump widget api version * Add mock for sendContentLoaded() * Embeded mode pre-requisites Signed-off-by: Šimon Brandner * Embeded mode E2EE Signed-off-by: Šimon Brandner * Encryption condition Signed-off-by: Šimon Brandner * Revert "Embeded mode pre-requisites" This reverts commit 8cd73702052609c995ad754e31f85d0da0be4aa9. * Get back event type Signed-off-by: Šimon Brandner fds Signed-off-by: Šimon Brandner * Change embedded E2EE implementation Signed-off-by: Šimon Brandner * More log detail * Fix tests and also better assert because the tests were passing undefined which was considered fine because we were only checking for null. * Simplify updateCallMembershipEvent a bit * Split up updateCallMembershipEvent some more * Use `crypto.getRandomValues()` Signed-off-by: Šimon Brandner * Rename to `membershipToUserAndDeviceId()` Signed-off-by: Šimon Brandner * Better error Signed-off-by: Šimon Brandner * Add log line Signed-off-by: Šimon Brandner * Add comment Signed-off-by: Šimon Brandner * Send call ID in enc events (also a small refactor) Signed-off-by: Šimon Brandner * Revert making `joinRoomSession()` async Signed-off-by: Šimon Brandner * Make `client` `private` again Signed-off-by: Šimon Brandner * Just use `toString()` Signed-off-by: Šimon Brandner * Fix `callId` check Signed-off-by: Šimon Brandner * Fix map Signed-off-by: Šimon Brandner * Fix map compare Signed-off-by: Šimon Brandner * Fix emitting Signed-off-by: Šimon Brandner * Explicit logging Signed-off-by: Šimon Brandner * Refactor Signed-off-by: Šimon Brandner * Make `updateEncryptionKeyEvent()` public Signed-off-by: Šimon Brandner * Only update keys based on others Signed-off-by: Šimon Brandner * Fix call order Signed-off-by: Šimon Brandner * Improve logging Signed-off-by: Šimon Brandner * Avoid races Signed-off-by: Šimon Brandner * Revert "Avoid races" This reverts commit f65ed72d6eaf71711a61db7f05e04899fb137e2d. * Add try-catch Signed-off-by: Šimon Brandner * Make `updateEncryptionKeyEvent()` private Signed-off-by: Šimon Brandner * Handle indices and throttling Signed-off-by: Šimon Brandner * Fix merge mistakes Signed-off-by: Šimon Brandner * Mort post-merge fixes Signed-off-by: Šimon Brandner * Split out key generation from key sending And send all keys in a key event (changes the format of the key event) rather than just the one we just generated. * Remember and clear the timeout for the send key event So we don't schedule more key updates if one is already pending. Also don't update the last sent time when we didn't actually send the keys. * Make key event resends more robust * Attempt to make tests pass * crypto wasn't defined at all * Hopefully get interface right * Fix key format on the wire to base64 * Add comment * More standard method order * Rename encryptMedia The js-sdk doesn't do media and therefore doesn't do media encryption * Stop logging encryption keys now * Use regular base64 It's not going in a URL, so no need * Re-add base64url randomstring was using it. Also give it a test. * Add tests for randomstring * Switch between either browser or node crypto Let's see if this will work... * Obviously crypto has already solved this * Some tests for MatrixRTCSession key stuff * Test keys object contents * Change keys event format To move away from m. keys * Test key event retries * Test onCallEncryption * Test event sending & spam prevention * Test event cancelation * Test onCallEncryption called * Some errors didn't have data * Fix binary key comparison & add log line * Fix compare function with undefined values * Remove more key logging * Check content.keys is an array * Check key index & key * Better function name * Tests too --------- Signed-off-by: Šimon Brandner Co-authored-by: David Baker Co-authored-by: David Baker --- spec/unit/base64.spec.ts | 18 +- spec/unit/matrixrtc/MatrixRTCSession.spec.ts | 248 +++++++++++++++- .../matrixrtc/MatrixRTCSessionManager.spec.ts | 32 ++- spec/unit/matrixrtc/mocks.ts | 6 +- spec/unit/randomstring.spec.ts | 66 +++++ src/@types/event.ts | 1 + src/base64.ts | 11 +- src/matrixrtc/MatrixRTCSession.ts | 267 +++++++++++++++++- src/matrixrtc/MatrixRTCSessionManager.ts | 17 +- src/matrixrtc/types.ts | 26 ++ src/randomstring.ts | 10 + 11 files changed, 682 insertions(+), 20 deletions(-) create mode 100644 spec/unit/randomstring.spec.ts create mode 100644 src/matrixrtc/types.ts diff --git a/spec/unit/base64.spec.ts b/spec/unit/base64.spec.ts index 0639f785ac4..4646fbd84a5 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 f8c229c9a7f..046dea947a1 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 6a240831e94..8784ab48b48 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 fa7d948e620..f710c49ab7f 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 00000000000..526edfacfcd --- /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 14b5b64023d..2111e3988bd 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 5a4c5c87a06..79bc5a49380 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 15c55f69f33..43348ff3ec2 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 6f643a26416..ba1eb0fa1dd 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 00000000000..21a55f46052 --- /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 0ed46fb3895..36a6e748284 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); }