diff --git a/package.json b/package.json index 9ac783ff7e7..eb07945746e 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^7.0.0", + "@matrix-org/matrix-sdk-crypto-wasm": "https://floofy.netlify.app/matrix-org-matrix-sdk-crypto-wasm-v7.0.0-to-device.tgz", "@matrix-org/olm": "3.2.15", "another-json": "^0.2.0", "bs58": "^6.0.0", diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 2b161c778f7..9c04c7f7dd1 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1554,6 +1554,124 @@ describe("RustCrypto", () => { await expect(rustCrypto.exportSecretsBundle()).resolves.toEqual(expect.objectContaining(bundle)); }); }); + + describe("encryptToDeviceMessages", () => { + let rustCrypto: RustCrypto; + let testOlmMachine: RustSdkCryptoJs.OlmMachine; + + beforeEach(async () => { + testOlmMachine = await OlmMachine.initialize( + new RustSdkCryptoJs.UserId(testData.TEST_USER_ID), + new RustSdkCryptoJs.DeviceId(testData.TEST_DEVICE_ID), + ); + jest.spyOn(OlmMachine, "initFromStore").mockResolvedValue(testOlmMachine); + rustCrypto = await makeTestRustCrypto(); + expect(OlmMachine.initFromStore).toHaveBeenCalled(); + }); + + afterEach(() => { + testOlmMachine?.free(); + }); + + const payload = { hello: "world" }; + + it("returns empty batch if devices not known", async () => { + const getMissingSessions = jest.spyOn(testOlmMachine, "getMissingSessions"); + const getDevice = jest.spyOn(testOlmMachine, "getDevice"); + const batch = await rustCrypto.encryptToDeviceMessages( + "m.test.type", + [ + { deviceId: "AAA", userId: "@user1:domain" }, + { deviceId: "BBB", userId: "@user1:domain" }, + { deviceId: "CCC", userId: "@user2:domain" }, + ], + payload, + ); + expect(getMissingSessions.mock.calls[0][0].length).toBe(2); + expect(getDevice).toHaveBeenCalledTimes(3); + expect(batch?.eventType).toEqual("m.room.encrypted"); + expect(batch?.batch).toEqual([]); + }, 120000); + + it("returns encrypted batch for known devices", async () => { + // Make m aware of another device, and get some OTK to be able to establish a session. + await testOlmMachine.markRequestAsSent( + "foo", + RustSdkCryptoJs.RequestType.KeysQuery, + JSON.stringify({ + device_keys: { + "@example:localhost": { + AFGUOBTZWM: { + algorithms: ["m.olm.v1.curve25519-aes-sha2", "m.megolm.v1.aes-sha2"], + device_id: "AFGUOBTZWM", + keys: { + "curve25519:AFGUOBTZWM": "boYjDpaC+7NkECQEeMh5dC+I1+AfriX0VXG2UV7EUQo", + "ed25519:AFGUOBTZWM": "NayrMQ33ObqMRqz6R9GosmHdT6HQ6b/RX/3QlZ2yiec", + }, + signatures: { + "@example:localhost": { + "ed25519:AFGUOBTZWM": + "RoSWvru1jj6fs2arnTedWsyIyBmKHMdOu7r9gDi0BZ61h9SbCK2zLXzuJ9ZFLao2VvA0yEd7CASCmDHDLYpXCA", + }, + }, + user_id: "@example:localhost", + unsigned: { + device_display_name: "rust-sdk", + }, + }, + }, + }, + failures: {}, + }), + ); + + await testOlmMachine.markRequestAsSent( + "bar", + RustSdkCryptoJs.RequestType.KeysClaim, + JSON.stringify({ + one_time_keys: { + "@example:localhost": { + AFGUOBTZWM: { + "signed_curve25519:AAAABQ": { + key: "9IGouMnkB6c6HOd4xUsNv4i3Dulb4IS96TzDordzOws", + signatures: { + "@example:localhost": { + "ed25519:AFGUOBTZWM": + "2bvUbbmJegrV0eVP/vcJKuIWC3kud+V8+C0dZtg4dVovOSJdTP/iF36tQn2bh5+rb9xLlSeztXBdhy4c+LiOAg", + }, + }, + }, + }, + }, + }, + failures: {}, + }), + ); + + const batch = await rustCrypto.encryptToDeviceMessages( + "m.test.type", + [ + { deviceId: "AAA", userId: "@user1:domain" }, + { deviceId: "BBB", userId: "@user1:domain" }, + { deviceId: "CCC", userId: "@user2:domain" }, + { deviceId: "AFGUOBTZWM", userId: "@example:localhost" }, + ], + payload, + ); + expect(batch?.eventType).toEqual("m.room.encrypted"); + expect(batch?.batch.length).toEqual(1); + expect(batch?.batch[0].deviceId).toEqual("AFGUOBTZWM"); + expect(batch?.batch[0].userId).toEqual("@example:localhost"); + expect(batch?.batch[0].payload).toEqual( + expect.objectContaining({ + "algorithm": "m.olm.v1.curve25519-aes-sha2", + "ciphertext": expect.any(Object), + "org.matrix.msgid": expect.any(String), + "sender_key": expect.any(String), + }), + ); + }); + }); }); /** Build a MatrixHttpApi instance */ diff --git a/src/client.ts b/src/client.ts index 04c10ceae52..5a7137f95c6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -3245,7 +3245,7 @@ export class MatrixClient extends TypedEventEmitter[], payload: object): Promise { if (!this.crypto) { diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 6b9db3811dc..a6bde042a78 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -16,6 +16,7 @@ limitations under the License. import type { SecretsBundle } from "@matrix-org/matrix-sdk-crypto-wasm"; import type { IMegolmSessionData } from "../@types/crypto.ts"; +import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts"; import { Room } from "../models/room.ts"; import { DeviceMap } from "../models/device.ts"; import { UIAuthCallback } from "../interactive-auth.ts"; @@ -340,6 +341,22 @@ export interface CryptoApi { */ getEncryptionInfoForEvent(event: MatrixEvent): Promise; + /** + * Encrypts a given payload object via Olm to-device messages to a given + * set of devices. + * + * @param eventType the type of the event to send + * @param devices an array of (user ID, device ID) pairs to encrypt the payload for + * @param payload the payload to encrypt + * + * @returns the batch of encrypted payloads which can then be sent via {@link MatrixClient#queueToDevice} + */ + encryptToDeviceMessages( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload, + ): Promise; + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // Device/User verification @@ -550,6 +567,22 @@ export interface CryptoApi { * @param secrets - The secrets bundle received from the other device */ importSecretsBundle?(secrets: Awaited>): Promise; + + /** + * Encrypts a given payload object via Olm to-device messages to a given + * set of devices. + * + * @param eventType the type of the event to send + * @param devices an array of (user ID, device ID) pairs to encrypt the payload for + * @param payload the payload to encrypt + * + * @returns a promise which resolves to the batch of encrypted payloads which can then be sent via {@link MatrixClient#queueToDevice} + */ + encryptToDeviceMessages( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload, + ): Promise; } /** A reason code for a failure to decrypt an event. */ diff --git a/src/crypto/index.ts b/src/crypto/index.ts index 420160c0de4..4a40975734a 100644 --- a/src/crypto/index.ts +++ b/src/crypto/index.ts @@ -55,7 +55,7 @@ import { IStore } from "../store/index.ts"; import { Room, RoomEvent } from "../models/room.ts"; import { RoomMember, RoomMemberEvent } from "../models/room-member.ts"; import { EventStatus, IContent, IEvent, MatrixEvent, MatrixEventEvent } from "../models/event.ts"; -import { ToDeviceBatch } from "../models/ToDeviceMessage.ts"; +import { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts"; import { ClientEvent, IKeysUploadResponse, ISignedKey, IUploadKeySignaturesResponse, MatrixClient } from "../client.ts"; import { IRoomEncryption, RoomList } from "./RoomList.ts"; import { IKeyBackupInfo } from "./keybackup.ts"; @@ -3522,59 +3522,12 @@ export class Crypto extends TypedEventEmitter[], payload: object): Promise { - const toDeviceBatch: ToDeviceBatch = { - eventType: EventType.RoomMessageEncrypted, - batch: [], - }; - try { - await Promise.all( - userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { - const deviceId = deviceInfo.deviceId; - const encryptedContent: IEncryptedContent = { - algorithm: olmlib.OLM_ALGORITHM, - sender_key: this.olmDevice.deviceCurve25519Key!, - ciphertext: {}, - [ToDeviceMessageId]: uuidv4(), - }; - - toDeviceBatch.batch.push({ - userId, - deviceId, - payload: encryptedContent, - }); - - await olmlib.ensureOlmSessionsForDevices( - this.olmDevice, - this.baseApis, - new Map([[userId, [deviceInfo]]]), - ); - await olmlib.encryptMessageForDevice( - encryptedContent.ciphertext, - this.userId, - this.deviceId, - this.olmDevice, - userId, - deviceInfo, - payload, - ); - }), - ); - - // prune out any devices that encryptMessageForDevice could not encrypt for, - // in which case it will have just not added anything to the ciphertext object. - // There's no point sending messages to devices if we couldn't encrypt to them, - // since that's effectively a blank message. - toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => { - if (Object.keys(msg.payload.ciphertext).length > 0) { - return true; - } else { - logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); - return false; - } - }); + const toDeviceBatch = await this.prepareToDeviceBatch(userDeviceInfoArr, payload); try { await this.baseApis.queueToDevice(toDeviceBatch); @@ -4305,6 +4258,92 @@ export class Crypto extends TypedEventEmitter { throw new Error("Not implemented"); } + + private async prepareToDeviceBatch( + userDeviceInfoArr: IOlmDevice[], + payload: object, + ): Promise { + const toDeviceBatch: ToDeviceBatch = { + eventType: EventType.RoomMessageEncrypted, + batch: [], + }; + + await Promise.all( + userDeviceInfoArr.map(async ({ userId, deviceInfo }) => { + const deviceId = deviceInfo.deviceId; + const encryptedContent: IEncryptedContent = { + algorithm: olmlib.OLM_ALGORITHM, + sender_key: this.olmDevice.deviceCurve25519Key!, + ciphertext: {}, + [ToDeviceMessageId]: uuidv4(), + }; + + toDeviceBatch.batch.push({ + userId, + deviceId, + payload: encryptedContent, + }); + + await olmlib.ensureOlmSessionsForDevices( + this.olmDevice, + this.baseApis, + new Map([[userId, [deviceInfo]]]), + ); + await olmlib.encryptMessageForDevice( + encryptedContent.ciphertext, + this.userId, + this.deviceId, + this.olmDevice, + userId, + deviceInfo, + payload, + ); + }), + ); + + // prune out any devices that encryptMessageForDevice could not encrypt for, + // in which case it will have just not added anything to the ciphertext object. + // There's no point sending messages to devices if we couldn't encrypt to them, + // since that's effectively a blank message. + toDeviceBatch.batch = toDeviceBatch.batch.filter((msg) => { + if (Object.keys(msg.payload.ciphertext).length > 0) { + return true; + } else { + logger.log(`No ciphertext for device ${msg.userId}:${msg.deviceId}: pruning`); + return false; + } + }); + + return toDeviceBatch; + } + + public async encryptToDeviceMessages( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload, + ): Promise { + const userIds = new Set(devices.map(({ userId }) => userId)); + const deviceInfoMap = await this.downloadKeys(Array.from(userIds), false); + + const userDeviceInfoArr: IOlmDevice[] = []; + + devices.forEach(({ userId, deviceId }) => { + const devices = deviceInfoMap.get(userId); + if (!devices) { + logger.warn(`No devices found for user ${userId}`); + return; + } + + if (devices.has(deviceId)) { + // Send the message to a specific device + userDeviceInfoArr.push({ userId, deviceInfo: devices.get(deviceId)! }); + } else { + logger.warn(`No device found for user ${userId} with id ${deviceId}`); + } + }); + + return this.prepareToDeviceBatch(userDeviceInfoArr, payload); + } } /** diff --git a/src/embedded.ts b/src/embedded.ts index 1974ea9f98e..7372e08576a 100644 --- a/src/embedded.ts +++ b/src/embedded.ts @@ -414,6 +414,22 @@ export class RoomWidgetClient extends MatrixClient { await this.widgetApi.sendToDevice((payload as { type: string }).type, true, recursiveMapToObject(contentMap)); } + /** + * Send an event to a specific list of devices via the widget API. + * Optionally encrypts the event. + * + * @param eventType + * @param encrypted - Whether the event should be encrypted. + * @param contentMap content to send. Map from user_id to device_id to content object. + */ + public async sendToDeviceViaWidgetApi( + eventType: string, + encrypted: boolean, + contentMap: SendToDeviceContentMap, + ): Promise { + await this.widgetApi.sendToDevice(eventType, encrypted, recursiveMapToObject(contentMap)); + } + // Overridden since we get TURN servers automatically over the widget API, // and this method would otherwise complain about missing an access token public async checkTurnServers(): Promise { diff --git a/src/matrixrtc/CallMembership.ts b/src/matrixrtc/CallMembership.ts index 495ce1c4947..05b77f37a93 100644 --- a/src/matrixrtc/CallMembership.ts +++ b/src/matrixrtc/CallMembership.ts @@ -22,6 +22,7 @@ import { Focus } from "./focus.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; type CallScope = "m.room" | "m.user"; + // Represents an entry in the memberships section of an m.call.member event as it is on the wire // There are two different data interfaces. One for the Legacy types and one compliant with MSC4143 @@ -39,6 +40,8 @@ export type SessionMembershipData = { // Application specific data scope?: CallScope; + + key_distribution?: KeyDistributionMechanism; }; export const isSessionMembershipData = (data: CallMembershipData): data is SessionMembershipData => @@ -69,6 +72,7 @@ export type CallMembershipDataLegacy = { membershipID: string; created_ts?: number; foci_active?: Focus[]; + key_distribution?: KeyDistributionMechanism; } & EitherAnd<{ expires: number }, { expires_ts: number }>; export const isLegacyCallMembershipData = (data: CallMembershipData): data is CallMembershipDataLegacy => @@ -103,6 +107,8 @@ const checkCallMembershipDataLegacy = (data: any, errors: string[]): data is Cal export type CallMembershipData = CallMembershipDataLegacy | SessionMembershipData; +type KeyDistributionMechanism = "room_event" | "to_device"; + export class CallMembership { public static equal(a: CallMembership, b: CallMembership): boolean { return deepCompare(a.membershipData, b.membershipData); @@ -240,4 +246,8 @@ export class CallMembership { } } } + + public get keyDistributionMethod(): KeyDistributionMechanism { + return this.membershipData.key_distribution ?? "room_event"; + } } diff --git a/src/matrixrtc/MatrixRTCSession.ts b/src/matrixrtc/MatrixRTCSession.ts index ebd69400a90..185ad8c6419 100644 --- a/src/matrixrtc/MatrixRTCSession.ts +++ b/src/matrixrtc/MatrixRTCSession.ts @@ -18,7 +18,7 @@ import { logger as rootLogger } from "../logger.ts"; import { TypedEventEmitter } from "../models/typed-event-emitter.ts"; import { EventTimeline } from "../models/event-timeline.ts"; import { Room } from "../models/room.ts"; -import { MatrixClient } from "../client.ts"; +import { MatrixClient, SendToDeviceContentMap } from "../client.ts"; import { EventType } from "../@types/event.ts"; import { UpdateDelayedEventAction } from "../@types/requests.ts"; import { @@ -31,13 +31,14 @@ import { import { RoomStateEvent } from "../models/room-state.ts"; import { Focus } from "./focus.ts"; import { randomString, secureRandomBase64Url } from "../randomstring.ts"; -import { EncryptionKeysEventContent } from "./types.ts"; +import { EncryptionKeysEventContent, EncryptionKeysToDeviceContent } from "./types.ts"; import { decodeBase64, encodeUnpaddedBase64 } from "../base64.ts"; import { KnownMembership } from "../@types/membership.ts"; import { MatrixError } from "../http-api/errors.ts"; import { MatrixEvent } from "../models/event.ts"; import { isLivekitFocusActive } from "./LivekitFocus.ts"; import { ExperimentalGroupCallRoomMemberState } from "../webrtc/groupCall.ts"; +import type { RoomWidgetClient } from "../embedded.ts"; const logger = rootLogger.getChild("MatrixRTCSession"); @@ -144,6 +145,21 @@ export class MatrixRTCSession extends TypedEventEmitter | undefined; + private currentEncryptionKeyIndex = -1; + + public statistics = { + counters: { + toDeviceEncryptionKeysSent: 0, + toDeviceEncryptionKeysReceived: 0, + roomEventEncryptionKeysSent: 0, + roomEventEncryptionKeysReceived: 0, + }, + totals: { + toDeviceEncryptionKeysReceivedTotalAge: 0, + roomEventEncryptionKeysReceivedTotalAge: 0, + }, + }; + /** * The callId (sessionId) of the call. * @@ -467,10 +483,16 @@ export class MatrixRTCSession extends TypedEventEmitter { 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); }, USE_KEY_DELAY); this.setNewKeyTimeouts.add(useKeyTimeout); } else { + if (userId === this.client.getUserId() && deviceId === this.client.getDeviceId()) { + this.currentEncryptionKeyIndex = encryptionKeyIndex; + } this.emit(MatrixRTCSessionEvent.EncryptionKeyChanged, keyBin, encryptionKeyIndex, participantId); } } @@ -480,7 +502,7 @@ export class MatrixRTCSession extends TypedEventEmitter => { + private sendEncryptionKeysEvent = async (indexToSend?: number): 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(); @@ -541,20 +563,22 @@ export class MatrixRTCSession extends TypedEventEmitter { - return { - index, - key: encodeUnpaddedBase64(key), - }; - }), - device_id: deviceId, - call_id: "", - } as EncryptionKeysEventContent); + await Promise.all([ + this.sendKeysViaRoomEvent(deviceId, myKeys), + this.sendKeysViaToDevice(deviceId, keyToSend, keyIndexToSend), + ]); logger.debug( - `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numSent=${myKeys.length}`, + `Embedded-E2EE-LOG updateEncryptionKeyEvent participantId=${userId}:${deviceId} numKeys=${myKeys.length} currentKeyIndex=${this.currentEncryptionKeyIndex} keyIndexToSend=${keyIndexToSend}`, this.encryptionKeys, ); } catch (error) { @@ -574,6 +598,93 @@ export class MatrixRTCSession extends TypedEventEmitter { + const membersRequiringRoomEvent = this.memberships.filter( + (m) => !this.isMyMembership(m) && m.keyDistributionMethod === "room_event", + ); + + if (membersRequiringRoomEvent.length === 0) { + logger.info("No members require keys via room event"); + return; + } + + logger.info( + `Sending encryption keys event for: ${membersRequiringRoomEvent.map((m) => `${m.sender}:${m.deviceId}`).join(", ")}`, + ); + + this.statistics.counters.roomEventEncryptionKeysSent += 1; + + 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); + } + + private async sendKeysViaToDevice(deviceId: string, key: Uint8Array, index: number): Promise { + const membershipsRequiringToDevice = this.memberships.filter( + (m) => !this.isMyMembership(m) && m.sender && m.keyDistributionMethod === "to_device", + ); + + if (membershipsRequiringToDevice.length === 0) { + logger.info("No members require keys via to-device event"); + return; + } + + const content: EncryptionKeysToDeviceContent = { + keys: [{ index, key: encodeUnpaddedBase64(key) }], + device_id: deviceId, + call_id: "", + room_id: this.room.roomId, + sent_ts: Date.now(), + }; + + logger.info( + `Sending encryption keys to-device batch for: ${membershipsRequiringToDevice.map(({ sender, deviceId }) => `${sender}:${deviceId}`).join(", ")}`, + ); + + this.statistics.counters.toDeviceEncryptionKeysSent += membershipsRequiringToDevice.length; + + // we don't do an instanceof due to circular dependency issues + if ("widgetApi" in this.client) { + logger.info("Sending keys via widgetApi"); + // embedded mode, getCrypto() returns null and so we make some assumptions about the underlying implementation + + const contentMap: SendToDeviceContentMap = new Map(); + + membershipsRequiringToDevice.forEach(({ sender, deviceId }) => { + if (!contentMap.has(sender!)) { + contentMap.set(sender!, new Map()); + } + + contentMap.get(sender!)!.set(deviceId, content); + }); + + await (this.client as unknown as RoomWidgetClient).sendToDeviceViaWidgetApi( + EventType.CallEncryptionKeysPrefix, + true, + contentMap, + ); + } else { + const crypto = this.client.getCrypto(); + if (!crypto) { + logger.error("No crypto instance available to send keys via to-device event"); + return; + } + + const devices = membershipsRequiringToDevice.map(({ deviceId, sender }) => ({ userId: sender!, deviceId })); + + const batch = await crypto.encryptToDeviceMessages(EventType.CallEncryptionKeysPrefix, devices, content); + + await this.client.queueToDevice(batch); + } + } + /** * Sets a timer for the soonest membership expiry */ @@ -649,6 +760,18 @@ export class MatrixRTCSession extends TypedEventEmitter { - await this.client.decryptEventIfNeeded(event); - if (event.isDecryptionFailure()) { - if (!isRetry) { - logger.warn( - `Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`, - ); - // retry after 1 second. After this we give up. - setTimeout(() => this.consumeCallEncryptionEvent(event, true), 1000); - } else { - logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); - } - return; - } else if (isRetry) { - logger.info(`Decryption succeeded for event ${event.getId()} after retry`); - } + private async consumeCallEncryptionEvent(event: MatrixEvent, roomId: string): Promise { + const room = this.client.getRoom(roomId); - if (event.getType() !== EventType.CallEncryptionKeysPrefix) return Promise.resolve(); - - const room = this.client.getRoom(event.getRoomId()); if (!room) { - logger.error(`Got room state event for unknown room ${event.getRoomId()}!`); - return Promise.resolve(); + logger.error(`Got room encryption event for unknown room ${roomId}!`); + return; } - this.getRoomSession(room).onCallEncryption(event); } + private onTimeline = (event: MatrixEvent): void => { - this.consumeCallEncryptionEvent(event); + return this.onTimelineWithRetry(event, false); + }; + + private onTimelineWithRetry = (event: MatrixEvent, isRetry = false): void => { + this.client.decryptEventIfNeeded(event).then(() => { + if (event.isDecryptionFailure()) { + if (!isRetry) { + logger.warn( + `Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`, + ); + // retry after 1 second. After this we give up. + setTimeout(() => this.onTimelineWithRetry(event, true), 1000); + } else { + logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); + } + return; + } else if (isRetry) { + logger.info(`Decryption succeeded for event ${event.getId()} after retry`); + } + + if (event.getType() !== EventType.CallEncryptionKeysPrefix) return; + + const roomId = event.getRoomId(); + if (!roomId) { + logger.error(`Got room state encryption event for unknown room ${roomId}!`); + return; + } + + this.consumeCallEncryptionEvent(event, roomId); + }); + }; + + private onToDeviceEvent = (event: MatrixEvent): void => { + return this.onToDeviceEventWithRetry(event, false); + }; + + private onToDeviceEventWithRetry = (event: MatrixEvent, isRetry: boolean): void => { + this.client.decryptEventIfNeeded(event).then(() => { + if (event.isDecryptionFailure()) { + if (!isRetry) { + logger.warn( + `Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason} will retry once only`, + ); + // retry after 1 second. After this we give up. + setTimeout(() => this.onToDeviceEventWithRetry(event, true), 1000); + } else { + logger.warn(`Decryption failed for event ${event.getId()}: ${event.decryptionFailureReason}`); + } + return; + } else if (isRetry) { + logger.info(`Decryption succeeded for event ${event.getId()} after retry`); + } + + if (event.getType() !== EventType.CallEncryptionKeysPrefix) return; + const roomId = event.getContent().room_id; + if (!roomId) { + logger.error("Got to-device event with no room_id!"); + return; + } + this.consumeCallEncryptionEvent(event, roomId); + }); }; private onRoom = (room: Room): void => { diff --git a/src/matrixrtc/types.ts b/src/matrixrtc/types.ts index 479e3436699..3f120b48a46 100644 --- a/src/matrixrtc/types.ts +++ b/src/matrixrtc/types.ts @@ -25,6 +25,11 @@ export interface EncryptionKeysEventContent { call_id: string; } +export interface EncryptionKeysToDeviceContent extends EncryptionKeysEventContent { + room_id?: string; + sent_ts: number; +} + export type CallNotifyType = "ring" | "notify"; export interface ICallNotifyContent { diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index c43b3911c26..a78b49fadf6 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -21,6 +21,7 @@ import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypt import { KnownMembership } from "../@types/membership.ts"; import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts"; import type { IEncryptedEventInfo } from "../crypto/api.ts"; +import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts"; import { MatrixEvent, MatrixEventEvent } from "../models/event.ts"; import { Room } from "../models/room.ts"; import { RoomMember } from "../models/room-member.ts"; @@ -30,7 +31,7 @@ import { DecryptionError, OnSyncCompletedData, } from "../common-crypto/CryptoBackend.ts"; -import { logger, Logger } from "../logger.ts"; +import { logger, Logger, LogSpan } from "../logger.ts"; import { IHttpOpts, MatrixHttpApi, Method } from "../http-api/index.ts"; import { RoomEncryptor } from "./RoomEncryptor.ts"; import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts"; @@ -1712,6 +1713,49 @@ export class RustCrypto extends TypedEventEmitter { return await this.olmMachine.getIdentity(new RustSdkCryptoJs.UserId(this.userId)); } + + public async encryptToDeviceMessages( + eventType: string, + devices: { userId: string; deviceId: string }[], + payload: ToDevicePayload, + ): Promise { + const logger = new LogSpan(this.logger, "encryptToDeviceMessages"); + const uniqueUsers = new Set(devices.map(({ userId }) => userId)); + + // This will ensure we have Olm sessions for all of the users' devices. + // However, we only care about some of the devices. + // So, perhaps we can optimise this later on. + await this.keyClaimManager.ensureSessionsForUsers( + logger, + Array.from(uniqueUsers).map((userId) => new RustSdkCryptoJs.UserId(userId)), + ); + const batch: ToDeviceBatch = { + batch: [], + eventType: EventType.RoomMessageEncrypted, + }; + + await Promise.all( + devices.map(async ({ userId, deviceId }) => { + const device: RustSdkCryptoJs.Device | undefined = await this.olmMachine.getDevice( + new RustSdkCryptoJs.UserId(userId), + new RustSdkCryptoJs.DeviceId(deviceId), + ); + + if (device) { + const encryptedPayload = JSON.parse(await device.encryptToDeviceEvent(eventType, payload)); + batch.batch.push({ + deviceId, + userId, + payload: encryptedPayload, + }); + } else { + this.logger.warn(`encryptToDeviceMessages: unknown device ${userId}:${deviceId}`); + } + }), + ); + + return batch; + } } class EventDecryptor { diff --git a/yarn.lock b/yarn.lock index ef354a7116e..abf2742f536 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1453,10 +1453,9 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@^7.0.0": +"@matrix-org/matrix-sdk-crypto-wasm@https://floofy.netlify.app/matrix-org-matrix-sdk-crypto-wasm-v7.0.0-to-device.tgz": version "7.0.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-7.0.0.tgz#8d6abdb9ded8656cc9e2a7909913a34bf3fc9b3a" - integrity sha512-MOencXiW/gI5MuTtCNsuojjwT5DXCrjMqv9xOslJC9h2tPdLFFFMGr58dY5Lis4DRd9MRWcgrGowUIHOqieWTA== + resolved "https://floofy.netlify.app/matrix-org-matrix-sdk-crypto-wasm-v7.0.0-to-device.tgz#cf0df87e75d8780e3756eb13dce817ae272cc071" "@matrix-org/olm@3.2.15": version "3.2.15" @@ -5794,16 +5793,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0": - version "4.2.3" - resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" - integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== - dependencies: - emoji-regex "^8.0.0" - is-fullwidth-code-point "^3.0.0" - strip-ansi "^6.0.1" - -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -5858,14 +5848,7 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1": - version "6.0.1" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" - integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== - dependencies: - ansi-regex "^5.0.1" - -strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -6397,16 +6380,7 @@ word-wrap@^1.2.5: resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.5.tgz#d2c45c6dd4fbce621a66f136cbe328afd0410b34" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": - version "7.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" - integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== - dependencies: - ansi-styles "^4.0.0" - string-width "^4.1.0" - strip-ansi "^6.0.0" - -wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==