From f1afb051e3cc40532346e473a075059fffa89864 Mon Sep 17 00:00:00 2001
From: Valere <bill.carson@valrsoft.com>
Date: Tue, 13 May 2025 09:09:43 +0200
Subject: [PATCH] Crypto: Support recognising a clearText vs a decrypted
 to-device event

---
 spec/integ/crypto/to-device-messages.spec.ts | 463 +++++++++++++++----
 src/common-crypto/CryptoBackend.ts           |   4 +-
 src/models/event.ts                          |  13 +
 src/rust-crypto/rust-crypto.ts               |  95 ++--
 src/sliding-sync-sdk.ts                      |  12 +-
 src/sync.ts                                  |  11 +-
 6 files changed, 477 insertions(+), 121 deletions(-)

diff --git a/spec/integ/crypto/to-device-messages.spec.ts b/spec/integ/crypto/to-device-messages.spec.ts
index 90ce5edc6..840d7c436 100644
--- a/spec/integ/crypto/to-device-messages.spec.ts
+++ b/spec/integ/crypto/to-device-messages.spec.ts
@@ -19,11 +19,22 @@ import "fake-indexeddb/auto";
 import { IDBFactory } from "fake-indexeddb";
 
 import { getSyncResponse, syncPromise } from "../../test-utils/test-utils";
