From 161c12f5d57f6e6af5804430899da3880fb53f40 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Wed, 2 Jul 2025 10:02:23 +0200 Subject: [PATCH] crypto: Add new `ClientEvent.ReceivedToDeviceMessage` with proper `OlmEncryptionInfo` support (#4891) * crypto: Add new ClientEvent.ReceivedToDeviceMessage refactor rename ProcessedToDeviceEvent to ReceivedToDeviceEvent * fix: Restore legacy isEncrypted() for to-device messages * Update test for new preprocessToDeviceMessages API * quick fix on doc * quick update docs and renaming * review: Better doc and names for OlmEncryptionInfo * review: Remove IToDeviceMessage alias and only keep IToDeviceEvent * review: improve comments of processToDeviceMessages * review: pass up encrypted event when no crypto callbacks * review: use single payload for ReceivedToDeviceMessage * fix linter * review: minor comment update --- spec/integ/crypto/to-device-messages.spec.ts | 124 ++++++++++++++++++- spec/unit/rust-crypto/rust-crypto.spec.ts | 6 +- src/client.ts | 23 +++- src/common-crypto/CryptoBackend.ts | 8 +- src/crypto-api/index.ts | 21 ++++ src/rust-crypto/rust-crypto.ts | 56 +++++++-- src/sliding-sync-sdk.ts | 18 ++- src/sync-accumulator.ts | 20 +++ src/sync.ts | 89 +++++++------ 9 files changed, 304 insertions(+), 61 deletions(-) diff --git a/spec/integ/crypto/to-device-messages.spec.ts b/spec/integ/crypto/to-device-messages.spec.ts index 90ce5edc6..7bfa9f409 100644 --- a/spec/integ/crypto/to-device-messages.spec.ts +++ b/spec/integ/crypto/to-device-messages.spec.ts @@ -17,13 +17,22 @@ limitations under the License. import fetchMock from "fetch-mock-jest"; import "fake-indexeddb/auto"; import { IDBFactory } from "fake-indexeddb"; +import Olm from "@matrix-org/olm"; import { getSyncResponse, syncPromise } from "../../test-utils/test-utils"; -import { createClient, type MatrixClient } from "../../../src"; +import { + ClientEvent, + createClient, + type IToDeviceEvent, + type MatrixClient, + type MatrixEvent, + type ReceivedToDeviceMessage, +} from "../../../src"; import * as testData from "../../test-utils/test-data"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; import { SyncResponder } from "../../test-utils/SyncResponder"; import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { encryptOlmEvent, establishOlmSession, getTestOlmAccountKeys } from "./olm-utils.ts"; afterEach(() => { // reset fake-indexeddb after each test, to make sure we don't leak connections @@ -43,6 +52,8 @@ describe("to-device-messages", () => { /** an object which intercepts `/keys/query` requests on the test homeserver */ let e2eKeyResponder: E2EKeyResponder; + let e2eKeyReceiver: E2EKeyReceiver; + let syncResponder: SyncResponder; beforeEach( async () => { @@ -59,8 +70,8 @@ describe("to-device-messages", () => { }); e2eKeyResponder = new E2EKeyResponder(homeserverUrl); - new E2EKeyReceiver(homeserverUrl); - const syncResponder = new SyncResponder(homeserverUrl); + e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); + syncResponder = new SyncResponder(homeserverUrl); // add bob as known user syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID])); @@ -149,4 +160,111 @@ describe("to-device-messages", () => { // for future: check that bob's device can decrypt the ciphertext? }); }); + + describe("receive to-device-messages", () => { + it("Should receive decrypted to-device message via ClientEvent", async () => { + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + await Olm.init(); + const testOlmAccount = new Olm.Account(); + testOlmAccount.create(); + + const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID"); + e2eKeyResponder.addDeviceKeys(testDeviceKeys); + + await aliceClient.startClient(); + await syncPromise(aliceClient); + + syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"])); + await syncPromise(aliceClient); + + const p2pSession = await establishOlmSession(aliceClient, e2eKeyReceiver, syncResponder, testOlmAccount); + + const toDeviceEvent = encryptOlmEvent({ + sender: "@bob:xyz", + senderKey: testDeviceKeys.keys[`curve25519:DEVICE_ID`], + senderSigningKey: testDeviceKeys.keys[`ed25519:DEVICE_ID`], + p2pSession: p2pSession, + recipient: aliceClient.getUserId()!, + recipientCurve25519Key: e2eKeyReceiver.getDeviceKey(), + recipientEd25519Key: e2eKeyReceiver.getSigningKey(), + plaincontent: { + body: "foo", + }, + plaintype: "m.test.type", + }); + + const processedToDeviceResolver: PromiseWithResolvers = Promise.withResolvers(); + + aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => { + processedToDeviceResolver.resolve(payload); + }); + + const oldToDeviceResolver: PromiseWithResolvers = Promise.withResolvers(); + + aliceClient.on(ClientEvent.ToDeviceEvent, (event) => { + oldToDeviceResolver.resolve(event); + }); + + expect(toDeviceEvent.type).toBe("m.room.encrypted"); + + syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } }); + await syncPromise(aliceClient); + + const { message, encryptionInfo } = await processedToDeviceResolver.promise; + + expect(message.type).toBe("m.test.type"); + expect(message.content["body"]).toBe("foo"); + + expect(encryptionInfo).not.toBeNull(); + expect(encryptionInfo!.senderVerified).toBe(false); + expect(encryptionInfo!.sender).toBe("@bob:xyz"); + expect(encryptionInfo!.senderDevice).toBe("DEVICE_ID"); + + const oldFormat = await oldToDeviceResolver.promise; + expect(oldFormat.isEncrypted()).toBe(true); + expect(oldFormat.getType()).toBe("m.test.type"); + expect(oldFormat.getContent()["body"]).toBe("foo"); + }); + + it("Should receive clear to-device message via ClientEvent", async () => { + await aliceClient.startClient(); + await syncPromise(aliceClient); + + const toDeviceEvent: IToDeviceEvent = { + sender: "@bob:xyz", + type: "m.test.type", + content: { + body: "foo", + }, + }; + + const processedToDeviceResolver: PromiseWithResolvers = Promise.withResolvers(); + + aliceClient.on(ClientEvent.ReceivedToDeviceMessage, (payload) => { + processedToDeviceResolver.resolve(payload); + }); + + const oldToDeviceResolver: PromiseWithResolvers = Promise.withResolvers(); + + aliceClient.on(ClientEvent.ToDeviceEvent, (event) => { + oldToDeviceResolver.resolve(event); + }); + + syncResponder.sendOrQueueSyncResponse({ to_device: { events: [toDeviceEvent] } }); + await syncPromise(aliceClient); + + const { message, encryptionInfo } = await processedToDeviceResolver.promise; + + expect(message.type).toBe("m.test.type"); + expect(message.content["body"]).toBe("foo"); + + // When the message is not encrypted, we don't have the encryptionInfo. + expect(encryptionInfo).toBeNull(); + + const oldFormat = await oldToDeviceResolver.promise; + expect(oldFormat.isEncrypted()).toBe(false); + expect(oldFormat.getType()).toBe("m.test.type"); + expect(oldFormat.getContent()["body"]).toBe("foo"); + }); + }); }); diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 65804d7a1..a92021751 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -551,11 +551,11 @@ describe("RustCrypto", () => { const inputs: IToDeviceEvent[] = [ { content: { key: "value" }, type: "org.matrix.test", sender: "@alice:example.com" }, ]; - const res = await rustCrypto.preprocessToDeviceMessages(inputs); + const res = (await rustCrypto.preprocessToDeviceMessages(inputs)).map((p) => p.message); expect(res).toEqual(inputs); }); - it("should pass through bad encrypted messages", async () => { + it("should fail to process bad encrypted messages", async () => { const olmMachine: OlmMachine = rustCrypto["olmMachine"]; const keys = olmMachine.identityKeys; const inputs: IToDeviceEvent[] = [ @@ -576,7 +576,7 @@ describe("RustCrypto", () => { ]; const res = await rustCrypto.preprocessToDeviceMessages(inputs); - expect(res).toEqual(inputs); + expect(res.length).toEqual(0); }); it("emits VerificationRequestReceived on incoming m.key.verification.request", async () => { diff --git a/src/client.ts b/src/client.ts index 64dfb62ec..698079b99 100644 --- a/src/client.ts +++ b/src/client.ts @@ -87,7 +87,12 @@ import { type IIdentityServerProvider } from "./@types/IIdentityServerProvider.t import { type MatrixScheduler } from "./scheduler.ts"; import { type BeaconEvent, type BeaconEventHandlerMap } from "./models/beacon.ts"; import { type AuthDict } from "./interactive-auth.ts"; -import { type IMinimalEvent, type IRoomEvent, type IStateEvent } from "./sync-accumulator.ts"; +import { + type IMinimalEvent, + type IRoomEvent, + type IStateEvent, + type ReceivedToDeviceMessage, +} from "./sync-accumulator.ts"; import type { EventTimelineSet } from "./models/event-timeline-set.ts"; import * as ContentHelpers from "./content-helpers.ts"; import { @@ -885,7 +890,9 @@ const EVENT_ID_PREFIX = "$"; export enum ClientEvent { Sync = "sync", Event = "event", + /** @deprecated Use {@link ReceivedToDeviceMessage}. */ ToDeviceEvent = "toDeviceEvent", + ReceivedToDeviceMessage = "receivedToDeviceMessage", AccountData = "accountData", Room = "Room", DeleteRoom = "deleteRoom", @@ -1088,6 +1095,20 @@ export type ClientEventHandlerMap = { * ``` */ [ClientEvent.ToDeviceEvent]: (event: MatrixEvent) => void; + /** + * Fires whenever the SDK receives a new to-device message. + * @param payload - The message and encryptionInfo for this message (See {@link ReceivedToDeviceMessage}) which caused this event to fire. + * @example + * ``` + * matrixClient.on("receivedToDeviceMessage", function(payload){ + * const { message, encryptionInfo } = payload; + * var claimed_sender = encryptionInfo ? encryptionInfo.sender : message.sender; + * var isVerified = encryptionInfo ? encryptionInfo.verified : false; + * var type = message.type; + * }); + * ``` + */ + [ClientEvent.ReceivedToDeviceMessage]: (payload: ReceivedToDeviceMessage) => void; /** * Fires if a to-device event is received that cannot be decrypted. * Encrypted to-device events will (generally) use plain Olm encryption, diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts index 04057d56a..b81f8765d 100644 --- a/src/common-crypto/CryptoBackend.ts +++ b/src/common-crypto/CryptoBackend.ts @@ -14,7 +14,7 @@ See the License for the specific language governing permissions and limitations under the License. */ -import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts"; +import type { IDeviceLists, IToDeviceEvent, ReceivedToDeviceMessage } from "../sync-accumulator.ts"; import { type IClearEvent, type MatrixEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts"; @@ -96,9 +96,11 @@ export interface SyncCryptoCallbacks { * messages, rather than the results of any decryption attempts. * * @param events - the received to-device messages - * @returns A list of preprocessed to-device messages. + * @returns A list of preprocessed to-device messages. This will not map 1:1 to the input list, as some messages may be invalid or + * failed to decrypt, and so will be omitted from the output list. + * */ - preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise; + preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise; /** * Called by the /sync loop when one time key counts and unused fallback key details are received. diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index a52df709e..173ff3176 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -1365,6 +1365,27 @@ export interface OwnDeviceKeys { curve25519: string; } +/** + * Information about the encryption of a successfully decrypted to-device message. + */ +export interface OlmEncryptionInfo { + /** The user ID of the event sender, note this is untrusted data unless `isVerified` is true **/ + sender: string; + /** + * The device ID of the device that sent us the event. + * Note this is untrusted data unless {@link senderVerified} is true. + * If the device ID is not known, this will be `null`. + **/ + senderDevice?: string; + /** The sender device's public Curve25519 key, base64 encoded **/ + senderCurve25519KeyBase64: string; + /** + * If true, this message is guaranteed to be authentic as it is coming from a device belonging to a user that we have verified. + * This is the state at the time of decryption (the user could be verified later). + */ + senderVerified: boolean; +} + export * from "./verification.ts"; export type * from "./keybackup.ts"; export * from "./recovery-key.ts"; diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index a72a099b0..e8d17a0b1 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -19,7 +19,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm"; import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts"; import { KnownMembership } from "../@types/membership.ts"; -import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts"; +import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts"; import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts"; import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; @@ -1481,7 +1481,7 @@ export class RustCrypto extends TypedEventEmitter; unusedFallbackKeys?: Set; devices?: RustSdkCryptoJs.DeviceLists; - }): Promise { - const result = await this.olmMachine.receiveSyncChanges( + }): Promise { + return await this.olmMachine.receiveSyncChanges( events ? JSON.stringify(events) : "[]", devices, oneTimeKeysCounts, unusedFallbackKeys, ); - - return result.map((processed) => JSON.parse(processed.rawEvent)); } /** called by the sync loop to preprocess incoming to-device messages @@ -1509,22 +1507,56 @@ export class RustCrypto extends TypedEventEmitter { + public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise { // send the received to-device messages into receiveSyncChanges. We have no info on device-list changes, // one-time-keys, or fallback keys, so just pass empty data. const processed = await this.receiveSyncChanges({ events }); - // look for interesting to-device messages + const received: ReceivedToDeviceMessage[] = []; + for (const message of processed) { - if (message.type === EventType.KeyVerificationRequest) { - const sender = message.sender; - const transactionId = message.content.transaction_id; + const parsedMessage: IToDeviceEvent = JSON.parse(message.rawEvent); + + // look for interesting to-device messages + if (parsedMessage.type === EventType.KeyVerificationRequest) { + const sender = parsedMessage.sender; + const transactionId = parsedMessage.content.transaction_id; if (transactionId && sender) { this.onIncomingKeyVerificationRequest(sender, transactionId); } } + + switch (message.type) { + case RustSdkCryptoJs.ProcessedToDeviceEventType.Decrypted: { + const encryptionInfo = (message as RustSdkCryptoJs.DecryptedToDeviceEvent).encryptionInfo; + received.push({ + message: parsedMessage, + encryptionInfo: { + sender: encryptionInfo.sender.toString(), + senderDevice: encryptionInfo.senderDevice?.toString(), + senderCurve25519KeyBase64: encryptionInfo.senderCurve25519Key, + senderVerified: encryptionInfo.isSenderVerified(), + }, + }); + break; + } + case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText: { + received.push({ + message: parsedMessage, + encryptionInfo: null, + }); + break; + } + case RustSdkCryptoJs.ProcessedToDeviceEventType.UnableToDecrypt: + // ignore messages we cannot decrypt + break; + case RustSdkCryptoJs.ProcessedToDeviceEventType.Invalid: + // ignore invalid messages + break; + } } - return processed; + + return received; } /** called by the sync loop to process one time key counts and unused fallback keys diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts index ed4820085..0bae2b640 100644 --- a/src/sliding-sync-sdk.ts +++ b/src/sliding-sync-sdk.ts @@ -37,6 +37,7 @@ import { type IStateEvent, type IStrippedState, type ISyncResponse, + type ReceivedToDeviceMessage, } from "./sync-accumulator.ts"; import { MatrixError } from "./http-api/index.ts"; import { @@ -150,11 +151,20 @@ class ExtensionToDevice implements Extension { - let events = data["events"] || []; - if (events.length > 0 && this.cryptoCallbacks) { - events = await this.cryptoCallbacks.preprocessToDeviceMessages(events); + const events = data["events"] || []; + let receivedToDeviceMessages: ReceivedToDeviceMessage[]; + if (this.cryptoCallbacks) { + receivedToDeviceMessages = await this.cryptoCallbacks.preprocessToDeviceMessages(events); + } else { + receivedToDeviceMessages = events.map((rawEvent) => + // Crypto is not enabled, so we just return the events. + ({ + message: rawEvent, + encryptionInfo: null, + }), + ); } - processToDeviceMessages(events, this.client); + processToDeviceMessages(receivedToDeviceMessages, this.client); this.nextBatch = data.next_batch; } diff --git a/src/sync-accumulator.ts b/src/sync-accumulator.ts index 14633ae05..44638d077 100644 --- a/src/sync-accumulator.ts +++ b/src/sync-accumulator.ts @@ -25,6 +25,7 @@ import { type IRoomSummary } from "./models/room-summary.ts"; import { type EventType } from "./@types/event.ts"; import { UNREAD_THREAD_NOTIFICATIONS } from "./@types/sync.ts"; import { ReceiptAccumulator } from "./receipt-accumulator.ts"; +import { type OlmEncryptionInfo } from "./crypto-api/index.ts"; interface IOpts { /** @@ -134,12 +135,31 @@ interface IAccountData { events: IMinimalEvent[]; } +/** A to-device message as received from the sync. */ export interface IToDeviceEvent { content: IContent; sender: string; type: string; } +/** + * A (possibly decrypted) to-device message after it has been successfully processed by the sdk. + * + * If the message was encrypted, the `encryptionInfo` field will contain the encryption information. + * If the message was sent in clear, this field will be null. + * + * The `message` field contains the message `type`, `content`, and `sender` as if the message was sent in clear. + */ +export interface ReceivedToDeviceMessage { + /** The message type, content, and sender as if the message was sent in clear. */ + message: IToDeviceEvent; + /** + * Information about the encryption of the message. + * Will be null if the message was sent in clear + */ + encryptionInfo: OlmEncryptionInfo | null; +} + interface IToDevice { events: IToDeviceEvent[]; } diff --git a/src/sync.ts b/src/sync.ts index 3fdbb8a22..01b9b8bb6 100644 --- a/src/sync.ts +++ b/src/sync.ts @@ -44,8 +44,8 @@ import { type IInvitedRoom, type IInviteState, type IJoinedRoom, - type ILeftRoom, type IKnockedRoom, + type ILeftRoom, type IMinimalEvent, type IRoomEvent, type IStateEvent, @@ -53,13 +53,14 @@ import { type ISyncResponse, type ITimeline, type IToDeviceEvent, + type ReceivedToDeviceMessage, } from "./sync-accumulator.ts"; -import { MatrixEvent, type IEvent } from "./models/event.ts"; +import { MatrixEvent } from "./models/event.ts"; import { type MatrixError, Method } from "./http-api/index.ts"; import { type ISavedSync } from "./store/index.ts"; import { EventType } from "./@types/event.ts"; import { type IPushRules } from "./@types/PushRules.ts"; -import { RoomStateEvent, type IMarkerFoundOptions } from "./models/room-state.ts"; +import { type IMarkerFoundOptions, RoomStateEvent } from "./models/room-state.ts"; import { RoomMemberEvent } from "./models/room-member.ts"; import { BeaconEvent } from "./models/beacon.ts"; import { type IEventsResponse } from "./@types/requests.ts"; @@ -1144,13 +1145,23 @@ export class SyncApi { // handle to-device events if (data.to_device && Array.isArray(data.to_device.events) && data.to_device.events.length > 0) { - let toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps); + const toDeviceMessages: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps); + let receivedToDeviceMessages: ReceivedToDeviceMessage[]; if (this.syncOpts.cryptoCallbacks) { - toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages); + receivedToDeviceMessages = + await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages); + } else { + receivedToDeviceMessages = toDeviceMessages.map((rawEvent) => + // Crypto is not enabled, so we just return the events. + ({ + message: rawEvent, + encryptionInfo: null, + }), + ); } - processToDeviceMessages(toDeviceMessages, client); + processToDeviceMessages(receivedToDeviceMessages, client); } else { // no more to-device events: we can stop polling with a short timeout. this.catchingUp = false; @@ -1909,21 +1920,21 @@ export function _createAndReEmitRoom(client: MatrixClient, roomId: string, opts: /** * Process a list of (decrypted, where possible) received to-device events. * - * Converts the events into `MatrixEvent`s, and emits appropriate {@link ClientEvent.ToDeviceEvent} events. + * Emits the appropriate {@link ClientEvent.ReceivedToDeviceMessage} event. + * Also converts the events into `MatrixEvent`s, and emits the now deprecated {@link ClientEvent.ToDeviceEvent} events for compatibility. * */ -export function processToDeviceMessages(toDeviceMessages: IToDeviceEvent[], client: MatrixClient): void { +export function processToDeviceMessages(toDeviceMessages: ReceivedToDeviceMessage[], client: MatrixClient): void { const cancelledKeyVerificationTxns: string[] = []; toDeviceMessages - .map(mapToDeviceEvent) - .map((toDeviceEvent) => { + .map((processedMessage) => { // map is a cheap inline forEach // We want to flag m.key.verification.start events as cancelled // if there's an accompanying m.key.verification.cancel event, so // we pull out the transaction IDs from the cancellation events // so we can flag the verification events as cancelled in the loop // below. - if (toDeviceEvent.getType() === "m.key.verification.cancel") { - const txnId: string = toDeviceEvent.getContent()["transaction_id"]; + if (processedMessage.message.type === "m.key.verification.cancel") { + const txnId: string = processedMessage.message.content["transaction_id"]; if (txnId) { cancelledKeyVerificationTxns.push(txnId); } @@ -1931,32 +1942,40 @@ export function processToDeviceMessages(toDeviceMessages: IToDeviceEvent[], clie // as mentioned above, .map is a cheap inline forEach, so return // the unmodified event. - return toDeviceEvent; + return processedMessage; }) - .forEach(function (toDeviceEvent) { - const content = toDeviceEvent.getContent(); - if (toDeviceEvent.getType() == "m.room.message" && content.msgtype == "m.bad.encrypted") { - // the mapper already logged a warning. - logger.log("Ignoring undecryptable to-device event from " + toDeviceEvent.getSender()); - return; - } - - if ( - toDeviceEvent.getType() === "m.key.verification.start" || - toDeviceEvent.getType() === "m.key.verification.request" - ) { - const txnId = content["transaction_id"]; - if (cancelledKeyVerificationTxns.includes(txnId)) { - toDeviceEvent.flagCancelled(); + .forEach(function (processedEvent) { + // For backwards compatibility, we also emit the event as a `MatrixEvent` using `ClientEvent.ToDeviceEvent`. + { + const toDeviceEvent = processedEvent.message; + const content = toDeviceEvent.content; + // The message is cloned before being passed to the MatrixEvent constructor, because + // the `makeEncrypted` method will mutate the type and content properties of the original message and will interfere + // with the emitted event for `ReceivedToDeviceMessage`. + const deprecatedCompatibilityEvent = new MatrixEvent(Object.assign({}, toDeviceEvent)); + if ( + toDeviceEvent.type === "m.key.verification.start" || + toDeviceEvent.type === "m.key.verification.request" + ) { + const txnId = content["transaction_id"]; + if (cancelledKeyVerificationTxns.includes(txnId)) { + deprecatedCompatibilityEvent.flagCancelled(); + } } + if (processedEvent.encryptionInfo) { + // Restore partially the legacy behavior to detect encrypted messages. + // Now `event.isEncrypted()` will return true. + deprecatedCompatibilityEvent.makeEncrypted( + EventType.RoomMessageEncrypted, + { ciphertext: "" }, + processedEvent.encryptionInfo.senderCurve25519KeyBase64, + "", + ); + } + + client.emit(ClientEvent.ToDeviceEvent, deprecatedCompatibilityEvent); } - client.emit(ClientEvent.ToDeviceEvent, toDeviceEvent); + client.emit(ClientEvent.ReceivedToDeviceMessage, processedEvent); }); } - -function mapToDeviceEvent(plainOldJsObject: Partial): MatrixEvent { - // to-device events should not have a `room_id` property, but let's be sure - delete plainOldJsObject.room_id; - return new MatrixEvent(plainOldJsObject); -}