diff --git a/package.json b/package.json index c2512d462c8..d55b47585fb 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,7 @@ ], "dependencies": { "@babel/runtime": "^7.12.5", - "@matrix-org/matrix-sdk-crypto-wasm": "^15.2.0", + "@matrix-org/matrix-sdk-crypto-wasm": "^15.3.0", "another-json": "^0.2.0", "bs58": "^6.0.0", "content-type": "^1.0.4", diff --git a/spec/integ/crypto/crypto.spec.ts b/spec/integ/crypto/crypto.spec.ts index cd57e078f0b..34fd4229348 100644 --- a/spec/integ/crypto/crypto.spec.ts +++ b/spec/integ/crypto/crypto.spec.ts @@ -88,7 +88,10 @@ import { encryptMegolmEventRawPlainText, establishOlmSession, getTestOlmAccountKeys, -} from "./olm-utils"; + expectSendRoomKey, + expectSendMegolmMessageEvent, + expectEncryptedSendMessageEvent, +} from "./olm-utils.ts"; import { AccountDataAccumulator } from "../../test-utils/AccountDataAccumulator"; import { UNSIGNED_MEMBERSHIP_FIELD } from "../../../src/@types/event"; import { KnownMembership } from "../../../src/@types/membership"; @@ -104,107 +107,6 @@ afterEach(() => { jest.useRealTimers(); }); -/** - * Expect that the client shares keys with the given recipient - * - * Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it - * to establish an Olm InboundGroupSession. - * - * @param recipientUserID - the user id of the expected recipient - * - * @param recipientOlmAccount - Olm.Account for the recipient - * - * @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key - * messages with the sender. Alternatively, null, in which case we will expect a pre-key message. - * - * @returns the established inbound group session - */ -async function expectSendRoomKey( - recipientUserID: string, - recipientOlmAccount: Olm.Account, - recipientOlmSession: Olm.Session | null = null, -): Promise { - const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"]; - - function onSendRoomKey(content: any): Olm.InboundGroupSession { - const m = content.messages[recipientUserID].DEVICE_ID; - const ct = m.ciphertext[testRecipientKey]; - - if (!recipientOlmSession) { - expect(ct.type).toEqual(0); // pre-key message - recipientOlmSession = new Olm.Session(); - recipientOlmSession.create_inbound(recipientOlmAccount, ct.body); - } else { - expect(ct.type).toEqual(1); // regular message - } - - const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body)); - expect(decrypted.type).toEqual("m.room_key"); - const inboundGroupSession = new Olm.InboundGroupSession(); - inboundGroupSession.create(decrypted.content.session_key); - return inboundGroupSession; - } - return await new Promise((resolve) => { - fetchMock.putOnce( - new RegExp("/sendToDevice/m.room.encrypted/"), - (url: string, opts: RequestInit): FetchMock.MockResponse => { - const content = JSON.parse(opts.body as string); - resolve(onSendRoomKey(content)); - return {}; - }, - { - // append to the list of intercepts on this path (since we have some tests that call - // this function multiple times) - overwriteRoutes: false, - }, - ); - }); -} - -/** - * Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint. - * See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid - * @returns the content of the encrypted event - */ -function expectEncryptedSendMessage() { - return new Promise((resolve) => { - fetchMock.putOnce( - new RegExp("/send/m.room.encrypted/"), - (url, request) => { - const content = JSON.parse(request.body as string); - resolve(content); - return { event_id: "$event_id" }; - }, - // append to the list of intercepts on this path (since we have some tests that call - // this function multiple times) - { overwriteRoutes: false }, - ); - }); -} - -/** - * Expect that the client sends an encrypted event - * - * Waits for an HTTP request to send an encrypted message in the test room. - * - * @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will - * be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed. - * - * @returns The content of the successfully-decrypted event - */ -async function expectSendMegolmMessage( - inboundGroupSessionPromise: Promise, -): Promise> { - const encryptedMessageContent = await expectEncryptedSendMessage(); - - // In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now. - const inboundGroupSession = await inboundGroupSessionPromise; - - const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext); - logger.log("Decrypted received megolm message", r); - return JSON.parse(r.plaintext); -} - describe("crypto", () => { let testOlmAccount = {} as unknown as Olm.Account; let testSenderKey = ""; @@ -991,7 +893,7 @@ describe("crypto", () => { // Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt. await Promise.all([ aliceClient.sendTextMessage(ROOM_ID, "test"), - expectSendMegolmMessage(inboundGroupSessionPromise), + expectSendMegolmMessageEvent(inboundGroupSessionPromise), ]); }); @@ -1018,7 +920,7 @@ describe("crypto", () => { // Send the first message, and check we can decrypt it. await Promise.all([ aliceClient.sendTextMessage(ROOM_ID, "test"), - expectSendMegolmMessage(inboundGroupSessionPromise), + expectSendMegolmMessageEvent(inboundGroupSessionPromise), ]); // Finally the interesting part: discard the session. @@ -1026,7 +928,7 @@ describe("crypto", () => { // Now when we send the next message, we should get a *new* megolm session. const inboundGroupSessionPromise2 = expectSendRoomKey("@bob:xyz", testOlmAccount); - const p2 = expectSendMegolmMessage(inboundGroupSessionPromise2); + const p2 = expectSendMegolmMessageEvent(inboundGroupSessionPromise2); await Promise.all([aliceClient.sendTextMessage(ROOM_ID, "test2"), p2]); }); @@ -1037,7 +939,7 @@ describe("crypto", () => { */ async function sendEncryptedMessage(): Promise { const [encryptedMessage] = await Promise.all([ - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), aliceClient.sendTextMessage(ROOM_ID, "test"), ]); return encryptedMessage; @@ -1159,7 +1061,7 @@ describe("crypto", () => { let [, , encryptedMessage] = await Promise.all([ aliceClient.sendTextMessage(ROOM_ID, "test"), expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession), - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), ]); // Check that the session id exists @@ -1187,7 +1089,7 @@ describe("crypto", () => { [, , encryptedMessage] = await Promise.all([ aliceClient.sendTextMessage(ROOM_ID, "test"), expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession), - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), ]); // Check that the new session id exists @@ -1385,7 +1287,7 @@ describe("crypto", () => { const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); // and finally the megolm message - const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise); + const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise); // kick it off const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test"); @@ -1408,7 +1310,7 @@ describe("crypto", () => { const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount, p2pSession); // and finally the megolm message - const megolmMessagePromise = expectSendMegolmMessage(inboundGroupSessionPromise); + const megolmMessagePromise = expectSendMegolmMessageEvent(inboundGroupSessionPromise); // kick it off const sendPromise = aliceClient.sendTextMessage(ROOM_ID, "test"); @@ -2300,7 +2202,7 @@ describe("crypto", () => { await syncPromise(client1); // Send a message, and expect to get an `m.room.encrypted` event. - await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessage()]); + await Promise.all([client1.sendTextMessage(ROOM_ID, "test"), expectEncryptedSendMessageEvent()]); // We now replace the client, and allow the new one to resync, *without* the encryption event. client2 = await replaceClient(client1); @@ -2321,7 +2223,7 @@ describe("crypto", () => { // Send a message, and expect to get an `m.room.encrypted` event. const [, msg1Content] = await Promise.all([ client1.sendTextMessage(ROOM_ID, "test1"), - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), ]); // Replace the state with one which bumps the rotation period. This should be ignored, though it's not @@ -2340,12 +2242,12 @@ describe("crypto", () => { // use a different one. const [, msg2Content] = await Promise.all([ client1.sendTextMessage(ROOM_ID, "test2"), - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), ]); expect(msg2Content.session_id).toEqual(msg1Content.session_id); const [, msg3Content] = await Promise.all([ client1.sendTextMessage(ROOM_ID, "test3"), - expectEncryptedSendMessage(), + expectEncryptedSendMessageEvent(), ]); expect(msg3Content.session_id).not.toEqual(msg1Content.session_id); }); @@ -2357,7 +2259,7 @@ describe("crypto", () => { await syncPromise(client1); // Send a message, and expect to get an `m.room.encrypted` event. - await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessage()]); + await Promise.all([client1.sendTextMessage(ROOM_ID, "test1"), expectEncryptedSendMessageEvent()]); // We now replace the client, and allow the new one to resync with a *different* encryption event. client2 = await replaceClient(client1); diff --git a/spec/integ/crypto/olm-utils.ts b/spec/integ/crypto/olm-utils.ts index 0491bb37ce5..30ae501329c 100644 --- a/spec/integ/crypto/olm-utils.ts +++ b/spec/integ/crypto/olm-utils.ts @@ -16,6 +16,7 @@ limitations under the License. import Olm from "@matrix-org/olm"; import anotherjson from "another-json"; +import fetchMock from "fetch-mock-jest"; import { type IContent, @@ -30,6 +31,8 @@ import { type IE2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; import { type ISyncResponder } from "../../test-utils/SyncResponder"; import { syncPromise } from "../../test-utils/test-utils"; import { type KeyBackupInfo } from "../../../src/crypto-api"; +import { logger } from "../../../src/logger"; +import type FetchMock from "fetch-mock"; /** * @module @@ -302,6 +305,7 @@ export function encryptMegolmEventRawPlainText(opts: { }, type: "m.room.encrypted", unsigned: {}, + state_key: opts.plaintext.state_key ? `${opts.plaintext.type}:${opts.plaintext.state_key}` : undefined, }; } @@ -414,3 +418,148 @@ export async function establishOlmSession( await syncPromise(testClient); return p2pSession; } + +/** + * Expect that the client shares keys with the given recipient + * + * Waits for an HTTP request to send the encrypted m.room_key to-device message; decrypts it and uses it + * to establish an Olm InboundGroupSession. + * + * @param recipientUserID - the user id of the expected recipient + * + * @param recipientOlmAccount - Olm.Account for the recipient + * + * @param recipientOlmSession - an Olm.Session for the recipient, which must already have exchanged pre-key + * messages with the sender. Alternatively, null, in which case we will expect a pre-key message. + * + * @returns the established inbound group session + */ +export async function expectSendRoomKey( + recipientUserID: string, + recipientOlmAccount: Olm.Account, + recipientOlmSession: Olm.Session | null = null, +): Promise { + const testRecipientKey = JSON.parse(recipientOlmAccount.identity_keys())["curve25519"]; + + function onSendRoomKey(content: any): Olm.InboundGroupSession { + const m = content.messages[recipientUserID].DEVICE_ID; + const ct = m.ciphertext[testRecipientKey]; + + if (!recipientOlmSession) { + expect(ct.type).toEqual(0); // pre-key message + recipientOlmSession = new Olm.Session(); + recipientOlmSession.create_inbound(recipientOlmAccount, ct.body); + } else { + expect(ct.type).toEqual(1); // regular message + } + + const decrypted = JSON.parse(recipientOlmSession.decrypt(ct.type, ct.body)); + expect(decrypted.type).toEqual("m.room_key"); + const inboundGroupSession = new Olm.InboundGroupSession(); + inboundGroupSession.create(decrypted.content.session_key); + return inboundGroupSession; + } + return await new Promise((resolve) => { + fetchMock.putOnce( + new RegExp("/sendToDevice/m.room.encrypted/"), + (url: string, opts: RequestInit): FetchMock.MockResponse => { + const content = JSON.parse(opts.body as string); + resolve(onSendRoomKey(content)); + return {}; + }, + { + // append to the list of intercepts on this path (since we have some tests that call + // this function multiple times) + overwriteRoutes: false, + }, + ); + }); +} + +/** + * Return the event received on rooms/{roomId}/send/m.room.encrypted endpoint. + * See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidsendeventtypetxnid + * @returns the content of the encrypted event + */ +export function expectEncryptedSendMessageEvent() { + return new Promise((resolve) => { + fetchMock.putOnce( + new RegExp("/send/m.room.encrypted/"), + (url, request) => { + const content = JSON.parse(request.body as string); + resolve(content); + return { event_id: "$event_id" }; + }, + // append to the list of intercepts on this path (since we have some tests that call + // this function multiple times) + { overwriteRoutes: false }, + ); + }); +} + +/** + * Return the event received on rooms/{roomId}/state/m.room.encrypted/{stateKey} endpoint. + * See https://spec.matrix.org/latest/client-server-api/#put_matrixclientv3roomsroomidstateeventtypestatekey + * @returns the content of the encrypted event + */ +function expectEncryptedSendStateEvent() { + return new Promise((resolve) => { + fetchMock.putOnce( + new RegExp("/state/m.room.encrypted/"), + (url, request) => { + const content = JSON.parse(request.body as string); + resolve(content); + return { event_id: "$event_id" }; + }, + // append to the list of intercepts on this path (since we have some tests that call + // this function multiple times) + { overwriteRoutes: false }, + ); + }); +} + +/** + * Expect that the client sends an encrypted message event + * + * Waits for an HTTP request to send an encrypted message in the test room. + * + * @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will + * be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed. + * + * @returns The content of the successfully-decrypted event + */ +export async function expectSendMegolmMessageEvent( + inboundGroupSessionPromise: Promise, +): Promise> { + const encryptedMessageContent = await expectEncryptedSendMessageEvent(); + + // In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now. + const inboundGroupSession = await inboundGroupSessionPromise; + + const r: any = inboundGroupSession.decrypt(encryptedMessageContent!.ciphertext); + logger.log("Decrypted received megolm message", r); + return JSON.parse(r.plaintext); +} + +/** + * Expect that the client sends an encrypted state event + * + * Waits for an HTTP request to send an encrypted state event in the test room. + * + * @param inboundGroupSessionPromise - a promise for an Olm InboundGroupSession, which will + * be used to decrypt the event. We will wait for this to resolve once the HTTP request has been processed. + * + * @returns The content of the successfully-decrypted state event + */ +export async function expectSendMegolmStateEvent( + inboundGroupSessionPromise: Promise, +): Promise> { + const encryptedStateContent = await expectEncryptedSendStateEvent(); + + // In some of the tests, the room key is sent *after* the actual event, so we may need to wait for it now. + const inboundGroupSession = await inboundGroupSessionPromise; + + const r: any = inboundGroupSession.decrypt(encryptedStateContent!.ciphertext); + logger.log("Decrypted received megolm state event", r); + return JSON.parse(r.plaintext); +} diff --git a/spec/integ/crypto/state-events.spec.ts b/spec/integ/crypto/state-events.spec.ts new file mode 100644 index 00000000000..f863a9055c6 --- /dev/null +++ b/spec/integ/crypto/state-events.spec.ts @@ -0,0 +1,219 @@ +/* +Copyright 2025 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +import anotherjson from "another-json"; +import fetchMock from "fetch-mock-jest"; +import "fake-indexeddb/auto"; +import Olm from "@matrix-org/olm"; + +import * as testUtils from "../../test-utils/test-utils"; +import { getSyncResponse, syncPromise } from "../../test-utils/test-utils"; +import { TEST_ROOM_ID as ROOM_ID } from "../../test-utils/test-data"; +import { logger } from "../../../src/logger"; +import { createClient, PendingEventOrdering, type IStartClientOpts, type MatrixClient } from "../../../src/matrix"; +import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver"; +import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder"; +import { type ISyncResponder, SyncResponder } from "../../test-utils/SyncResponder"; +import { + createOlmAccount, + createOlmSession, + encryptGroupSessionKey, + encryptMegolmEvent, + getTestOlmAccountKeys, + expectSendRoomKey, + expectSendMegolmStateEvent, +} from "./olm-utils"; +import { mockInitialApiRequests } from "../../test-utils/mockEndpoints"; + +describe("Encrypted State Events", () => { + let testOlmAccount = {} as unknown as Olm.Account; + let testSenderKey = ""; + + /** the MatrixClient under test */ + let aliceClient: MatrixClient; + + /** an object which intercepts `/keys/upload` requests from {@link #aliceClient} to catch the uploaded keys */ + let keyReceiver: E2EKeyReceiver; + + /** an object which intercepts `/sync` requests from {@link #aliceClient} */ + let syncResponder: ISyncResponder; + + async function startClientAndAwaitFirstSync(opts: IStartClientOpts = {}): Promise { + logger.log(aliceClient.getUserId() + ": starting"); + + mockInitialApiRequests(aliceClient.getHomeserverUrl()); + + // we let the client do a very basic initial sync, which it needs before + // it will upload one-time keys. + syncResponder.sendOrQueueSyncResponse({ next_batch: 1 }); + + aliceClient.startClient({ + // set this so that we can get hold of failed events + pendingEventOrdering: PendingEventOrdering.Detached, + ...opts, + }); + + await syncPromise(aliceClient); + logger.log(aliceClient.getUserId() + ": started"); + } + + beforeEach(async () => { + fetchMock.catch(404); + fetchMock.config.warnOnFallback = false; + + const homeserverUrl = "https://alice-server.com"; + aliceClient = createClient({ + baseUrl: homeserverUrl, + userId: "@alice:localhost", + accessToken: "akjgkrgjs", + deviceId: "xzcvb", + logger: logger.getChild("aliceClient"), + enableEncryptedStateEvents: true, + }); + + keyReceiver = new E2EKeyReceiver(homeserverUrl); + syncResponder = new SyncResponder(homeserverUrl); + + await aliceClient.initRustCrypto(); + + // create a test olm device which we will use to communicate with alice. We use libolm to implement this. + testOlmAccount = await createOlmAccount(); + const testE2eKeys = JSON.parse(testOlmAccount.identity_keys()); + testSenderKey = testE2eKeys.curve25519; + }, 10000); + + afterEach(async () => { + await aliceClient.stopClient(); + await jest.runAllTimersAsync(); + fetchMock.mockReset(); + }); + + function expectAliceKeyQuery(response: any) { + fetchMock.postOnce(new RegExp("/keys/query"), (url: string, opts: RequestInit) => response, { + overwriteRoutes: false, + }); + } + + function expectAliceKeyClaim(response: any) { + fetchMock.postOnce(new RegExp("/keys/claim"), response); + } + + function getTestKeysClaimResponse(userId: string) { + testOlmAccount.generate_one_time_keys(1); + const testOneTimeKeys = JSON.parse(testOlmAccount.one_time_keys()); + testOlmAccount.mark_keys_as_published(); + + const keyId = Object.keys(testOneTimeKeys.curve25519)[0]; + const oneTimeKey: string = testOneTimeKeys.curve25519[keyId]; + const unsignedKeyResult = { key: oneTimeKey }; + const j = anotherjson.stringify(unsignedKeyResult); + const sig = testOlmAccount.sign(j); + const keyResult = { + ...unsignedKeyResult, + signatures: { [userId]: { "ed25519:DEVICE_ID": sig } }, + }; + + return { + one_time_keys: { [userId]: { DEVICE_ID: { ["signed_curve25519:" + keyId]: keyResult } } }, + failures: {}, + }; + } + + it("Should receive an encrypted state event", async () => { + expectAliceKeyQuery({ device_keys: { "@alice:localhost": {} }, failures: {} }); + await startClientAndAwaitFirstSync(); + + const p2pSession = await createOlmSession(testOlmAccount, keyReceiver); + const groupSession = new Olm.OutboundGroupSession(); + groupSession.create(); + + // make the room_key event + const roomKeyEncrypted = encryptGroupSessionKey({ + recipient: aliceClient.getUserId()!, + recipientCurve25519Key: keyReceiver.getDeviceKey(), + recipientEd25519Key: keyReceiver.getSigningKey(), + olmAccount: testOlmAccount, + p2pSession: p2pSession, + groupSession: groupSession, + room_id: ROOM_ID, + }); + + // encrypt a state event with the group session + const eventEncrypted = encryptMegolmEvent({ + senderKey: testSenderKey, + groupSession: groupSession, + room_id: ROOM_ID, + plaintext: { + type: "m.room.topic", + state_key: "", + content: { + topic: "Secret!", + }, + }, + }); + + // Alice gets both the events in a single sync + const syncResponse = { + next_batch: 1, + to_device: { + events: [roomKeyEncrypted], + }, + rooms: { + join: { + [ROOM_ID]: { timeline: { events: [eventEncrypted] } }, + }, + }, + }; + + syncResponder.sendOrQueueSyncResponse(syncResponse); + await syncPromise(aliceClient); + + const room = aliceClient.getRoom(ROOM_ID)!; + const event = room.getLiveTimeline().getEvents()[0]; + expect(event.isEncrypted()).toBe(true); + + // it probably won't be decrypted yet, because it takes a while to process the olm keys + const decryptedEvent = await testUtils.awaitDecryption(event, { waitOnDecryptionFailure: true }); + expect(decryptedEvent.getContent().topic).toEqual("Secret!"); + }); + + it("Should send an encrypted state event", async () => { + const homeserverUrl = aliceClient.getHomeserverUrl(); + const keyResponder = new E2EKeyResponder(homeserverUrl); + keyResponder.addKeyReceiver("@alice:localhost", keyReceiver); + + const testDeviceKeys = getTestOlmAccountKeys(testOlmAccount, "@bob:xyz", "DEVICE_ID"); + keyResponder.addDeviceKeys(testDeviceKeys); + + await startClientAndAwaitFirstSync(); + + // Alice shares a room with Bob + syncResponder.sendOrQueueSyncResponse(getSyncResponse(["@bob:xyz"], ROOM_ID, true)); + await syncPromise(aliceClient); + + // ... and claim one of Bob's OTKs ... + expectAliceKeyClaim(getTestKeysClaimResponse("@bob:xyz")); + + // ... and send an m.room.topic message + const inboundGroupSessionPromise = expectSendRoomKey("@bob:xyz", testOlmAccount); + + // Finally, send the message, and expect to get an `m.room.encrypted` event that we can decrypt. + await Promise.all([ + aliceClient.setRoomTopic(ROOM_ID, "Secret!"), + expectSendMegolmStateEvent(inboundGroupSessionPromise), + ]); + }); +}); diff --git a/spec/test-utils/test-utils.ts b/spec/test-utils/test-utils.ts index 01104c7b63f..5067e4dff65 100644 --- a/spec/test-utils/test-utils.ts +++ b/spec/test-utils/test-utils.ts @@ -63,7 +63,11 @@ export function syncPromise(client: MatrixClient, count = 1): Promise { * * @returns the sync response */ -export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): ISyncResponse { +export function getSyncResponse( + roomMembers: string[], + roomId = TEST_ROOM_ID, + encryptStateEvents = false, +): ISyncResponse { const roomResponse: IJoinedRoom = { summary: { "m.heroes": [], @@ -77,7 +81,8 @@ export function getSyncResponse(roomMembers: string[], roomId = TEST_ROOM_ID): I type: "m.room.encryption", state_key: "", content: { - algorithm: "m.megolm.v1.aes-sha2", + "algorithm": "m.megolm.v1.aes-sha2", + "io.element.msc3414.encrypt_state_events": encryptStateEvents, }, }), ], diff --git a/spec/unit/models/event.spec.ts b/spec/unit/models/event.spec.ts index 13e2575ec15..f32256253ab 100644 --- a/spec/unit/models/event.spec.ts +++ b/spec/unit/models/event.spec.ts @@ -16,7 +16,7 @@ limitations under the License. import { type MockedObject } from "jest-mock"; -import { MatrixEvent, MatrixEventEvent } from "../../../src/models/event"; +import { type IContent, MatrixEvent, MatrixEventEvent } from "../../../src/models/event"; import { emitPromise } from "../../test-utils/test-utils"; import { type IAnnotatedPushRule, @@ -335,6 +335,31 @@ describe("MatrixEvent", () => { } }); + describe("state key packing", () => { + it("should pack the state key during encryption", () => { + const ev = createStateEvent("$event1:server", "m.room.topic", "", { topic: "" }); + expect(ev.getStateKey()).toStrictEqual(""); + ev.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", ""); + expect(ev.getStateKey()).toStrictEqual(""); + expect(ev.getWireStateKey()).toStrictEqual("m.room.topic:"); + + const keyedEv = createStateEvent("$event2:server", "m.beacon_info", "@alice:server", {}); + expect(keyedEv.getStateKey()).toStrictEqual("@alice:server"); + keyedEv.makeEncrypted("m.room.encrypted", { ciphertext: "xyz" }, "", ""); + expect(keyedEv.getStateKey()).toStrictEqual("@alice:server"); + expect(keyedEv.getWireStateKey()).toStrictEqual("m.beacon_info:@alice:server"); + }); + + function createStateEvent(eventId: string, type: string, stateKey: string, content?: IContent): MatrixEvent { + return new MatrixEvent({ + type, + state_key: stateKey, + content, + event_id: eventId, + }); + } + }); + describe("applyVisibilityEvent", () => { it("should emit VisibilityChange if a change was made", async () => { const ev = new MatrixEvent({ diff --git a/spec/unit/rust-crypto/RoomEncryptor.spec.ts b/spec/unit/rust-crypto/RoomEncryptor.spec.ts index b61dd217d83..ea38932fccc 100644 --- a/spec/unit/rust-crypto/RoomEncryptor.spec.ts +++ b/spec/unit/rust-crypto/RoomEncryptor.spec.ts @@ -72,6 +72,7 @@ describe("RoomEncryptor", () => { body: text, msgtype: "m.text", }), + isState: () => false, makeEncrypted: jest.fn().mockReturnValue(undefined), } as unknown as Mocked; } diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 99a0decf89f..952370d4611 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -830,6 +830,7 @@ describe("RustCrypto", () => { TEST_DEVICE_ID, secretStorage, {} as CryptoCallbacks, + false, ); async function createSecretStorageKey() { diff --git a/src/@types/state_events.ts b/src/@types/state_events.ts index dd5b0d8ebab..0eb650556a8 100644 --- a/src/@types/state_events.ts +++ b/src/@types/state_events.ts @@ -105,9 +105,10 @@ export interface RoomPinnedEventsEventContent { } export interface RoomEncryptionEventContent { - algorithm: "m.megolm.v1.aes-sha2"; - rotation_period_ms?: number; - rotation_period_msgs?: number; + "algorithm": "m.megolm.v1.aes-sha2"; + "io.element.msc3414.encrypt_state_events"?: boolean; + "rotation_period_ms"?: number; + "rotation_period_msgs"?: number; } export interface RoomHistoryVisibilityEventContent { diff --git a/src/client.ts b/src/client.ts index c504e315a08..1b7f27be6fd 100644 --- a/src/client.ts +++ b/src/client.ts @@ -425,6 +425,11 @@ export interface ICreateClientOpts { */ cryptoCallbacks?: CryptoCallbacks; + /** + * Enable encrypted state events. + */ + enableEncryptedStateEvents?: boolean; + /** * Method to generate room names for empty rooms and rooms names based on membership. * Defaults to a built-in English handler with basic pluralisation. @@ -1205,6 +1210,7 @@ export class MatrixClient extends TypedEventEmitter; // XXX: Intended private, used in code. private cryptoBackend?: CryptoBackend; // one of crypto or rustCrypto + private readonly enableEncryptedStateEvents: boolean; public cryptoCallbacks: CryptoCallbacks; // XXX: Intended private, used in code. public callEventHandler?: CallEventHandler; // XXX: Intended private, used in code. public groupCallEventHandler?: GroupCallEventHandler; @@ -1363,6 +1369,7 @@ export class MatrixClient extends TypedEventEmitter { this.emit(CryptoEvent.LegacyCryptoStoreMigrationProgress, progress, total); }, + + enableEncryptedStateEvents: this.enableEncryptedStateEvents, }); rustCrypto.setSupportedVerificationMethods(this.verificationMethods); @@ -6034,6 +6043,10 @@ export class MatrixClient extends TypedEventEmitter { + if (event.isState() && !this.enableEncryptedStateEvents) { + return Promise.resolve(); + } + if (event.shouldAttemptDecryption() && this.getCrypto()) { event.attemptDecryption(this.cryptoBackend!, options); } @@ -6618,23 +6631,83 @@ export class MatrixClient extends TypedEventEmitter( + public async sendStateEvent( roomId: string, eventType: K, content: StateEvents[K], stateKey = "", opts: IRequestOpts = {}, ): Promise { + const room = this.getRoom(roomId); + const event = new MatrixEvent({ + room_id: roomId, + type: eventType, + state_key: stateKey, + // Cast safety: StateEvents[K] is a stronger bound than IContent, which has [key: string]: any + content: content as IContent, + }); + + await this.encryptStateEventIfNeeded(event, room ?? undefined); + const pathParams = { $roomId: roomId, - $eventType: eventType, - $stateKey: stateKey, + $eventType: event.getWireType(), + $stateKey: event.getWireStateKey(), }; let path = utils.encodeUri("/rooms/$roomId/state/$eventType", pathParams); if (stateKey !== undefined) { path = utils.encodeUri(path + "/$stateKey", pathParams); } - return this.http.authedRequest(Method.Put, path, undefined, content as Body, opts); + return this.http.authedRequest(Method.Put, path, undefined, event.getWireContent(), opts); + } + + private async encryptStateEventIfNeeded(event: MatrixEvent, room?: Room): Promise { + if (!this.enableEncryptedStateEvents) { + return; + } + + // If the room is unknown, we cannot encrypt for it + if (!room) return; + + if (!this.cryptoBackend && this.usingExternalCrypto) { + // The client has opted to allow sending messages to encrypted + // rooms even if the room is encrypted, and we haven't set up + // crypto. This is useful for users of matrix-org/pantalaimon + return; + } + + if (!this.cryptoBackend) { + throw new Error("This room is configured to use encryption, but your client does not support encryption."); + } + + // Check regular encryption conditions. + if (!(await this.shouldEncryptEventForRoom(event, room))) { + return; + } + + // If the crypto impl thinks we shouldn't encrypt, then we shouldn't. + // Safety: we checked the crypto impl exists above. + if (!(await this.cryptoBackend!.isStateEncryptionEnabledInRoom(room.roomId))) { + return; + } + + // Check if the event is excluded under MSC3414 + if ( + [ + "m.room.create", + "m.room.member", + "m.room.join_rules", + "m.room.power_levels", + "m.room.third_party_invite", + "m.room.history_visibility", + "m.room.guest_access", + "m.room.encryption", + ].includes(event.getType()) + ) { + return; + } + + await this.cryptoBackend.encryptEvent(event, room); } /** diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 4e26c05b799..ff55c439631 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -118,6 +118,11 @@ export interface CryptoApi { */ isEncryptionEnabledInRoom(roomId: string): Promise; + /** + * Check if we believe the given room supports encrypted state events. + */ + isStateEncryptionEnabledInRoom(roomId: string): Promise; + /** * Perform any background tasks that can be done before a message is ready to * send, in order to speed up sending of the message. diff --git a/src/models/event.ts b/src/models/event.ts index 2bc1d0dc5b1..dba134b894f 100644 --- a/src/models/event.ts +++ b/src/models/event.ts @@ -158,6 +158,7 @@ export interface IMarkedUnreadEvent { export interface IClearEvent { room_id?: string; type: string; + state_key?: string; content: Omit; unsigned?: IUnsigned; } @@ -728,11 +729,25 @@ export class MatrixEvent extends TypedEventEmitterundefined - * for message events. + * Get the event state_key if it has one. If necessary, this will perform + * string-unpacking on the state key, as per MSC3414. This will return + * undefined for message events. * @returns The event's `state_key`. */ public getStateKey(): string | undefined { + if (this.clearEvent) { + return this.clearEvent.state_key; + } + return this.event.state_key; + } + + /** + * Get the raw event state_key if it has one. This may be string-packed as per + * MSC3414 if the state event is encrypted. This will return undefined + * for message events. + * @returns The event's `state_key`. + */ + public getWireStateKey(): string | undefined { return this.event.state_key; } @@ -785,11 +800,17 @@ export class MatrixEvent extends TypedEventEmitter { logger.debug("Encrypting actual message content"); - const encryptedContent = await this.olmMachine.encryptRoomEvent( - new RoomId(this.room.roomId), - event.getType(), - JSON.stringify(event.getContent()), - ); + + const room = new RoomId(this.room.roomId); + const type = event.getType(); + const content = JSON.stringify(event.getContent()); + + let encryptedContent; + if (event.isState()) { + encryptedContent = await this.olmMachine.encryptStateEvent( + room, + type, + // Safety: we've already checked above that this is a state event, so the state key must exist. + event.getStateKey()!, + content, + ); + } else { + encryptedContent = await this.olmMachine.encryptRoomEvent(room, type, content); + } event.makeEncrypted( EventType.RoomMessageEncrypted, diff --git a/src/rust-crypto/index.ts b/src/rust-crypto/index.ts index 83572b458bf..22eb3fa5c56 100644 --- a/src/rust-crypto/index.ts +++ b/src/rust-crypto/index.ts @@ -91,6 +91,11 @@ export async function initRustCrypto(args: { * Called with (-1, -1) to mark the end of migration. */ legacyMigrationProgressListener?: (progress: number, total: number) => void; + + /** + * Whether to enable support for encrypting state events. + */ + enableEncryptedStateEvents?: boolean; }): Promise { const { logger } = args; @@ -128,6 +133,7 @@ export async function initRustCrypto(args: { args.cryptoCallbacks, storeHandle, args.legacyCryptoStore, + args.enableEncryptedStateEvents, ); storeHandle.free(); @@ -145,6 +151,7 @@ async function initOlmMachine( cryptoCallbacks: CryptoCallbacks, storeHandle: StoreHandle, legacyCryptoStore?: CryptoStore, + enableEncryptedStateEvents?: boolean, ): Promise { logger.debug("Init OlmMachine"); @@ -167,7 +174,16 @@ async function initOlmMachine( // Disable room key requests, per https://github.com/vector-im/element-web/issues/26524. olmMachine.roomKeyRequestsEnabled = false; - const rustCrypto = new RustCrypto(logger, olmMachine, http, userId, deviceId, secretStorage, cryptoCallbacks); + const rustCrypto = new RustCrypto( + logger, + olmMachine, + http, + userId, + deviceId, + secretStorage, + cryptoCallbacks, + enableEncryptedStateEvents, + ); await olmMachine.registerRoomKeyUpdatedCallback((sessions: RustSdkCryptoJs.RoomKeyInfo[]) => rustCrypto.onRoomKeysUpdated(sessions), diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 44619abc8cb..8e8ccb67e17 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -162,6 +162,9 @@ export class RustCrypto extends TypedEventEmitter { + const roomSettings: RustSdkCryptoJs.RoomSettings | undefined = await this.olmMachine.getRoomSettings( + new RustSdkCryptoJs.RoomId(roomId), + ); + return Boolean(roomSettings?.encryptStateEvents); + } + /** * Implementation of {@link CryptoApi#getOwnDeviceKeys}. */ @@ -1717,7 +1730,7 @@ export class RustCrypto extends TypedEventEmitter ev.isState())) { + await this.client.decryptEventIfNeeded(ev); + } + try { if ("org.matrix.msc4222.state_after" in joinObj) { await this.injectRoomEvents( diff --git a/yarn.lock b/yarn.lock index 6de751dfdd5..208216b0f32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1763,7 +1763,7 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@matrix-org/matrix-sdk-crypto-wasm@^15.2.0": +"@matrix-org/matrix-sdk-crypto-wasm@^15.3.0": version "15.3.0" resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-wasm/-/matrix-sdk-crypto-wasm-15.3.0.tgz#141fd041ae382b793369bcee4394b0b577bdea0c" integrity sha512-QyxHvncvkl7nf+tnn92PjQ54gMNV8hMSpiukiDgNrqF6IYwgySTlcSdkPYdw8QjZJ0NR6fnVrNzMec0OohM3wA== @@ -2314,12 +2314,7 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.21.0.tgz#58f30aec8db8212fd886835dc5969cdf47cb29f5" integrity sha512-PAL6LUuQwotLW2a8VsySDBwYMm129vFm4tMVlylzdoTybTHaAi0oBp7Ac6LhSrHHOdLM3efH+nAR6hAWoMF89A== -"@typescript-eslint/types@8.44.0", "@typescript-eslint/types@^8.44.0": - version "8.44.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924" - integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA== - -"@typescript-eslint/types@^8.41.0": +"@typescript-eslint/types@8.44.0", "@typescript-eslint/types@^8.41.0", "@typescript-eslint/types@^8.44.0": version "8.44.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.44.0.tgz#4b9154ab164a0beff22d3217ff0fdc8d10bce924" integrity sha512-ZSl2efn44VsYM0MfDQe68RKzBz75NPgLQXuGypmym6QVOWL5kegTZuZ02xRAT9T+onqvM6T8CdQk0OwYMB6ZvA==