-import { createClient, type MatrixClient } from "../../../src";
+import {
+    ClientEvent,
+    createClient,
+    EventType,
+    type MatrixClient,
+    type MatrixEvent,
+    MemoryCryptoStore,
+    MemoryStore,
+} 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 { mockInitialApiRequests } from "../../test-utils/mockEndpoints.ts";
+import { defer } from "../../../src/utils.ts";
+import { DecryptionFailureCode } from "../../../src/crypto-api";
 
 afterEach(() => {
     // reset fake-indexeddb after each test, to make sure we don't leak connections
@@ -39,114 +50,400 @@ afterEach(() => {
  * to provide the most effective integration tests possible.
  */
 describe("to-device-messages", () => {
-    let aliceClient: MatrixClient;
+    describe("Send", () => {
+        let aliceClient: MatrixClient;
 
-    /** an object which intercepts `/keys/query` requests on the test homeserver */
-    let e2eKeyResponder: E2EKeyResponder;
+        /** an object which intercepts `/keys/query` requests on the test homeserver */
+        let e2eKeyResponder: E2EKeyResponder;
 
-    beforeEach(
-        async () => {
+        beforeEach(
+            async () => {
+                // anything that we don't have a specific matcher for silently returns a 404
+                fetchMock.catch(404);
+                fetchMock.config.warnOnFallback = false;
+
+                const homeserverUrl = "https://server.com";
+                aliceClient = createClient({
+                    baseUrl: homeserverUrl,
+                    userId: testData.TEST_USER_ID,
+                    accessToken: "akjgkrgjsalice",
+                    deviceId: testData.TEST_DEVICE_ID,
+                });
+
+                e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
+                new E2EKeyReceiver(homeserverUrl);
+                const syncResponder = new SyncResponder(homeserverUrl);
+
+                // add bob as known user
+                syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
+
+                // Silence warnings from the backup manager
+                fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
+                    status: 404,
+                    body: { errcode: "M_NOT_FOUND" },
+                });
+
+                fetchMock.get(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
+                fetchMock.get(new URL("/_matrix/client/versions/", homeserverUrl).toString(), {});
+                fetchMock.post(
+                    new URL(
+                        `/_matrix/client/v3/user/${encodeURIComponent(testData.TEST_USER_ID)}/filter`,
+                        homeserverUrl,
+                    ).toString(),
+                    { filter_id: "fid" },
+                );
+
+                await aliceClient.initRustCrypto();
+            },
+            /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
+            10000,
+        );
+
+        afterEach(async () => {
+            aliceClient.stopClient();
+            fetchMock.mockReset();
+        });
+
+        describe("encryptToDeviceMessages", () => {
+            it("returns empty batch for device that is not known", async () => {
+                await aliceClient.startClient();
+
+                const toDeviceBatch = await aliceClient
+                    .getCrypto()
+                    ?.encryptToDeviceMessages(
+                        "m.test.event",
+                        [{ userId: testData.BOB_TEST_USER_ID, deviceId: testData.BOB_TEST_DEVICE_ID }],
+                        {
+                            some: "content",
+                        },
+                    );
+
+                expect(toDeviceBatch).toBeDefined();
+                const { batch, eventType } = toDeviceBatch!;
+                expect(eventType).toBe("m.room.encrypted");
+                expect(batch.length).toBe(0);
+            });
+
+            it("returns encrypted batch for known device", async () => {
+                await aliceClient.startClient();
+                e2eKeyResponder.addDeviceKeys(testData.BOB_SIGNED_TEST_DEVICE_DATA);
+                fetchMock.post("express:/_matrix/client/v3/keys/claim", () => ({
+                    one_time_keys: testData.BOB_ONE_TIME_KEYS,
+                }));
+                await syncPromise(aliceClient);
+
+                const toDeviceBatch = await aliceClient
+                    .getCrypto()
+                    ?.encryptToDeviceMessages(
+                        "m.test.event",
+                        [{ userId: testData.BOB_TEST_USER_ID, deviceId: testData.BOB_TEST_DEVICE_ID }],
+                        {
+                            some: "content",
+                        },
+                    );
+
+                expect(toDeviceBatch?.batch.length).toBe(1);
+                expect(toDeviceBatch?.eventType).toBe("m.room.encrypted");
+                const { deviceId, payload, userId } = toDeviceBatch!.batch[0];
+                expect(deviceId).toBe(testData.BOB_TEST_DEVICE_ID);
+                expect(userId).toBe(testData.BOB_TEST_USER_ID);
+                expect(payload.algorithm).toBe("m.olm.v1.curve25519-aes-sha2");
+                expect(payload.sender_key).toEqual(expect.any(String));
+                expect(payload.ciphertext).toEqual(
+                    expect.objectContaining({
+                        [testData.BOB_SIGNED_TEST_DEVICE_DATA.keys[`curve25519:${testData.BOB_TEST_DEVICE_ID}`]]: {
+                            body: expect.any(String),
+                            type: 0,
+                        },
+                    }),
+                );
+
+                // for future: check that bob's device can decrypt the ciphertext?
+            });
+        });
+    });
+
+    describe("Receive", () => {
+        beforeEach(async () => {
+            fetchMock.mockReset();
             // anything that we don't have a specific matcher for silently returns a 404
             fetchMock.catch(404);
             fetchMock.config.warnOnFallback = false;
+        });
 
-            const homeserverUrl = "https://server.com";
-            aliceClient = createClient({
-                baseUrl: homeserverUrl,
-                userId: testData.TEST_USER_ID,
-                accessToken: "akjgkrgjsalice",
-                deviceId: testData.TEST_DEVICE_ID,
-            });
+        it("Receive encrypted", async () => {
+            const aliceHomeserverUrl = "https://alice.server.com";
+            const aliceCryptoStore = new MemoryCryptoStore();
 
-            e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
-            new E2EKeyReceiver(homeserverUrl);
-            const syncResponder = new SyncResponder(homeserverUrl);
+            const aliceClient = createClient({
+                baseUrl: aliceHomeserverUrl,
+                userId: "@alice:localhost",
+                accessToken: "T11",
+                deviceId: "alice_device",
+                store: new MemoryStore(),
+                cryptoStore: aliceCryptoStore,
+            });
 
-            // add bob as known user
-            syncResponder.sendOrQueueSyncResponse(getSyncResponse([testData.BOB_TEST_USER_ID]));
+            const bobHomeserverUrl = "https://bob.server.com";
+            const bobCryptoStore = new MemoryCryptoStore();
 
-            // Silence warnings from the backup manager
-            fetchMock.getOnce(new URL("/_matrix/client/v3/room_keys/version", homeserverUrl).toString(), {
-                status: 404,
-                body: { errcode: "M_NOT_FOUND" },
+            const bobClient = createClient({
+                baseUrl: bobHomeserverUrl,
+                userId: "@bob:localhost",
+                accessToken: "T22",
+                deviceId: "bob_device",
+                // store: new MemoryStore(),
+                cryptoStore: bobCryptoStore,
             });
 
-            fetchMock.get(new URL("/_matrix/client/v3/pushrules/", homeserverUrl).toString(), {});
-            fetchMock.get(new URL("/_matrix/client/versions/", homeserverUrl).toString(), {});
-            fetchMock.post(
-                new URL(
-                    `/_matrix/client/v3/user/${encodeURIComponent(testData.TEST_USER_ID)}/filter`,
-                    homeserverUrl,
-                ).toString(),
-                { filter_id: "fid" },
-            );
-
-            await aliceClient.initRustCrypto();
-        },
-        /* it can take a while to initialise the crypto library on the first pass, so bump up the timeout. */
-        10000,
-    );
-
-    afterEach(async () => {
-        aliceClient.stopClient();
-        fetchMock.mockReset();
-    });
+            mockInitialApiRequests(aliceClient.getHomeserverUrl(), "@alice:localhost");
+            mockInitialApiRequests(bobClient.getHomeserverUrl(), "@bob:localhost");
+
+            const aliceSyncResponder = new SyncResponder(aliceHomeserverUrl);
+            const bobSyncResponder = new SyncResponder(bobHomeserverUrl);
 
-    describe("encryptToDeviceMessages", () => {
-        it("returns empty batch for device that is not known", async () => {
+            const aliceE2eKeyResponder = new E2EKeyResponder(aliceHomeserverUrl);
+            const aliceE2eKeyReceiver = new E2EKeyReceiver(aliceHomeserverUrl);
+
+            const bobE2eKeyResponder = new E2EKeyResponder(bobHomeserverUrl);
+            const bobE2eKeyReceiver = new E2EKeyReceiver(bobHomeserverUrl);
+
+            aliceE2eKeyResponder.addKeyReceiver("@bob:localhost", bobE2eKeyReceiver);
+            bobE2eKeyResponder.addKeyReceiver("@alice:localhost", aliceE2eKeyReceiver);
+
+            await aliceClient.initRustCrypto({ useIndexedDB: false });
+            await bobClient.initRustCrypto({ useIndexedDB: false });
+
+            // INITIAL SYNCS
+            aliceSyncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
             await aliceClient.startClient();
+            await syncPromise(aliceClient);
+
+            bobSyncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
+            await bobClient.startClient();
+            await syncPromise(bobClient);
+
+            // Make alice and bob know each other
+            aliceSyncResponder.sendOrQueueSyncResponse(getSyncResponse(["@alice:localhost", "@bob:localhost"]));
+            await syncPromise(aliceClient);
+            bobSyncResponder.sendOrQueueSyncResponse(getSyncResponse(["@alice:localhost", "@bob:localhost"]));
+            await syncPromise(bobClient);
+
+            {
+                const aliceBobDevices = await aliceClient.getCrypto()!.getUserDeviceInfo(["@bob:localhost"]);
+                const aliceBobDevice = aliceBobDevices.get("@bob:localhost")?.get("bob_device");
+                expect(aliceBobDevice).toBeDefined();
+            }
+            {
+                const bobAliceDevices = await bobClient.getCrypto()!.getUserDeviceInfo(["@alice:localhost"]);
+                const bobAliceDevice = bobAliceDevices.get("@alice:localhost")?.get("alice_device");
+                expect(bobAliceDevice).toBeDefined();
+            }
+
+            const keys = await bobE2eKeyReceiver.awaitOneTimeKeyUpload();
+            const otkId = Object.keys(keys)[0];
+            const otk = keys[otkId];
+
+            fetchMock.post("https://alice.server.com/_matrix/client/v3/keys/claim", () => ({
+                one_time_keys: {
+                    "@bob:localhost": {
+                        bob_device: {
+                            [otkId]: otk,
+                        },
+                    },
+                },
+            }));
 
             const toDeviceBatch = await aliceClient
                 .getCrypto()
-                ?.encryptToDeviceMessages(
-                    "m.test.event",
-                    [{ userId: testData.BOB_TEST_USER_ID, deviceId: testData.BOB_TEST_DEVICE_ID }],
-                    {
-                        some: "content",
-                    },
-                );
+                ?.encryptToDeviceMessages("m.test.event", [{ userId: "@bob:localhost", deviceId: "bob_device" }], {
+                    some: "Hello",
+                });
 
-            expect(toDeviceBatch).toBeDefined();
-            const { batch, eventType } = toDeviceBatch!;
-            expect(eventType).toBe("m.room.encrypted");
-            expect(batch.length).toBe(0);
+            expect(toDeviceBatch!.batch.length).toBe(1);
+            const first = toDeviceBatch!.batch[0];
+
+            const decryptedToDeviceDefer = defer<MatrixEvent>();
+            bobClient.on(ClientEvent.ToDeviceEvent, (event) => {
+                decryptedToDeviceDefer.resolve(event);
+            });
+
+            // Feed that back to bob
+            const syncedToDeviceEvent = {
+                type: EventType.RoomMessageEncrypted,
+                content: first.payload,
+                sender: "@alice:localhost",
+            };
+
+            bobSyncResponder.sendOrQueueSyncResponse({
+                next_batch: 2,
+                to_device: {
+                    events: [syncedToDeviceEvent],
+                },
+            });
+
+            const event = await decryptedToDeviceDefer.promise;
+
+            expect(event.getType()).toEqual("m.test.event");
+            expect(event.getWireType()).toEqual("m.room.encrypted");
+            expect(event.getClearContent()?.some).toEqual("Hello");
+            expect(event.isEncrypted()).toBe(true);
+            expect(event.isDecryptionFailure()).toBe(false);
+
+            aliceClient.stopClient();
+            bobClient.stopClient();
         });
 
-        it("returns encrypted batch for known device", async () => {
+        it("Receive a plain text to device", async () => {
+            const aliceHomeserverUrl = "https://alice.server.com";
+            const aliceCryptoStore = new MemoryCryptoStore();
+
+            const aliceClient = createClient({
+                baseUrl: aliceHomeserverUrl,
+                userId: "@alice:localhost",
+                accessToken: "T11",
+                deviceId: "alice_device",
+                store: new MemoryStore(),
+                cryptoStore: aliceCryptoStore,
+            });
+
+            const aliceSyncResponder = new SyncResponder(aliceHomeserverUrl);
+            mockInitialApiRequests(aliceClient.getHomeserverUrl(), "@alice:localhost");
+
+            // INITIAL SYNCS
+            aliceSyncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
             await aliceClient.startClient();
-            e2eKeyResponder.addDeviceKeys(testData.BOB_SIGNED_TEST_DEVICE_DATA);
-            fetchMock.post("express:/_matrix/client/v3/keys/claim", () => ({
-                one_time_keys: testData.BOB_ONE_TIME_KEYS,
-            }));
             await syncPromise(aliceClient);
 
-            const toDeviceBatch = await aliceClient
-                .getCrypto()
-                ?.encryptToDeviceMessages(
-                    "m.test.event",
-                    [{ userId: testData.BOB_TEST_USER_ID, deviceId: testData.BOB_TEST_DEVICE_ID }],
-                    {
-                        some: "content",
+            const receivedToDeviceDefer = defer<MatrixEvent>();
+            aliceClient.on(ClientEvent.ToDeviceEvent, (event) => {
+                receivedToDeviceDefer.resolve(event);
+            });
+
+            const syncedToDeviceEvent = {
+                type: "m.test.event",
+                content: {
+                    some: "Hello",
+                },
+                sender: "@alice:localhost",
+            };
+
+            aliceSyncResponder.sendOrQueueSyncResponse({
+                next_batch: 1,
+                to_device: {
+                    events: [syncedToDeviceEvent],
+                },
+            });
+
+            const receivedEvent = await receivedToDeviceDefer.promise;
+            expect(receivedEvent.getType()).toEqual("m.test.event");
+            expect(receivedEvent.getWireType()).toEqual("m.test.event");
+            expect(receivedEvent.getClearContent()).toBe(null);
+            expect(receivedEvent.getContent()?.some).toEqual("Hello");
+            expect(receivedEvent.isEncrypted()).toBe(false);
+            expect(receivedEvent.isDecryptionFailure()).toBe(false);
+
+            aliceClient.stopClient();
+        });
+
+        it("Receive a UTD to device", async () => {
+            const aliceHomeserverUrl = "https://alice.server.com";
+            const aliceCryptoStore = new MemoryCryptoStore();
+
+            const aliceClient = createClient({
+                baseUrl: aliceHomeserverUrl,
+                userId: "@alice:localhost",
+                accessToken: "T11",
+                deviceId: "alice_device",
+                store: new MemoryStore(),
+                cryptoStore: aliceCryptoStore,
+            });
+
+            const aliceE2eKeyReceiver = new E2EKeyReceiver(aliceHomeserverUrl);
+
+            const aliceSyncResponder = new SyncResponder(aliceHomeserverUrl);
+            mockInitialApiRequests(aliceClient.getHomeserverUrl(), "@alice:localhost");
+
+            // INITIAL SYNCS
+            await aliceClient.initRustCrypto({ useIndexedDB: false });
+            aliceSyncResponder.sendOrQueueSyncResponse({ next_batch: 1 });
+            await aliceClient.startClient();
+            await syncPromise(aliceClient);
+
+            const receivedToDeviceDefer = defer<MatrixEvent>();
+            aliceClient.once(ClientEvent.ToDeviceEvent, (event) => {
+                receivedToDeviceDefer.resolve(event);
+            });
+
+            const syncedToDeviceEvent = {
+                content: {
+                    algorithm: "m.olm.v1.curve25519-aes-sha2",
+                    ciphertext: {
+                        [aliceE2eKeyReceiver.getDeviceKey()]: {
+                            // this payload is just captured from a sync of some other element web with other users
+                            body: "Awogjvpx458CGhuo77HX/+tp1sxgRoCi7iAlzMvfrpbWoREQAiKACysX/p+ojr5QitCi9WRXNyamW2v2LTvoyWKtVaA2oHnYGR5s5RYhDfnIgh5MMSqqKlAbfqLvrbLovTYcKagCBbFnbA43f6zYM44buGgy8q70hMVH5WP6aK1E9Z3DVZ+8PnXQGpsrxvz2IsL6w0Nzl/qUyBEQFcgkjoDPawbsZRCllMgq2LQUyqlun6IgDTCozqsfxhDWpdfYGde4z16m34Ang7f5pH+BmPrFs6E1AO5+UbhhhS6NwWlfEtA6/9yfMxWLz1d2OrLh+QG7lYFAU9/CzIoPxaHKKr4JxgL9CjsmUPyDymWOWHP0jLi1NwpOv6hGpx0FgM7jJIMk6gWGgC5rEgEeTIwdrJh3F9OKTNSva5hvD9LomGk6tZgzQG6oap1e3wiOUyTt6S7BlyMppIu3RlIiNihZ9e17JEGiGDXOXzMJ6ISAgvGVgTP7+EvyEt2Wt4du7uBo/UvljRvVNu3I8tfItizPAOlvz460+aBDxk+sflJWt7OnhiyPnOCfopb+1RzqKVCnnPyVaP2f4BPf8qpn/f5YZk+5jJgBrGPiHzzmb3sQ5pC470s6+U3MpVFlFTG/xPBtMRMwPsbKoHfnRPqIqGu5dQ1Sw7T6taDXWjP450TvjxgHK5t2z1rLA2SXzAB1P8xbi6YXqQwxL6PvMNHn/TM0jiIQHYuqg5/RKLyhHybfP8JAjgNBw9z16wfKR/YoYFr7c+S4McQaMNa8v2SxGzhpCC3duAoK2qCWLEkYRO5cMCsGm/9bf8Q+//OykygBU/hdkT1eHUbexgALPLdfhzduutU7pbChg4T7SH7euh/3NLmS/SQvkmPfm3ckbh/Vlcj9CsXws/7MX/VJbhpbyzgBNtMnbG6tAeAofMa6Go/yMgiNBZIhLpAm31iUbUhaGm2IIlF/lsmSYEiBPoSVfFU44tetX2I/PBDGiBlzyU+yC2TOEBwMGxBE3WHbIe5/7sKW8xJF9t+HBfxIyW1QRtY3EKdEcuVOTyMxYzq3L5OKOOtPDHObYiiXg00mAgdQqgfkEAIfoRCOa2NYfTedwwo0S77eQ1sPvW5Hhf+Cm+bLibkWzaYHEZF+vyE9/Tn0tZGtH07RXfUyhp1vtTH49OBZHGkb/r+L8OjYJTST1dDCGqeGXO3uwYjoWHXtezLVHYgL+UOwcLJfMF5s9DQiqcfYXzp2kEWGsaetBFXcUWqq4RMHqlr6QfbxyuYLlQzc/AYA/MrT3J6nDpNLcvozH3RcIs8NcKcjdtjvgL0QGThy3RcecJQEDx3STrkkePL3dlyFCtVsmtQ0vjBBCxUgdySfxiobGGnpezZYi7q+Xz61GOZ9QqYmkcZOPzfNWeqtmzB7gqlH1gkFsK2yMAzKq2XCDFHvA7YAT3yMGiY06FcQ+2jyg7Bk2Q+AvjTG8hlPlmt6BZfW5cz1qx1apQn1qHXHrgfWcI52rApYQlNPOU1Uc8kZ8Ee6XUhhXBGY1rvZiKjKFG0PPuS8xo4/P7/u+gH5gItmEVDFL6giYPFsPpqAQkUN7hFoGiVZEjO4PwrLOmydsEcNOfACqrnUs08FQtvPg0sjHnxh6nh6FUQv93ukKl6+c9d+pCsN2xukrQ7Dog3nrjFZ6PrS5J0k9rDAOwTB55sfGXPZ2rATOK1WS4XcpsCtqwnYm4sGNc8ALMQkQ97zCnw8TcQwLvdUMlfbqQ5ykDQpQD68fITEDDHmBAeTCjpC713E6AhvOMwTJvjhd7hSkeOTRTmn9zXIVGNo1jSr8u0xO9uLGeWsV0+UlRLgp7/nsgfermjwNN8wj6MW3DHGS8UzzYfe9TGCeywqqIUTqgfXY48leGgB7twh4cl4jcOQniLATTvigIAQIvq/Uv8L45BGnkpKTdQ5F73gehXdVA",
+                            type: 1,
+                        },
                     },
-                );
+                    sender_key: "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
+                },
+                type: "m.room.encrypted",
+                sender: "@bob:example.org",
+            };
+
+            aliceSyncResponder.sendOrQueueSyncResponse({
+                next_batch: 1,
+                to_device: {
+                    events: [syncedToDeviceEvent],
+                },
+            });
+            await syncPromise(aliceClient);
 
-            expect(toDeviceBatch?.batch.length).toBe(1);
-            expect(toDeviceBatch?.eventType).toBe("m.room.encrypted");
-            const { deviceId, payload, userId } = toDeviceBatch!.batch[0];
-            expect(deviceId).toBe(testData.BOB_TEST_DEVICE_ID);
-            expect(userId).toBe(testData.BOB_TEST_USER_ID);
-            expect(payload.algorithm).toBe("m.olm.v1.curve25519-aes-sha2");
-            expect(payload.sender_key).toEqual(expect.any(String));
-            expect(payload.ciphertext).toEqual(
-                expect.objectContaining({
-                    [testData.BOB_SIGNED_TEST_DEVICE_DATA.keys[`curve25519:${testData.BOB_TEST_DEVICE_ID}`]]: {
-                        body: expect.any(String),
-                        type: 0,
+            const receivedEvent = await receivedToDeviceDefer.promise;
+            expect(receivedEvent.isEncrypted()).toBe(true);
+            expect(receivedEvent.isDecryptionFailure()).toBe(true);
+            expect(receivedEvent.getType()).toEqual("m.room.encrypted");
+            expect(receivedEvent.getWireType()).toEqual("m.room.encrypted");
+            expect(receivedEvent.decryptionFailureReason).toBe(DecryptionFailureCode.UNKNOWN_ERROR);
+
+            // Test an invalid event (no algorithm)
+            {
+                const receivedToDeviceDefer = Promise.withResolvers<MatrixEvent>();
+                aliceClient.once(ClientEvent.ToDeviceEvent, (event) => {
+                    receivedToDeviceDefer.resolve(event);
+                });
+
+                const syncedToDeviceEvent = {
+                    content: {
+                        // algorithm: "m.olm.v1.curve25519-aes-sha2",
+                        ciphertext: {
+                            [aliceE2eKeyReceiver.getDeviceKey()]: {
+                                // this payload is just captured from a sync of some other element web with other users
+                                body: "Awogjvpx458CGhuo77HX/+tp1sxgRoCi7iAlzMvfrpbWoREQAiKACysX/p+ojr5QitCi9WRXNyamW2v2LTvoyWKtVaA2oHnYGR5s5RYhDfnIgh5MMSqqKlAbfqLvrbLovTYcKagCBbFnbA43f6zYM44buGgy8q70hMVH5WP6aK1E9Z3DVZ+8PnXQGpsrxvz2IsL6w0Nzl/qUyBEQFcgkjoDPawbsZRCllMgq2LQUyqlun6IgDTCozqsfxhDWpdfYGde4z16m34Ang7f5pH+BmPrFs6E1AO5+UbhhhS6NwWlfEtA6/9yfMxWLz1d2OrLh+QG7lYFAU9/CzIoPxaHKKr4JxgL9CjsmUPyDymWOWHP0jLi1NwpOv6hGpx0FgM7jJIMk6gWGgC5rEgEeTIwdrJh3F9OKTNSva5hvD9LomGk6tZgzQG6oap1e3wiOUyTt6S7BlyMppIu3RlIiNihZ9e17JEGiGDXOXzMJ6ISAgvGVgTP7+EvyEt2Wt4du7uBo/UvljRvVNu3I8tfItizPAOlvz460+aBDxk+sflJWt7OnhiyPnOCfopb+1RzqKVCnnPyVaP2f4BPf8qpn/f5YZk+5jJgBrGPiHzzmb3sQ5pC470s6+U3MpVFlFTG/xPBtMRMwPsbKoHfnRPqIqGu5dQ1Sw7T6taDXWjP450TvjxgHK5t2z1rLA2SXzAB1P8xbi6YXqQwxL6PvMNHn/TM0jiIQHYuqg5/RKLyhHybfP8JAjgNBw9z16wfKR/YoYFr7c+S4McQaMNa8v2SxGzhpCC3duAoK2qCWLEkYRO5cMCsGm/9bf8Q+//OykygBU/hdkT1eHUbexgALPLdfhzduutU7pbChg4T7SH7euh/3NLmS/SQvkmPfm3ckbh/Vlcj9CsXws/7MX/VJbhpbyzgBNtMnbG6tAeAofMa6Go/yMgiNBZIhLpAm31iUbUhaGm2IIlF/lsmSYEiBPoSVfFU44tetX2I/PBDGiBlzyU+yC2TOEBwMGxBE3WHbIe5/7sKW8xJF9t+HBfxIyW1QRtY3EKdEcuVOTyMxYzq3L5OKOOtPDHObYiiXg00mAgdQqgfkEAIfoRCOa2NYfTedwwo0S77eQ1sPvW5Hhf+Cm+bLibkWzaYHEZF+vyE9/Tn0tZGtH07RXfUyhp1vtTH49OBZHGkb/r+L8OjYJTST1dDCGqeGXO3uwYjoWHXtezLVHYgL+UOwcLJfMF5s9DQiqcfYXzp2kEWGsaetBFXcUWqq4RMHqlr6QfbxyuYLlQzc/AYA/MrT3J6nDpNLcvozH3RcIs8NcKcjdtjvgL0QGThy3RcecJQEDx3STrkkePL3dlyFCtVsmtQ0vjBBCxUgdySfxiobGGnpezZYi7q+Xz61GOZ9QqYmkcZOPzfNWeqtmzB7gqlH1gkFsK2yMAzKq2XCDFHvA7YAT3yMGiY06FcQ+2jyg7Bk2Q+AvjTG8hlPlmt6BZfW5cz1qx1apQn1qHXHrgfWcI52rApYQlNPOU1Uc8kZ8Ee6XUhhXBGY1rvZiKjKFG0PPuS8xo4/P7/u+gH5gItmEVDFL6giYPFsPpqAQkUN7hFoGiVZEjO4PwrLOmydsEcNOfACqrnUs08FQtvPg0sjHnxh6nh6FUQv93ukKl6+c9d+pCsN2xukrQ7Dog3nrjFZ6PrS5J0k9rDAOwTB55sfGXPZ2rATOK1WS4XcpsCtqwnYm4sGNc8ALMQkQ97zCnw8TcQwLvdUMlfbqQ5ykDQpQD68fITEDDHmBAeTCjpC713E6AhvOMwTJvjhd7hSkeOTRTmn9zXIVGNo1jSr8u0xO9uLGeWsV0+UlRLgp7/nsgfermjwNN8wj6MW3DHGS8UzzYfe9TGCeywqqIUTqgfXY48leGgB7twh4cl4jcOQniLATTvigIAQIvq/Uv8L45BGnkpKTdQ5F73gehXdVA",
+                                type: 1,
+                            },
+                        },
+                        sender_key: "WimPd2udAU/1S/+YBpPbmr9L+0H5H+BnAVHSwDxlPGc",
                     },
-                }),
-            );
+                    type: "m.room.encrypted",
+                    sender: "@bob:example.org",
+                };
+
+                aliceSyncResponder.sendOrQueueSyncResponse({
+                    next_batch: 2,
+                    to_device: {
+                        events: [syncedToDeviceEvent],
+                    },
+                });
+                await syncPromise(aliceClient);
+
+                const receivedEvent = await receivedToDeviceDefer.promise;
+                expect(receivedEvent.isEncrypted()).toBe(true);
+                expect(receivedEvent.isDecryptionFailure()).toBe(true);
+                expect(receivedEvent.decryptionFailureReason).toBe(DecryptionFailureCode.UNKNOWN_ERROR);
+            }
 
-            // for future: check that bob's device can decrypt the ciphertext?
+            aliceClient.stopClient();
         });
     });
 });
diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts
index 04057d56a..e2cf0a303 100644
--- a/src/common-crypto/CryptoBackend.ts
+++ b/src/common-crypto/CryptoBackend.ts
@@ -15,7 +15,7 @@ limitations under the License.
 */
 
 import type { IDeviceLists, IToDeviceEvent } from "../sync-accumulator.ts";
-import { type IClearEvent, type MatrixEvent } from "../models/event.ts";
+import { type IClearEvent, type MatrixEvent, type MatrixToDeviceEvent } from "../models/event.ts";
 import { type Room } from "../models/room.ts";
 import { type CryptoApi, type DecryptionFailureCode, type ImportRoomKeysOpts } from "../crypto-api/index.ts";
 import { type KeyBackupInfo, type KeyBackupSession } from "../crypto-api/keybackup.ts";
@@ -98,7 +98,7 @@ export interface SyncCryptoCallbacks {
      * @param events - the received to-device messages
      * @returns A list of preprocessed to-device messages.
      */
-    preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]>;
+    preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<MatrixToDeviceEvent[]>;
 
     /**
      * Called by the /sync loop when one time key counts and unused fallback key details are received.
diff --git a/src/models/event.ts b/src/models/event.ts
index f3b2c4b71..b013912e5 100644
--- a/src/models/event.ts
+++ b/src/models/event.ts
@@ -248,6 +248,8 @@ export type MatrixEventHandlerMap = {
     [MatrixEventEvent.SentinelUpdated]: () => void;
 } & Pick<ThreadEventHandlerMap, ThreadEvent.Update>;
 
+export type MatrixToDeviceEvent = MatrixEvent;
+
 export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, MatrixEventHandlerMap> {
     // applied push rule and action for this event
     private pushDetails: PushDetails = {};
@@ -792,6 +794,17 @@ export class MatrixEvent extends TypedEventEmitter<MatrixEventEmittedEvents, Mat
         this.claimedEd25519Key = claimedEd25519Key;
     }
 
+    /**
+     * Mark that event has failed to decrypt.
+     *
+     * Used for to device events, to signal that decryption was attempted but failed.
+     *
+     * @internal
+     */
+    public makeUTD(reason: DecryptionFailureCode): void {
+        this._decryptionFailureReason = reason;
+    }
+
     /**
      * Check if this event is currently being decrypted.
      *
diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts
index c989e6cfd..552b6fbb5 100644
--- a/src/rust-crypto/rust-crypto.ts
+++ b/src/rust-crypto/rust-crypto.ts
@@ -20,8 +20,8 @@ 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 { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts";
-import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts";
+import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts";
+import { MatrixEvent, MatrixEventEvent, type MatrixToDeviceEvent } from "../models/event.ts";
 import { type Room } from "../models/room.ts";
 import { type RoomMember } from "../models/room-member.ts";
 import {
@@ -37,6 +37,7 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
 import { KeyClaimManager } from "./KeyClaimManager.ts";
 import { logDuration, MapWithDefault } from "../utils.ts";
 import {
+    AllDevicesIsolationMode,
     type BackupTrustInfo,
     type BootstrapCrossSigningOpts,
     type CreateSecretStorageOpts,
@@ -45,29 +46,28 @@ import {
     type CrossSigningStatus,
     type CryptoApi,
     type CryptoCallbacks,
+    CryptoEvent,
+    type CryptoEventHandlerMap,
     DecryptionFailureCode,
+    deriveRecoveryKeyFromPassphrase,
+    type DeviceIsolationMode,
+    DeviceIsolationModeKind,
     DeviceVerificationStatus,
+    encodeRecoveryKey,
     type EventEncryptionInfo,
     EventShieldColour,
     EventShieldReason,
     type GeneratedSecretStorageKey,
     type ImportRoomKeysOpts,
+    ImportRoomKeyStage,
     type KeyBackupCheck,
     type KeyBackupInfo,
-    type OwnDeviceKeys,
-    UserVerificationStatus,
-    type VerificationRequest,
-    encodeRecoveryKey,
-    deriveRecoveryKeyFromPassphrase,
-    type DeviceIsolationMode,
-    AllDevicesIsolationMode,
-    DeviceIsolationModeKind,
-    CryptoEvent,
-    type CryptoEventHandlerMap,
     type KeyBackupRestoreOpts,
     type KeyBackupRestoreResult,
+    type OwnDeviceKeys,
     type StartDehydrationOpts,
-    ImportRoomKeyStage,
+    UserVerificationStatus,
+    type VerificationRequest,
 } from "../crypto-api/index.ts";
 import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
 import { type IDownloadKeyResult, type IQueryKeysRequest } from "../client.ts";
@@ -139,26 +139,20 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
 
     public constructor(
         private readonly logger: Logger,
-
         /** The `OlmMachine` from the underlying rust crypto sdk. */
         private readonly olmMachine: RustSdkCryptoJs.OlmMachine,
-
         /**
          * Low-level HTTP interface: used to make outgoing requests required by the rust SDK.
          *
          * We expect it to set the access token, etc.
          */
         private readonly http: MatrixHttpApi<IHttpOpts & { onlyData: true }>,
-
         /** The local user's User ID. */
         private readonly userId: string,
-
         /** The local user's Device ID. */
         _deviceId: string,
-
         /** Interface to server-side secret storage */
         private readonly secretStorage: ServerSideSecretStorage,
-
         /** Crypto callbacks provided by the application */
         private readonly cryptoCallbacks: CryptoCallbacks,
     ) {
@@ -1493,7 +1487,7 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
         oneTimeKeysCounts?: Map<string, number>;
         unusedFallbackKeys?: Set<string>;
         devices?: RustSdkCryptoJs.DeviceLists;
-    }): Promise<IToDeviceEvent[]> {
+    }): Promise<MatrixToDeviceEvent[]> {
         const result = await logDuration(logger, "receiveSyncChanges", async () => {
             return await this.olmMachine.receiveSyncChanges(
                 events ? JSON.stringify(events) : "[]",
@@ -1503,8 +1497,57 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
             );
         });
 
-        // receiveSyncChanges returns a JSON-encoded list of decrypted to-device messages.
-        return JSON.parse(result);
+        return result.map((processed) => {
+            switch (processed.type) {
+                case RustSdkCryptoJs.ProcessedToDeviceEventType.Decrypted: {
+                    const decryptedEvent = JSON.parse(processed.wireEvent);
+                    /** Make it as an encrypted event.
+                     * Re-using the existing internal `makeEncrypted`
+                     * We want here that the following `MatrixEvent` API to work as expected:
+                     * - {@link MatrixEvent#getClearContent()}
+                     * - {@link MatrixEvent#isEncrypted()}
+                     * - {@link MatrixEvent#getWireType()}
+                     * The {@link MatrixEvent#senderCurve25519Key}, {@link MatrixEvent#claimedEd25519Key} end point are not supported yet,
+                     * for that rust sdk should return some EncryptionInfo.
+                     * XXX: Work around: maybe we can use MSC4147 for now if really needed?
+                     */
+                    const ev = new MatrixEvent(decryptedEvent);
+                    ev.makeEncrypted(
+                        EventType.RoomMessageEncrypted,
+                        {
+                            algorithm: "m.olm.v1.curve25519-aes-sha2",
+                            // The original event is not accessible and this information is
+                            // lost with the rust SDK (`ciphertext`, `sender_key`)
+                            ciphertext: {},
+                            sender_key: "",
+                        },
+                        "",
+                        "",
+                    );
+                    return ev;
+                }
+                case RustSdkCryptoJs.ProcessedToDeviceEventType.UnableToDecrypt: {
+                    const ev = new MatrixEvent(JSON.parse(processed.wireEvent));
+                    // We don't have details about the failure
+                    ev.makeUTD(DecryptionFailureCode.UNKNOWN_ERROR);
+                    return ev;
+                }
+                case RustSdkCryptoJs.ProcessedToDeviceEventType.PlainText:
+                    return new MatrixEvent(JSON.parse(processed.wireEvent));
+                case RustSdkCryptoJs.ProcessedToDeviceEventType.Invalid: {
+                    const parsedEvent: IToDeviceEvent = JSON.parse(processed.wireEvent);
+                    const ev = new MatrixEvent(parsedEvent);
+                    if (parsedEvent.type == EventType.RoomMessageEncrypted) {
+                        // Consider as a failed to decrypt?
+                        // What reason to use here? the olm (BAD_ENCRYPTED?) codes are marked as deprecated
+                        ev.makeUTD(DecryptionFailureCode.UNKNOWN_ERROR);
+                        return ev;
+                    } else {
+                        return ev;
+                    }
+                }
+            }
+        });
     }
 
     /** called by the sync loop to preprocess incoming to-device messages
@@ -1512,16 +1555,16 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
      * @param events - the received to-device messages
      * @returns A list of preprocessed to-device messages.
      */
-    public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<IToDeviceEvent[]> {
+    public async preprocessToDeviceMessages(events: IToDeviceEvent[]): Promise<MatrixToDeviceEvent[]> {
         // 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
         for (const message of processed) {
-            if (message.type === EventType.KeyVerificationRequest) {
-                const sender = message.sender;
-                const transactionId = message.content.transaction_id;
+            if (message.getType() === EventType.KeyVerificationRequest) {
+                const sender = message.getSender();
+                const transactionId = message.getContent().transaction_id;
                 if (transactionId && sender) {
                     this.onIncomingKeyVerificationRequest(sender, transactionId);
                 }
diff --git a/src/sliding-sync-sdk.ts b/src/sliding-sync-sdk.ts
index 9080db1d1..bc08c87d8 100644
--- a/src/sliding-sync-sdk.ts
+++ b/src/sliding-sync-sdk.ts
@@ -30,7 +30,7 @@ import {
     type SetPresence,
     mapToDeviceEvent,
 } from "./sync.ts";
-import { type MatrixEvent } from "./models/event.ts";
+import { type MatrixEvent, type MatrixToDeviceEvent } from "./models/event.ts";
 import {
     type IMinimalEvent,
     type IRoomEvent,
@@ -151,12 +151,14 @@ class ExtensionToDevice implements Extension<ExtensionToDeviceRequest, Extension
 
     public async onResponse(data: ExtensionToDeviceResponse): Promise<void> {
         const cancelledKeyVerificationTxns: string[] = [];
-        let events = data["events"] || [];
-        if (events.length > 0 && this.cryptoCallbacks) {
-            events = await this.cryptoCallbacks.preprocessToDeviceMessages(events);
+        const rawEvents = data["events"] || [];
+        let events: MatrixToDeviceEvent[] = [];
+        if (rawEvents.length > 0 && this.cryptoCallbacks) {
+            events = await this.cryptoCallbacks.preprocessToDeviceMessages(rawEvents);
+        } else {
+            events = rawEvents.map(mapToDeviceEvent);
         }
         events
-            .map(mapToDeviceEvent)
             .map((toDeviceEvent) => {
                 // map is a cheap inline forEach
                 // We want to flag m.key.verification.start events as cancelled
diff --git a/src/sync.ts b/src/sync.ts
index 4c9a78fa6..17686a0f7 100644
--- a/src/sync.ts
+++ b/src/sync.ts
@@ -54,7 +54,7 @@ import {
     type ITimeline,
     type IToDeviceEvent,
 } from "./sync-accumulator.ts";
-import { MatrixEvent, type IEvent } from "./models/event.ts";
+import { MatrixEvent, type IEvent, type MatrixToDeviceEvent } 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";
@@ -1144,15 +1144,16 @@ 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 rawEvents: IToDeviceEvent[] = data.to_device.events.filter(noUnsafeEventProps);
+            let toDeviceMessages: MatrixToDeviceEvent[] = [];
             if (this.syncOpts.cryptoCallbacks) {
-                toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(toDeviceMessages);
+                toDeviceMessages = await this.syncOpts.cryptoCallbacks.preprocessToDeviceMessages(rawEvents);
+            } else {
+                toDeviceMessages = rawEvents.map(mapToDeviceEvent);
             }
 
             const cancelledKeyVerificationTxns: string[] = [];
             toDeviceMessages
-                .map(mapToDeviceEvent)
                 .map((toDeviceEvent) => {
                     // map is a cheap inline forEach
                     // We want to flag m.key.verification.start events as cancelled
-- 
GitLab