diff --git a/spec/integ/crypto/history-sharing.spec.ts b/spec/integ/crypto/history-sharing.spec.ts index 2d106845db0..5a942354db2 100644 --- a/spec/integ/crypto/history-sharing.spec.ts +++ b/spec/integ/crypto/history-sharing.spec.ts @@ -30,7 +30,7 @@ import { import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts"; import { SyncResponder } from "../../test-utils/SyncResponder.ts"; import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts"; -import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts"; +import { getSyncResponse, mkEventCustom, syncPromise, waitFor } from "../../test-utils/test-utils.ts"; import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts"; import { flushPromises } from "../../test-utils/flushPromises.ts"; import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts"; @@ -80,6 +80,9 @@ describe("History Sharing", () => { let bobSyncResponder: SyncResponder; beforeEach(async () => { + // Reset mocks. + fetchMock.reset(); + // anything that we don't have a specific matcher for silently returns a 404 fetchMock.catch(404); fetchMock.config.warnOnFallback = false; @@ -201,6 +204,104 @@ describe("History Sharing", () => { expect(event.getContent().body).toEqual("Hi!"); }); + test("Room keys are imported correctly if invite is accepted before the bundle arrives", async () => { + // Alice is in an encrypted room + const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID); + aliceSyncResponder.sendOrQueueSyncResponse(syncResponse); + await syncPromise(aliceClient); + + // ... and she sends an event + const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted"); + await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hello!" }); + const sentMessage = await msgProm; + debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`); + + // Now, Alice invites Bob + const uploadProm = new Promise((resolve) => { + fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => { + const body = request.body as Uint8Array; + debug(`Alice uploaded blob of length ${body.length}`); + resolve(body); + return { content_uri: "mxc://alice-server/here" }; + }); + }); + const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted"); + // POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite + fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {}); + await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true }); + const uploadedBlob = await uploadProm; + const sentToDeviceRequest = await toDeviceMessageProm; + debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`); + const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!]; + expect(bobToDeviceMessage).toBeDefined(); + + // Bob receives the room invite, but not the room key bundle + const inviteEvent = mkEventCustom({ + type: "m.room.member", + sender: aliceClient.getSafeUserId(), + state_key: bobClient.getSafeUserId(), + content: { membership: KnownMembership.Invite }, + }); + bobSyncResponder.sendOrQueueSyncResponse({ + rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } }, + }); + await syncPromise(bobClient); + + const room = bobClient.getRoom(ROOM_ID); + expect(room).toBeTruthy(); + expect(room?.getMyMembership()).toEqual(KnownMembership.Invite); + + fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, { + room_id: ROOM_ID, + }); + await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true }); + + // Bob receives and attempts to decrypt the megolm message, but should not be able to (yet). + const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID); + bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push( + mkEventCustom({ + type: "m.room.encrypted", + sender: aliceClient.getSafeUserId(), + content: sentMessage, + event_id: "$event_id", + }) as any, + ); + bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse); + await syncPromise(bobClient); + const bobRoom = bobClient.getRoom(ROOM_ID); + const event = bobRoom!.getLastLiveEvent()!; + expect(event.getId()).toEqual("$event_id"); + await event.getDecryptionPromise(); + expect(event.isDecryptionFailure()).toBeTruthy(); + + // Now the room key bundle message arrives + fetchMock.getOnce( + `begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, + { body: uploadedBlob }, + { sendAsJson: false }, + ); + bobSyncResponder.sendOrQueueSyncResponse({ + to_device: { + events: [ + { + type: "m.room.encrypted", + sender: aliceClient.getSafeUserId(), + content: bobToDeviceMessage, + }, + ], + }, + }); + await syncPromise(bobClient); + + // Once the room key bundle finishes downloading, we should be able to decrypt the message. + await waitFor(async () => { + await event.getDecryptionPromise(); + expect(event.isDecryptionFailure()).toBeFalsy(); + expect(event.getType()).toEqual("m.room.message"); + expect(event.getContent().body).toEqual("Hello!"); + }); + }); + afterEach(async () => { bobClient.stopClient(); aliceClient.stopClient(); diff --git a/src/client.ts b/src/client.ts index 84996c151b8..11da42de67e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -2408,7 +2408,12 @@ export class MatrixClient extends TypedEventEmitter; + + /** + * Mark a room as pending a key bundle under MSC4268. The backend will listen for room key bundle messages, and if + * it sees one matching the room specified, it will automatically import it as long as the message author's ID matches + * the inviter's ID. + * + * @param roomId - The room we were invited to, for which we did not receive a key bundle before accepting the invite. */ - maybeAcceptKeyBundle(roomId: string, inviter: string): Promise; + markRoomAsPendingKeyBundle(roomId: string): void; } /** The methods which crypto implementations should expose to the Sync api diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index dbb0abf7c3b..c01531cddbe 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -129,6 +129,9 @@ export class RustCrypto extends TypedEventEmitter = {}; + /** mapping of room ID -> inviter ID for rooms pending MSC4268 key bundles */ + private roomsPendingKeyBundles: Set = new Set(); + private eventDecryptor: EventDecryptor; private keyClaimManager: KeyClaimManager; private outgoingRequestProcessor: OutgoingRequestProcessor; @@ -329,7 +332,7 @@ export class RustCrypto extends TypedEventEmitter { + public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise { // TODO: retry this if it gets interrupted or it fails. (https://github.com/matrix-org/matrix-rust-sdk/issues/5112) // TODO: do this in the background. // TODO: handle the bundle message arriving after the invite (https://github.com/element-hq/element-web/issues/30740) @@ -352,7 +355,7 @@ export class RustCrypto extends TypedEventEmitter this.roomsPendingKeyBundles.has(payload.message.content.room_id))) { + const success = await this.maybeAcceptKeyBundle(payload.message.content.room_id, payload.message.sender); + if (success) { + this.roomsPendingKeyBundles.delete(payload.message.content.room_id); + } + } + return received; } @@ -2470,3 +2498,30 @@ function rustEncryptionInfoToJsEncryptionInfo( type CryptoEvents = (typeof CryptoEvent)[keyof typeof CryptoEvent]; type RustCryptoEvents = Exclude; + +interface RoomKeyBundleMessage { + message: { + type: "io.element.msc4268.room_key_bundle"; + content: { + room_id: string; + }; + }; +} + +/** + * Determines if the given payload is a RoomKeyBundleMessage. + * + * A RoomKeyBundleMessage is identified by having a specific message type + * ("io.element.msc4268.room_key_bundle") and a valid room_id in its content. + * + * @param payload - The received to-device message to check. + * @returns True if the payload matches the RoomKeyBundleMessage structure, false otherwise. + */ +function isRoomKeyBundleMessage( + payload: ReceivedToDeviceMessage, +): payload is ReceivedToDeviceMessage & RoomKeyBundleMessage { + return ( + payload.message.type === "io.element.msc4268.room_key_bundle" && + typeof payload.message.content.room_id === "string" + ); +}