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