From 41439b08757cd01afbc4d87c1fd85212a6941438 Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Jul 2025 11:52:12 +0200 Subject: [PATCH 1/3] Widgets: Use the new ClientEvent.ReceivedToDeviceMessage instead of ToDeviceEvent --- src/stores/widgets/StopGapWidget.ts | 13 +++--- .../stores/widgets/StopGapWidget-test.ts | 42 +++++++++++++++---- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 672c1b27b48..673ceac20a2 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -13,6 +13,7 @@ import { type MatrixClient, ClientEvent, RoomStateEvent, + type ReceivedToDeviceMessage, } from "matrix-js-sdk/src/matrix"; import { KnownMembership } from "matrix-js-sdk/src/types"; import { @@ -360,7 +361,7 @@ export class StopGapWidget extends EventEmitter { this.client.on(ClientEvent.Event, this.onEvent); this.client.on(MatrixEventEvent.Decrypted, this.onEventDecrypted); this.client.on(RoomStateEvent.Events, this.onStateUpdate); - this.client.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + this.client.on(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage); this.messaging.on( `action:${WidgetApiFromWidgetAction.UpdateAlwaysOnScreen}`, @@ -493,7 +494,7 @@ export class StopGapWidget extends EventEmitter { this.client.off(ClientEvent.Event, this.onEvent); this.client.off(MatrixEventEvent.Decrypted, this.onEventDecrypted); this.client.off(RoomStateEvent.Events, this.onStateUpdate); - this.client.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent); + this.client.off(ClientEvent.ReceivedToDeviceMessage, this.onToDeviceMessage); } private onEvent = (ev: MatrixEvent): void => { @@ -513,10 +514,10 @@ export class StopGapWidget extends EventEmitter { }); }; - private onToDeviceEvent = async (ev: MatrixEvent): Promise => { - await this.client.decryptEventIfNeeded(ev); - if (ev.isDecryptionFailure()) return; - await this.messaging?.feedToDevice(ev.getEffectiveEvent() as IRoomEvent, ev.isEncrypted()); + private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise => { + const { message, encryptionInfo } = payload; + // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent + await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); }; /** diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index d61070b1e4f..9ce5f102407 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -83,16 +83,42 @@ describe("StopGapWidget", () => { }); it("feeds incoming to-device messages to the widget", async () => { - const event = mkEvent({ - event: true, - type: "org.example.foo", - user: "@alice:example.org", - content: { hello: "world" }, - }); + const receivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "world", + }, + }, + encryptionInfo: null, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, false); + }); + + it("feeds incoming encrypted to-device messages to the widget", async () => { + const receivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "world", + }, + }, + encryptionInfo: { + senderVerified: false, + sender: "@alice:example.org", + senderCurve25519KeyBase64: "", + senderDevice: "ABCDEFGHI", + }, + }; - client.emit(ClientEvent.ToDeviceEvent, event); + client.emit(ClientEvent.ReceivedToDeviceMessage, receivedToDevice); await Promise.resolve(); // flush promises - expect(messaging.feedToDevice).toHaveBeenCalledWith(event.getEffectiveEvent(), false); + expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true); }); it("feeds incoming state updates to the widget", () => { From 67e84c658a2682bfa84ae9c6d524d674f791d2eb Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Jul 2025 14:47:15 +0200 Subject: [PATCH 2/3] ToDevice: Only accept encrypted to-device traffic in e2ee rooms --- src/stores/widgets/StopGapWidget.ts | 28 ++++++- .../stores/widgets/StopGapWidget-test.ts | 75 +++++++++++++++++++ 2 files changed, 101 insertions(+), 2 deletions(-) diff --git a/src/stores/widgets/StopGapWidget.ts b/src/stores/widgets/StopGapWidget.ts index 673ceac20a2..e03ae2c07c3 100644 --- a/src/stores/widgets/StopGapWidget.ts +++ b/src/stores/widgets/StopGapWidget.ts @@ -515,9 +515,33 @@ export class StopGapWidget extends EventEmitter { }; private onToDeviceMessage = async (payload: ReceivedToDeviceMessage): Promise => { + // Check if the room the widget is in is end-to-end encrypted + let acceptEncryptedTrafficOnly: boolean; + if (this.roomId && this.client.getCrypto()) { + acceptEncryptedTrafficOnly = await this.client.getCrypto()!.isEncryptionEnabledInRoom(this.roomId); + } else { + // If the widget is not in a room, default to encrypted traffic only + acceptEncryptedTrafficOnly = true; + } const { message, encryptionInfo } = payload; - // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent - await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); + + if (acceptEncryptedTrafficOnly) { + // Only pass on to-device messages that are encrypted + if (encryptionInfo != null) { + // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent + await this.messaging?.feedToDevice(message as IRoomEvent, true); + } else { + logger.warn( + `Received to-device event in clear for a widget in an e2e room (${this.roomId}), dropping.`, + ); + } + return; + } else { + // Forward to the widget. + // It is ok to send an encrypted to-device message even if the room is clear. + // TODO: Update the widget API to use a proper IToDeviceMessage instead of a IRoomEvent + await this.messaging?.feedToDevice(message as IRoomEvent, encryptionInfo != null); + } }; /** diff --git a/test/unit-tests/stores/widgets/StopGapWidget-test.ts b/test/unit-tests/stores/widgets/StopGapWidget-test.ts index 9ce5f102407..e5dfbacf014 100644 --- a/test/unit-tests/stores/widgets/StopGapWidget-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidget-test.ts @@ -121,6 +121,81 @@ describe("StopGapWidget", () => { expect(messaging.feedToDevice).toHaveBeenCalledWith(receivedToDevice.message, true); }); + it("Drop sent in clear to-device messages if room is encrypted.", async () => { + jest.spyOn(client.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(true); + + const clearReceivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "spoofed world", + }, + }, + encryptionInfo: null, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, clearReceivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).not.toHaveBeenCalled(); + + const encryptedReceivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "Hello world", + }, + }, + encryptionInfo: { + senderVerified: false, + sender: "@alice:example.org", + senderCurve25519KeyBase64: "", + senderDevice: "ABCDEFGHI", + }, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, encryptedReceivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).toHaveBeenCalledWith(encryptedReceivedToDevice.message, true); + }); + + it("Default to only encrypted traffic if there is no room.", async () => { + // Replace the widget with one that has no room + // first stop messaging to clear the previous widget + widget.stopMessaging(); + widget = new StopGapWidget({ + app: { + id: "test", + creatorUserId: "@alice:example.org", + type: "example", + url: "https://example.org?user-id=$matrix_user_id&device-id=$org.matrix.msc3819.matrix_device_id&base-url=$org.matrix.msc4039.matrix_base_url&theme=$org.matrix.msc2873.client_theme", + }, + // no room provided + userId: "@alice:example.org", + creatorUserId: "@alice:example.org", + waitForIframeLoad: true, + userWidget: false, + }); + // Start messaging without an iframe, since ClientWidgetApi is mocked + widget.startMessaging(null as unknown as HTMLIFrameElement); + + const clearReceivedToDevice = { + message: { + type: "org.example.foo", + sender: "@alice:example.org", + content: { + hello: "spoofed world", + }, + }, + encryptionInfo: null, + }; + + client.emit(ClientEvent.ReceivedToDeviceMessage, clearReceivedToDevice); + await Promise.resolve(); // flush promises + expect(messaging.feedToDevice).not.toHaveBeenCalled(); + }); + it("feeds incoming state updates to the widget", () => { const event = mkEvent({ event: true, From c8d7d789e853952210ddf713c6bdfd5875e2e71a Mon Sep 17 00:00:00 2001 From: Valere Date: Wed, 2 Jul 2025 16:21:52 +0200 Subject: [PATCH 3/3] ToDevice: Force encryption when sending for widgets in e2ee room --- src/stores/widgets/StopGapWidgetDriver.ts | 18 +++- test/test-utils/test-utils.ts | 1 + .../widgets/StopGapWidgetDriver-test.ts | 93 ++++++++++++++----- 3 files changed, 85 insertions(+), 27 deletions(-) diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 282d6f5d925..d175e18b55a 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -424,8 +424,22 @@ export class StopGapWidgetDriver extends WidgetDriver { ): Promise { const client = MatrixClientPeg.safeGet(); - if (encrypted) { - const crypto = client.getCrypto(); + const crypto = client.getCrypto(); + + let forceEncryptedTraffic: boolean; + if (crypto) { + if (this.inRoomId) { + forceEncryptedTraffic = await client.getCrypto()!.isEncryptionEnabledInRoom(this.inRoomId); + } else { + // If the widget is not in a room, we default to only encrypted traffic + forceEncryptedTraffic = true; + } + } else { + // If the client does not have crypto we default to not allowing encrypted traffic? + forceEncryptedTraffic = false; + } + + if (forceEncryptedTraffic || encrypted) { if (!crypto) throw new Error("E2EE not enabled"); // attempt to re-batch these up into a single request diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 1c147023c25..1f1a0a9fa58 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -158,6 +158,7 @@ export function createTestClient(): MatrixClient { isSecretStorageReady: jest.fn().mockResolvedValue(false), deleteKeyBackupVersion: jest.fn(), crossSignDevice: jest.fn(), + encryptToDeviceMessages: jest.fn(), }), getPushActionsForEvent: jest.fn(), diff --git a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts index 1b646498bb0..07286945b3b 100644 --- a/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts +++ b/test/unit-tests/stores/widgets/StopGapWidgetDriver-test.ts @@ -190,6 +190,22 @@ describe("StopGapWidgetDriver", () => { beforeEach(() => { driver = mkDefaultDriver(); + + mocked(client.getCrypto()!.encryptToDeviceMessages).mockImplementation( + async (eventType, devices, payload) => { + return { + eventType: "m.room.encrypted", + batch: devices.map(({ userId, deviceId }) => ({ + userId, + deviceId, + payload: { + type: "m.room.encrypted", + content: { ciphertext: "ciphertext" }, + }, + })), + }; + }, + ); }); it("sends unencrypted messages", async () => { @@ -203,25 +219,54 @@ describe("StopGapWidgetDriver", () => { }); }); - it("sends encrypted messages", async () => { - const encryptToDeviceMessages = jest - .fn() - .mockImplementation( - (eventType, recipients: { userId: string; deviceId: string }[], content: object) => ({ - eventType: "m.room.encrypted", - batch: recipients.map(({ userId, deviceId }) => ({ - userId, - deviceId, - payload: { - eventType, - content, - }, - })), - }), - ); + it("should force encrypted traffic if room is e2ee", async () => { + mocked(client.getCrypto()!.isEncryptionEnabledInRoom).mockResolvedValue(true); - MatrixClientPeg.safeGet().getCrypto()!.encryptToDeviceMessages = encryptToDeviceMessages; + // Try to send with `encrypted: false`, but it should be forced to true + await driver.sendToDevice("org.example.foo", false, contentMap); + expect(client.getCrypto()!.encryptToDeviceMessages).toHaveBeenCalled(); + expect(client.queueToDevice).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "m.room.encrypted", + }), + ); + }); + + it("Allow to send encrypted in clear room", async () => { + mocked(client.getCrypto()!.isEncryptionEnabledInRoom).mockResolvedValue(false); + await driver.sendToDevice("org.example.foo", true, contentMap); + expect(client.getCrypto()!.encryptToDeviceMessages).toHaveBeenCalled(); + expect(client.queueToDevice).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "m.room.encrypted", + }), + ); + }); + + it("Should default to encrypted traffic for non-room widgets", async () => { + const driver = new StopGapWidgetDriver( + [], + new Widget({ + id: "an_id", + creatorUserId: "@alice:example.org", + type: WidgetType.CUSTOM.preferred, + url: "https://call.element.io", + }), + WidgetKind.Account, + true, + ); + + await driver.sendToDevice("org.example.foo", false, contentMap); + expect(client.getCrypto()!.encryptToDeviceMessages).toHaveBeenCalled(); + expect(client.queueToDevice).toHaveBeenCalledWith( + expect.objectContaining({ + eventType: "m.room.encrypted", + }), + ); + }); + + it("sends encrypted messages", async () => { await driver.sendToDevice("org.example.foo", true, { "@alice:example.org": { aliceMobile: { @@ -235,14 +280,14 @@ describe("StopGapWidgetDriver", () => { }, }); - expect(encryptToDeviceMessages).toHaveBeenCalledWith( + expect(client.getCrypto()!.encryptToDeviceMessages).toHaveBeenCalledWith( "org.example.foo", [{ deviceId: "aliceMobile", userId: "@alice:example.org" }], { hello: "alice", }, ); - expect(encryptToDeviceMessages).toHaveBeenCalledWith( + expect(client.getCrypto()!.encryptToDeviceMessages).toHaveBeenCalledWith( "org.example.foo", [{ deviceId: "bobDesktop", userId: "@bob:example.org" }], { @@ -252,21 +297,19 @@ describe("StopGapWidgetDriver", () => { expect(client.queueToDevice).toHaveBeenCalledWith({ eventType: "m.room.encrypted", batch: expect.arrayContaining([ - { + expect.objectContaining({ deviceId: "aliceMobile", - payload: { content: { hello: "alice" }, eventType: "org.example.foo" }, userId: "@alice:example.org", - }, + }), ]), }); expect(client.queueToDevice).toHaveBeenCalledWith({ eventType: "m.room.encrypted", batch: expect.arrayContaining([ - { + expect.objectContaining({ deviceId: "bobDesktop", - payload: { content: { hello: "bob" }, eventType: "org.example.foo" }, userId: "@bob:example.org", - }, + }), ]), }); });