From a3d7bc7798005d3d084a40a68e93270f0df664fe Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 11 Jul 2025 17:21:57 +0100 Subject: [PATCH 1/5] Give `MatrixClient.invite` an options param --- spec/integ/matrix-client-methods.spec.ts | 53 ++++++++++++++++++++++++ src/@types/requests.ts | 8 ++++ src/client.ts | 18 +++++--- 3 files changed, 74 insertions(+), 5 deletions(-) diff --git a/spec/integ/matrix-client-methods.spec.ts b/spec/integ/matrix-client-methods.spec.ts index f4e207b508..0ab10be43b 100644 --- a/spec/integ/matrix-client-methods.spec.ts +++ b/spec/integ/matrix-client-methods.spec.ts @@ -267,6 +267,59 @@ describe("MatrixClient", function () { }); }); + describe("invite", function () { + it("should send request to /invite", async () => { + const roomId = "!roomId:server"; + const userId = "@user:server"; + + httpBackend + .when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`) + .check((request) => { + expect(request.data).toEqual({ user_id: userId }); + }) + .respond(200, {}); + + const prom = client.invite(roomId, userId); + await httpBackend.flushAllExpected(); + await prom; + httpBackend.verifyNoOutstandingExpectation(); + }); + + it("accepts a stringy reason argument", async () => { + const roomId = "!roomId:server"; + const userId = "@user:server"; + + httpBackend + .when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`) + .check((request) => { + expect(request.data).toEqual({ user_id: userId, reason: "testreason" }); + }) + .respond(200, {}); + + const prom = client.invite(roomId, userId, "testreason"); + await httpBackend.flushAllExpected(); + await prom; + httpBackend.verifyNoOutstandingExpectation(); + }); + + it("accepts an options object with a reason", async () => { + const roomId = "!roomId:server"; + const userId = "@user:server"; + + httpBackend + .when("POST", `/rooms/${encodeURIComponent(roomId)}/invite`) + .check((request) => { + expect(request.data).toEqual({ user_id: userId, reason: "testreason" }); + }) + .respond(200, {}); + + const prom = client.invite(roomId, userId, { reason: "testreason" }); + await httpBackend.flushAllExpected(); + await prom; + httpBackend.verifyNoOutstandingExpectation(); + }); + }); + describe("knockRoom", function () { const roomId = "!some-room-id:example.org"; const reason = "some reason"; diff --git a/src/@types/requests.ts b/src/@types/requests.ts index f933012247..a618f6d936 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -43,6 +43,14 @@ export interface IJoinRoomOpts { viaServers?: string[]; } +/** Options object for {@link MatrixClient.invite}. */ +export interface InviteOpts { + /** + * The reason for the invite. + */ + reason?: string; +} + export interface KnockRoomOpts { /** * The reason for the knock. diff --git a/src/client.ts b/src/client.ts index 6ee06d4294..d2de367e2d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -115,6 +115,7 @@ import { type IGuestAccessOpts, type IJoinRoomOpts, type INotificationsResponse, + type InviteOpts, type IPaginateOpts, type IPresenceOpts, type IRedactOpts, @@ -3755,12 +3756,19 @@ export class MatrixClient extends TypedEventEmitter { - return this.membershipChange(roomId, userId, KnownMembership.Invite, reason); + public invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise { + if (typeof opts != "object") { + opts = { reason: opts }; + } + return this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason); } /** From d6a65c75aa3aafbb1d67b3a7672dc30a4f3c9f00 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 4 Jul 2025 13:33:39 +0100 Subject: [PATCH 2/5] tests: Cross-signing keys support in `E2EKeyReceiver` Have `E2EKeyReceiver` collect uploaded cross-signing keys, so that they can be returned by `E2EKeyResponder`. --- spec/integ/crypto/cross-signing.spec.ts | 15 ++++---- spec/integ/crypto/device-dehydration.spec.ts | 1 - spec/test-utils/E2EKeyReceiver.ts | 36 ++++++++++++++++--- spec/test-utils/E2EKeyResponder.ts | 37 ++++++++++++-------- spec/test-utils/mockEndpoints.ts | 13 +------ spec/unit/rust-crypto/rust-crypto.spec.ts | 12 ------- 6 files changed, 64 insertions(+), 50 deletions(-) diff --git a/spec/integ/crypto/cross-signing.spec.ts b/spec/integ/crypto/cross-signing.spec.ts index 81887f85d2..592b68a483 100644 --- a/spec/integ/crypto/cross-signing.spec.ts +++ b/spec/integ/crypto/cross-signing.spec.ts @@ -137,9 +137,9 @@ describe("cross-signing", () => { const authDict = { type: "test" }; await bootstrapCrossSigning(authDict); - // check the cross-signing keys upload - expect(fetchMock.called("upload-keys")).toBeTruthy(); - const [, keysOpts] = fetchMock.lastCall("upload-keys")!; + // check the cross-signing keys have been uploaded + expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy(); + const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!; const keysBody = JSON.parse(keysOpts!.body as string); expect(keysBody.auth).toEqual(authDict); // check uia dict was passed // there should be a key of each type @@ -420,15 +420,18 @@ describe("cross-signing", () => { return new Promise((resolve) => { fetchMock.post( { - url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"), - name: "upload-keys", + url: new URL( + "/_matrix/client/v3/keys/device_signing/upload", + aliceClient.getHomeserverUrl(), + ).toString(), + name: "upload-cross-signing-keys", }, (url, options) => { const content = JSON.parse(options.body as string); resolve(content); return {}; }, - // Override the routes define in `mockSetupCrossSigningRequests` + // Override the route defined in E2EKeyReceiver { overwriteRoutes: true }, ); }); diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 79e27f4f04..9dbb692b7b 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -181,7 +181,6 @@ async function initializeSecretStorage( const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); const e2eKeyResponder = new E2EKeyResponder(homeserverUrl); e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {}); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); const accountData: Map = new Map(); fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => { diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index af7b0b0ce8..d70408272e 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -14,11 +14,11 @@ See the License for the specific language governing permissions and limitations under the License. */ -import debugFunc from "debug"; -import { type Debugger } from "debug"; +import debugFunc, { type Debugger } from "debug"; import fetchMock from "fetch-mock-jest"; import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto"; +import type { CrossSigningKeys } from "../../src"; /** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys * @@ -55,14 +55,15 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { private readonly debug: Debugger; private deviceKeys: IDeviceKeys | null = null; + private crossSigningKeys: CrossSigningKeys | null = null; private oneTimeKeys: Record = {}; private readonly oneTimeKeysPromise: Promise; /** * Construct a new E2EKeyReceiver. * - * It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl. - * Only /upload requests made to this server will be intercepted: this allows a single test to use more than one + * It will immediately register an intercept of `/keys/uploads` and `/keys/device_signing/upload` requests for the given homeserverUrl. + * Only requests made to this server will be intercepted: this allows a single test to use more than one * client and have the keys collected separately. * * @param homeserverUrl - the Homeserver Url of the client under test. @@ -77,6 +78,14 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener); }); + + fetchMock.post( + { + url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(), + name: "upload-cross-signing-keys", + }, + (url, options) => this.onSigningKeyUploadRequest(options), + ); } private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise { @@ -113,6 +122,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { }; } + private async onSigningKeyUploadRequest(request: RequestInit): Promise { + const content = JSON.parse(request.body as string); + if (this.crossSigningKeys) { + throw new Error("Application attempted to upload E2E cross-signing keys multiple times"); + } + this.debug(`received cross-signing keys`); + // Remove UIA data + delete content["auth"]; + this.crossSigningKeys = content; + return {}; + } + /** Get the uploaded Ed25519 key * * If device keys have not yet been uploaded, throws an error @@ -150,6 +171,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { return this.deviceKeys; } + /** + * If cross-signing keys have been uploaded, return them. Else return null. + */ + public getUploadedCrossSigningKeys(): CrossSigningKeys | null { + return this.crossSigningKeys; + } + /** * If one-time keys have already been uploaded, return them. Otherwise, * set up an expectation that the keys will be uploaded, and wait for diff --git a/spec/test-utils/E2EKeyResponder.ts b/spec/test-utils/E2EKeyResponder.ts index a3110d0830..0b17b1f07a 100644 --- a/spec/test-utils/E2EKeyResponder.ts +++ b/spec/test-utils/E2EKeyResponder.ts @@ -17,7 +17,7 @@ limitations under the License. import fetchMock from "fetch-mock-jest"; import { MapWithDefault } from "../../src/utils"; -import { type IDownloadKeyResult } from "../../src"; +import { type IDownloadKeyResult, type SigningKeys } from "../../src"; import { type IDeviceKeys } from "../../src/@types/crypto"; import { type E2EKeyReceiver } from "./E2EKeyReceiver"; @@ -50,18 +50,14 @@ export class E2EKeyResponder { const content = JSON.parse(options.body as string); const usersToReturn = Object.keys(content["device_keys"]); const response = { - device_keys: {} as { [userId: string]: any }, - master_keys: {} as { [userId: string]: any }, - self_signing_keys: {} as { [userId: string]: any }, - user_signing_keys: {} as { [userId: string]: any }, - failures: {} as { [serverName: string]: any }, - }; + device_keys: {}, + master_keys: {}, + self_signing_keys: {}, + user_signing_keys: {}, + failures: {}, + } as IDownloadKeyResult; for (const user of usersToReturn) { - const userKeys = this.deviceKeysByUserByDevice.get(user); - if (userKeys !== undefined) { - response.device_keys[user] = Object.fromEntries(userKeys.entries()); - } - + // First see if we have an E2EKeyReceiver for this user, and if so, return any keys that have been uploaded const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user); if (e2eKeyReceiver !== undefined) { const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys(); @@ -69,16 +65,27 @@ export class E2EKeyResponder { response.device_keys[user] ??= {}; response.device_keys[user][deviceKeys.device_id] = deviceKeys; } + const crossSigningKeys = e2eKeyReceiver.getUploadedCrossSigningKeys(); + if (crossSigningKeys !== null) { + response.master_keys![user] = crossSigningKeys["master_key"]; + response.self_signing_keys![user] = crossSigningKeys["self_signing_key"] as SigningKeys; + } } + // Mix in any keys that have been added explicitly to this E2EKeyResponder. + const userKeys = this.deviceKeysByUserByDevice.get(user); + if (userKeys !== undefined) { + response.device_keys[user] ??= {}; + Object.assign(response.device_keys[user], Object.fromEntries(userKeys.entries())); + } if (this.masterKeysByUser.hasOwnProperty(user)) { - response.master_keys[user] = this.masterKeysByUser[user]; + response.master_keys![user] = this.masterKeysByUser[user]; } if (this.selfSigningKeysByUser.hasOwnProperty(user)) { - response.self_signing_keys[user] = this.selfSigningKeysByUser[user]; + response.self_signing_keys![user] = this.selfSigningKeysByUser[user]; } if (this.userSigningKeysByUser.hasOwnProperty(user)) { - response.user_signing_keys[user] = this.userSigningKeysByUser[user]; + response.user_signing_keys![user] = this.userSigningKeysByUser[user]; } } return response; diff --git a/spec/test-utils/mockEndpoints.ts b/spec/test-utils/mockEndpoints.ts index 2acb205e58..c42c0e7c28 100644 --- a/spec/test-utils/mockEndpoints.ts +++ b/spec/test-utils/mockEndpoints.ts @@ -43,11 +43,10 @@ export function mockInitialApiRequests(homeserverUrl: string, userId: string = " } /** - * Mock the requests needed to set up cross signing + * Mock the requests needed to set up cross signing, besides those provided by {@link E2EKeyReceiver}. * * Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request * Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check) - * Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check) */ export function mockSetupCrossSigningRequests(): void { // have account_data requests return an empty object @@ -58,16 +57,6 @@ export function mockSetupCrossSigningRequests(): void { // we expect a request to upload signatures for our device ... fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); - - // ... and one to upload the cross-signing keys (with UIA) - fetchMock.post( - // legacy crypto uses /unstable/; /v3/ is correct - { - url: new RegExp("/_matrix/client/(unstable|v3)/keys/device_signing/upload"), - name: "upload-keys", - }, - {}, - ); } /** diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 36387e6cfc..276b1b2742 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1548,10 +1548,6 @@ describe("RustCrypto", () => { const e2eKeyReceiver = new E2EKeyReceiver("http://server"); const e2eKeyResponder = new E2EKeyResponder("http://server"); e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", { - status: 200, - body: {}, - }); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { status: 200, body: {}, @@ -1793,10 +1789,6 @@ describe("RustCrypto", () => { error: "Not found", }, }); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", { - status: 200, - body: {}, - }); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { status: 200, body: {}, @@ -1934,10 +1926,6 @@ describe("RustCrypto", () => { error: "Not found", }, }); - fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", { - status: 200, - body: {}, - }); fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { status: 200, body: {}, From bb185658339cc6273db3795336e852c5a1dff8ee Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 7 Jul 2025 12:42:48 +0100 Subject: [PATCH 3/5] tests: Signature upload support in `E2EKeyReceiver` Have `E2EKeyReceiver` collect uploaded device signatures, so that they can be returned by `E2EKeyResponder`. --- spec/integ/crypto/cross-signing.spec.ts | 3 -- spec/integ/crypto/device-dehydration.spec.ts | 1 - spec/test-utils/E2EKeyReceiver.ts | 51 ++++++++++++++++++-- spec/test-utils/mockEndpoints.ts | 4 -- spec/unit/rust-crypto/rust-crypto.spec.ts | 12 ----- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/spec/integ/crypto/cross-signing.spec.ts b/spec/integ/crypto/cross-signing.spec.ts index 592b68a483..2b4dc8f3b3 100644 --- a/spec/integ/crypto/cross-signing.spec.ts +++ b/spec/integ/crypto/cross-signing.spec.ts @@ -225,9 +225,6 @@ describe("cross-signing", () => { await aliceClient.startClient(); await syncPromise(aliceClient); - // we expect a request to upload signatures for our device ... - fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); - // we expect the UserTrustStatusChanged event to be fired after the cross signing keys import const userTrustStatusChangedPromise = new Promise((resolve) => aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve), diff --git a/spec/integ/crypto/device-dehydration.spec.ts b/spec/integ/crypto/device-dehydration.spec.ts index 9dbb692b7b..7a89bf8372 100644 --- a/spec/integ/crypto/device-dehydration.spec.ts +++ b/spec/integ/crypto/device-dehydration.spec.ts @@ -181,7 +181,6 @@ async function initializeSecretStorage( const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl); const e2eKeyResponder = new E2EKeyResponder(homeserverUrl); e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {}); const accountData: Map = new Map(); fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => { const name = url.split("/").pop()!; diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index d70408272e..b01ae7ece1 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -18,7 +18,8 @@ import debugFunc, { type Debugger } from "debug"; import fetchMock from "fetch-mock-jest"; import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto"; -import type { CrossSigningKeys } from "../../src"; +import type { CrossSigningKeys, ISignedKey, KeySignatures } from "../../src"; +import type { CrossSigningKeyInfo } from "../../src/crypto-api"; /** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys * @@ -62,10 +63,15 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { /** * Construct a new E2EKeyReceiver. * - * It will immediately register an intercept of `/keys/uploads` and `/keys/device_signing/upload` requests for the given homeserverUrl. + * It will immediately register an intercept of [`/keys/upload`][1], [`/keys/signatures/upload`][2] and + * [`/keys/device_signing/upload`][3] requests for the given homeserverUrl. * Only requests made to this server will be intercepted: this allows a single test to use more than one * client and have the keys collected separately. * + * [1]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysupload + * [2]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keyssignaturesupload + * [3]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysdevice_signingupload + * * @param homeserverUrl - the Homeserver Url of the client under test. */ public constructor(homeserverUrl: string) { @@ -79,6 +85,14 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener); }); + fetchMock.post( + { + url: new URL("/_matrix/client/v3/keys/signatures/upload", homeserverUrl).toString(), + name: "upload-sigs", + }, + (url, options) => this.onSignaturesUploadRequest(options), + ); + fetchMock.post( { url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(), @@ -96,8 +110,10 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { if (this.deviceKeys) { throw new Error("Application attempted to upload E2E device keys multiple times"); } - this.debug(`received device keys`); this.deviceKeys = content.device_keys; + this.debug( + `received device keys for user ID ${this.deviceKeys!.user_id}, device ID ${this.deviceKeys!.device_id}`, + ); } if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) { @@ -122,6 +138,35 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { }; } + private async onSignaturesUploadRequest(request: RequestInit): Promise { + const content = JSON.parse(request.body as string) as KeySignatures; + for (const [userId, userKeys] of Object.entries(content)) { + for (const [deviceId, signedKey] of Object.entries(userKeys)) { + this.onDeviceSignatureUpload(userId, deviceId, signedKey); + } + } + + return {}; + } + + private onDeviceSignatureUpload(userId: string, deviceId: string, signedKey: CrossSigningKeyInfo | ISignedKey) { + if (!this.deviceKeys || userId != this.deviceKeys.user_id || deviceId != this.deviceKeys.device_id) { + this.debug( + `Ignoring device key signature upload for unknown device user ID ${userId}, device ID ${deviceId}`, + ); + return; + } + + this.debug(`received device key signature for user ID ${userId}, device ID ${deviceId}`); + this.deviceKeys.signatures ??= {}; + for (const [signingUser, signatures] of Object.entries(signedKey.signatures!)) { + this.deviceKeys.signatures[signingUser] = Object.assign( + this.deviceKeys.signatures[signingUser] ?? {}, + signatures, + ); + } + } + private async onSigningKeyUploadRequest(request: RequestInit): Promise { const content = JSON.parse(request.body as string); if (this.crossSigningKeys) { diff --git a/spec/test-utils/mockEndpoints.ts b/spec/test-utils/mockEndpoints.ts index c42c0e7c28..24a747575c 100644 --- a/spec/test-utils/mockEndpoints.ts +++ b/spec/test-utils/mockEndpoints.ts @@ -46,7 +46,6 @@ export function mockInitialApiRequests(homeserverUrl: string, userId: string = " * Mock the requests needed to set up cross signing, besides those provided by {@link E2EKeyReceiver}. * * Return 404 error for `GET _matrix/client/v3/user/:userId/account_data/:type` request - * Return `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check) */ export function mockSetupCrossSigningRequests(): void { // have account_data requests return an empty object @@ -54,9 +53,6 @@ export function mockSetupCrossSigningRequests(): void { status: 404, body: { errcode: "M_NOT_FOUND", error: "Account data not found." }, }); - - // we expect a request to upload signatures for our device ... - fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {}); } /** diff --git a/spec/unit/rust-crypto/rust-crypto.spec.ts b/spec/unit/rust-crypto/rust-crypto.spec.ts index 276b1b2742..82de230484 100644 --- a/spec/unit/rust-crypto/rust-crypto.spec.ts +++ b/spec/unit/rust-crypto/rust-crypto.spec.ts @@ -1548,10 +1548,6 @@ describe("RustCrypto", () => { const e2eKeyReceiver = new E2EKeyReceiver("http://server"); const e2eKeyResponder = new E2EKeyResponder("http://server"); e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { - status: 200, - body: {}, - }); await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true }); await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow( "Cannot pin identity of own user", @@ -1789,10 +1785,6 @@ describe("RustCrypto", () => { error: "Not found", }, }); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { - status: 200, - body: {}, - }); const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage); // dehydration requires secret storage and cross signing @@ -1926,10 +1918,6 @@ describe("RustCrypto", () => { error: "Not found", }, }); - fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", { - status: 200, - body: {}, - }); rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage); // dehydration requires secret storage and cross signing From 77487cedfec5b53902051fbd43c490a108675244 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Thu, 3 Jul 2025 17:26:33 +0100 Subject: [PATCH 4/5] tests: Implement `E2EOTKClaimResponder` class A new test helper, which intercepts `/keys/claim`, allowing clients under test to claim OTKs uploaded by other devices. --- spec/test-utils/E2EKeyReceiver.ts | 14 +++++ spec/test-utils/E2EOTKClaimResponder.ts | 73 +++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 spec/test-utils/E2EOTKClaimResponder.ts diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index b01ae7ece1..1df130a6a1 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -234,4 +234,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { await this.oneTimeKeysPromise; return this.oneTimeKeys; } + + /** + * If no one-time keys have yet been uploaded, return `null`. + * Otherwise, pop a key from the uploaded list. + */ + public getOneTimeKey(): [string, IOneTimeKey] | null { + const keys = Object.entries(this.oneTimeKeys); + if (keys.length == 0) { + return null; + } + const [otkId, otk] = keys[0]; + delete this.oneTimeKeys[otkId]; + return [otkId, otk]; + } } diff --git a/spec/test-utils/E2EOTKClaimResponder.ts b/spec/test-utils/E2EOTKClaimResponder.ts new file mode 100644 index 0000000000..4471be5853 --- /dev/null +++ b/spec/test-utils/E2EOTKClaimResponder.ts @@ -0,0 +1,73 @@ +/* +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 fetchMock from "fetch-mock-jest"; + +import { MapWithDefault } from "../../src/utils"; +import { type E2EKeyReceiver } from "./E2EKeyReceiver"; +import { type IClaimKeysRequest } from "../../src"; + +/** + * An object which intercepts `/keys/claim` fetches via fetch-mock. + */ +export class E2EOTKClaimResponder { + private e2eKeyReceiversByUserByDevice = new MapWithDefault>(() => new Map()); + + /** + * Construct a new E2EOTKClaimResponder. + * + * It will immediately register an intercept of `/keys/claim` requests for the given homeserverUrl. + * Only /claim requests made to this server will be intercepted: this allows a single test to use more than one + * client and have the keys collected separately. + * + * @param homeserverUrl - the Homeserver Url of the client under test. + */ + public constructor(homeserverUrl: string) { + const listener = (url: string, options: RequestInit) => this.onKeyClaimRequest(options); + fetchMock.post(new URL("/_matrix/client/v3/keys/claim", homeserverUrl).toString(), listener); + } + + private onKeyClaimRequest(options: RequestInit) { + const content = JSON.parse(options.body as string) as IClaimKeysRequest; + const response = { + one_time_keys: {} as { [userId: string]: any }, + }; + for (const [userId, devices] of Object.entries(content["one_time_keys"])) { + for (const deviceId of Object.keys(devices)) { + const e2eKeyReceiver = this.e2eKeyReceiversByUserByDevice.get(userId)?.get(deviceId); + const otk = e2eKeyReceiver?.getOneTimeKey(); + if (otk) { + const [keyId, key] = otk; + response.one_time_keys[userId] ??= {}; + response.one_time_keys[userId][deviceId] = { + [keyId]: key, + }; + } + } + } + return response; + } + + /** + * Add an E2EKeyReceiver to poll for uploaded keys + * + * When the `/keys/claim` request is received, a OTK will be removed from the `E2EKeyReceiver` and + * added to the response. + */ + public addKeyReceiver(userId: string, deviceId: string, e2eKeyReceiver: E2EKeyReceiver) { + this.e2eKeyReceiversByUserByDevice.getOrCreate(userId).set(deviceId, e2eKeyReceiver); + } +} From 361b0a1c08f4e8ccdf3c61fb505b865dd7969049 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Fri, 4 Jul 2025 13:55:19 +0100 Subject: [PATCH 5/5] Expose experimental settings for encrypted history sharing Add options to `MatrixClient.invite` and `MatrixClient.joinRoom` to share and accept encrypted history on invite, per MSC4268. --- spec/integ/crypto/history-sharing.spec.ts | 239 ++++++++++++++++++++++ spec/test-utils/E2EKeyReceiver.ts | 10 +- src/@types/requests.ts | 17 ++ src/client.ts | 27 ++- src/common-crypto/CryptoBackend.ts | 13 ++ src/crypto-api/index.ts | 14 ++ src/rust-crypto/rust-crypto.ts | 129 ++++++++++-- 7 files changed, 429 insertions(+), 20 deletions(-) create mode 100644 spec/integ/crypto/history-sharing.spec.ts diff --git a/spec/integ/crypto/history-sharing.spec.ts b/spec/integ/crypto/history-sharing.spec.ts new file mode 100644 index 0000000000..aede8e7d14 --- /dev/null +++ b/spec/integ/crypto/history-sharing.spec.ts @@ -0,0 +1,239 @@ +/* +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 "fake-indexeddb/auto"; +import fetchMock from "fetch-mock-jest"; +import mkDebug from "debug"; + +import { + createClient, + DebugLogger, + EventType, + type IContent, + KnownMembership, + type MatrixClient, + MsgType, +} from "../../../src"; +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 { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts"; +import { flushPromises } from "../../test-utils/flushPromises.ts"; +import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts"; +import { escapeRegExp } from "../../../src/utils.ts"; + +const debug = mkDebug("matrix-js-sdk:history-sharing"); + +// load the rust library. This can take a few seconds on a slow GH worker. +beforeAll(async () => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm"); + await RustSdkCryptoJs.initAsync(); +}, 10000); + +afterEach(() => { + // reset fake-indexeddb after each test, to make sure we don't leak connections + // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state + // eslint-disable-next-line no-global-assign + indexedDB = new IDBFactory(); +}); + +const ROOM_ID = "!room:example.com"; +const ALICE_HOMESERVER_URL = "https://alice-server.com"; +const BOB_HOMESERVER_URL = "https://bob-server.com"; + +async function createAndInitClient(homeserverUrl: string, userId: string) { + mockInitialApiRequests(homeserverUrl, userId); + + const client = createClient({ + baseUrl: homeserverUrl, + userId: userId, + accessToken: "akjgkrgjs", + deviceId: "xzcvb", + logger: new DebugLogger(mkDebug(`matrix-js-sdk:${userId}`)), + }); + + await client.initRustCrypto({ cryptoDatabasePrefix: userId }); + await client.startClient(); + await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true }); + return client; +} + +describe("History Sharing", () => { + let aliceClient: MatrixClient; + let aliceSyncResponder: SyncResponder; + let bobClient: MatrixClient; + let bobSyncResponder: SyncResponder; + + beforeEach(async () => { + // anything that we don't have a specific matcher for silently returns a 404 + fetchMock.catch(404); + fetchMock.config.warnOnFallback = false; + mockSetupCrossSigningRequests(); + + const aliceId = "@alice:localhost"; + const bobId = "@bob:xyz"; + + const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-"); + const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL); + const aliceKeyClaimResponder = new E2EOTKClaimResponder(ALICE_HOMESERVER_URL); + aliceSyncResponder = new SyncResponder(ALICE_HOMESERVER_URL); + + const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-"); + const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL); + bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL); + + aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver); + aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver); + bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver); + bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver); + + aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId); + bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId); + + aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver); + + aliceSyncResponder.sendOrQueueSyncResponse({}); + await syncPromise(aliceClient); + + bobSyncResponder.sendOrQueueSyncResponse({}); + await syncPromise(bobClient); + }); + + test("Share room key", 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: "Hi!" }); + 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 to-device event and the room invite + 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] } } } }, + to_device: { + events: [ + { + type: "m.room.encrypted", + sender: aliceClient.getSafeUserId(), + content: bobToDeviceMessage, + }, + ], + }, + }); + 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, + }); + fetchMock.getOnce( + `begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`, + { body: uploadedBlob }, + { sendAsJson: false }, + ); + await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true }); + + // Bob receives, should be able to decrypt, the megolm message + 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.getType()).toEqual("m.room.message"); + expect(event.getContent().body).toEqual("Hi!"); + }); + + afterEach(async () => { + bobClient.stopClient(); + aliceClient.stopClient(); + await flushPromises(); + }); +}); + +function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise { + return new Promise((resolve) => { + fetchMock.putOnce( + new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/rooms/[^/]*/send/${escapeRegExp(msgtype)}/`), + (url, request) => { + const content = JSON.parse(request.body as string); + resolve(content); + return { event_id: "$event_id" }; + }, + { name: "sendRoomEvent" }, + ); + }); +} + +function expectSendToDeviceMessage( + homeserverUrl: string, + msgtype: string, +): Promise>> { + return new Promise((resolve) => { + fetchMock.putOnce( + new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/sendToDevice/${escapeRegExp(msgtype)}/`), + (url: string, opts: RequestInit) => { + const body = JSON.parse(opts.body as string); + resolve(body.messages); + return {}; + }, + ); + }); +} diff --git a/spec/test-utils/E2EKeyReceiver.ts b/spec/test-utils/E2EKeyReceiver.ts index 1df130a6a1..d591994f9a 100644 --- a/spec/test-utils/E2EKeyReceiver.ts +++ b/spec/test-utils/E2EKeyReceiver.ts @@ -72,9 +72,11 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { * [2]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keyssignaturesupload * [3]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysdevice_signingupload * - * @param homeserverUrl - the Homeserver Url of the client under test. + * @param homeserverUrl - The Homeserver Url of the client under test. + * @param routeNamePrefix - An optional prefix to add to the fetchmock route names. Required if there is more than + * one E2EKeyReceiver instance active. */ - public constructor(homeserverUrl: string) { + public constructor(homeserverUrl: string, routeNamePrefix: string = "") { this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`); // set up a listener for /keys/upload. @@ -88,7 +90,7 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { fetchMock.post( { url: new URL("/_matrix/client/v3/keys/signatures/upload", homeserverUrl).toString(), - name: "upload-sigs", + name: routeNamePrefix + "upload-sigs", }, (url, options) => this.onSignaturesUploadRequest(options), ); @@ -96,7 +98,7 @@ export class E2EKeyReceiver implements IE2EKeyReceiver { fetchMock.post( { url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(), - name: "upload-cross-signing-keys", + name: routeNamePrefix + "upload-cross-signing-keys", }, (url, options) => this.onSigningKeyUploadRequest(options), ); diff --git a/src/@types/requests.ts b/src/@types/requests.ts index a618f6d936..44407da748 100644 --- a/src/@types/requests.ts +++ b/src/@types/requests.ts @@ -41,6 +41,14 @@ export interface IJoinRoomOpts { * The server names to try and join through in addition to those that are automatically chosen. */ viaServers?: string[]; + + /** + * When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental + * support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). + * + * @experimental + */ + acceptSharedHistory?: boolean; } /** Options object for {@link MatrixClient.invite}. */ @@ -49,6 +57,15 @@ export interface InviteOpts { * The reason for the invite. */ reason?: string; + + /** + * Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history + * visibility was `shared`, via the experimental + * support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). + * + * @experimental + */ + shareEncryptedHistory?: boolean; } export interface KnockRoomOpts { diff --git a/src/client.ts b/src/client.ts index d2de367e2d..3031fcb601 100644 --- a/src/client.ts +++ b/src/client.ts @@ -223,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts"; import { type CrossSigningKeyInfo, type CryptoApi, + type CryptoCallbacks, CryptoEvent, type CryptoEventHandlerMap, - type CryptoCallbacks, } from "./crypto-api/index.ts"; import { type SecretStorageKeyDescription, @@ -2371,7 +2371,12 @@ export class MatrixClient extends TypedEventEmitter { const room = this.getRoom(roomIdOrAlias); - if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room; + const preJoinMembership = room?.getMember(this.getSafeUserId()); + const preJoinMembershipSender = preJoinMembership?.events.member?.getSender() ?? null; + this.logger.debug( + `joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership?.membership}, preJoinMembershipSender=${preJoinMembershipSender}, opts=${JSON.stringify(opts)}`, + ); + if (preJoinMembership?.membership == KnownMembership.Join) return room!; let signPromise: Promise = Promise.resolve(); @@ -2398,6 +2403,15 @@ export class MatrixClient extends TypedEventEmitter(Method.Post, path, queryParams, data); const roomId = res.room_id; + if ( + opts.acceptSharedHistory && + preJoinMembership?.membership == KnownMembership.Invite && + preJoinMembershipSender && + this.cryptoBackend + ) { + await this.cryptoBackend.maybeAcceptKeyBundle(roomId, preJoinMembershipSender); + } + // In case we were originally given an alias, check the room cache again // with the resolved ID - this method is supposed to no-op if we already // were in the room, after all. @@ -3764,11 +3778,16 @@ export class MatrixClient extends TypedEventEmitter { + public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise { if (typeof opts != "object") { opts = { reason: opts }; } - return this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason); + + if (opts.shareEncryptedHistory) { + await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId); + } + + return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason); } /** diff --git a/src/common-crypto/CryptoBackend.ts b/src/common-crypto/CryptoBackend.ts index b81f8765d6..65d90a6df3 100644 --- a/src/common-crypto/CryptoBackend.ts +++ b/src/common-crypto/CryptoBackend.ts @@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi { * @returns a promise which resolves once the keys have been imported */ importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise; + + /** + * Having accepted an invite for the given room from the given user, attempt to + * find information about a room key bundle and, if found, download the + * bundle and import the room keys, as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}. + * + * @param roomId - The room we were invited to, for which we want to check if a room + * key bundle was received. + * + * @param inviter - The user who invited us to the room and is expected to have + * sent the room key bundle. + */ + maybeAcceptKeyBundle(roomId: string, inviter: string): Promise; } /** The methods which crypto implementations should expose to the Sync api diff --git a/src/crypto-api/index.ts b/src/crypto-api/index.ts index 173ff31764..b247b47a1e 100644 --- a/src/crypto-api/index.ts +++ b/src/crypto-api/index.ts @@ -729,6 +729,20 @@ export interface CryptoApi { * @param secrets - The secrets bundle received from the other device */ importSecretsBundle?(secrets: Awaited>): Promise; + + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + // + // Room key history sharing (MSC4268) + // + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// + + /** + * Share any shareable E2EE history in the given room with the given recipient, + * as per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268) + * + * @experimental + */ + shareRoomHistoryWithUser(roomId: string, userId: string): Promise; } /** A reason code for a failure to decrypt an event. */ diff --git a/src/rust-crypto/rust-crypto.ts b/src/rust-crypto/rust-crypto.ts index 9cb8522d38..48cbaa43cd 100644 --- a/src/rust-crypto/rust-crypto.ts +++ b/src/rust-crypto/rust-crypto.ts @@ -20,7 +20,7 @@ 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, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.ts"; -import type { ToDevicePayload, ToDeviceBatch } from "../models/ToDeviceMessage.ts"; +import type { ToDeviceBatch, ToDevicePayload } from "../models/ToDeviceMessage.ts"; import { type MatrixEvent, MatrixEventEvent } from "../models/event.ts"; import { type Room } from "../models/room.ts"; import { type RoomMember } from "../models/room-member.ts"; @@ -37,6 +37,7 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts"; import { KeyClaimManager } from "./KeyClaimManager.ts"; import { 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"; @@ -94,6 +94,7 @@ import { DehydratedDeviceManager } from "./DehydratedDeviceManager.ts"; import { VerificationMethod } from "../types.ts"; import { keyFromAuthData } from "../common-crypto/key-passphrase.ts"; import { type UIAuthCallback } from "../interactive-auth.ts"; +import { getHttpUriForMxc } from "../content-repo.ts"; const ALL_VERIFICATION_METHODS = [ VerificationMethod.Sas, @@ -321,6 +322,62 @@ export class RustCrypto extends TypedEventEmitter { + // TODO: retry this if it gets interrupted or it fails. + // TODO: do this in the background. + // TODO: handle the bundle message arriving after the invite. + + const logger = new LogSpan(this.logger, `maybeAcceptKeyBundle(${roomId}, ${inviter})`); + + const bundleData = await this.olmMachine.getReceivedRoomKeyBundleData( + new RustSdkCryptoJs.RoomId(roomId), + new RustSdkCryptoJs.UserId(inviter), + ); + if (!bundleData) { + logger.info("No key bundle found for user"); + return; + } + + logger.info(`Fetching key bundle ${bundleData.url}`); + const url = getHttpUriForMxc( + this.http.opts.baseUrl, + bundleData.url, + undefined, + undefined, + undefined, + /* allowDirectLinks */ false, + /* allowRedirects */ true, + /* useAuthentication */ true, + ); + let encryptedBundle: Blob; + try { + const bundleUrl = new URL(url); + encryptedBundle = await this.http.authedRequest( + Method.Get, + bundleUrl.pathname + bundleUrl.search, + {}, + undefined, + { + rawResponseBody: true, + prefix: "", + }, + ); + } catch (err) { + logger.warn(`Error downloading encrypted bundle from ${url}:`, err); + throw err; + } + + logger.info(`Received blob of length ${encryptedBundle.size}`); + try { + await this.olmMachine.receiveRoomKeyBundle(bundleData, new Uint8Array(await encryptedBundle.arrayBuffer())); + } catch (err) { + logger.warn(`Error receiving encrypted bundle:`, err); + throw err; + } + } /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // CryptoApi implementation @@ -1474,6 +1531,54 @@ export class RustCrypto extends TypedEventEmitter { + const logger = new LogSpan(this.logger, `shareRoomHistoryWithUser(${roomId}, ${userId})`); + + // 0. We can only share room history if our user has set up cross-signing. + const identity = await this.getOwnIdentity(); + if (!identity?.isVerified()) { + logger.warn( + "Not sharing message history as the current device is not verified by our cross-signing identity", + ); + return; + } + + logger.info("Sharing message history"); + + // 1. Construct the key bundle + const bundle = await this.getOlmMachineOrThrow().buildRoomKeyBundle(new RustSdkCryptoJs.RoomId(roomId)); + if (!bundle) { + logger.info("No keys to share"); + return; + } + + // 2. Upload the encrypted bundle to the server + const uploadResponse = await this.http.uploadContent(bundle.encryptedData); + logger.info(`Uploaded encrypted key blob: ${JSON.stringify(uploadResponse)}`); + + // 3. We may not share a room with the user, so get a fresh list of devices for the invited user. + const req = this.getOlmMachineOrThrow().queryKeysForUsers([new RustSdkCryptoJs.UserId(userId)]); + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + + // 4. Establish Olm sessions with all of the recipient's devices. + await this.keyClaimManager.ensureSessionsForUsers(logger, [new RustSdkCryptoJs.UserId(userId)]); + + // 5. Send to-device messages to the recipient to share the keys. + const requests = await this.getOlmMachineOrThrow().shareRoomKeyBundleData( + new RustSdkCryptoJs.UserId(userId), + new RustSdkCryptoJs.RoomId(roomId), + uploadResponse.content_uri, + bundle.mediaEncryptionInfo, + RustSdkCryptoJs.CollectStrategy.identityBasedStrategy(), + ); + for (const req of requests) { + await this.outgoingRequestProcessor.makeOutgoingRequest(req); + } + } + /////////////////////////////////////////////////////////////////////////////////////////////////////////////////// // // SyncCryptoCallbacks implementation