Skip to content

Commit 361b0a1

Browse files
committed
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.
1 parent 77487ce commit 361b0a1

File tree

7 files changed

+429
-20
lines changed

7 files changed

+429
-20
lines changed
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
/*
2+
Copyright 2025 The Matrix.org Foundation C.I.C.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
import "fake-indexeddb/auto";
18+
import fetchMock from "fetch-mock-jest";
19+
import mkDebug from "debug";
20+
21+
import {
22+
createClient,
23+
DebugLogger,
24+
EventType,
25+
type IContent,
26+
KnownMembership,
27+
type MatrixClient,
28+
MsgType,
29+
} from "../../../src";
30+
import { E2EKeyReceiver } from "../../test-utils/E2EKeyReceiver.ts";
31+
import { SyncResponder } from "../../test-utils/SyncResponder.ts";
32+
import { mockInitialApiRequests, mockSetupCrossSigningRequests } from "../../test-utils/mockEndpoints.ts";
33+
import { getSyncResponse, mkEventCustom, syncPromise } from "../../test-utils/test-utils.ts";
34+
import { E2EKeyResponder } from "../../test-utils/E2EKeyResponder.ts";
35+
import { flushPromises } from "../../test-utils/flushPromises.ts";
36+
import { E2EOTKClaimResponder } from "../../test-utils/E2EOTKClaimResponder.ts";
37+
import { escapeRegExp } from "../../../src/utils.ts";
38+
39+
const debug = mkDebug("matrix-js-sdk:history-sharing");
40+
41+
// load the rust library. This can take a few seconds on a slow GH worker.
42+
beforeAll(async () => {
43+
// eslint-disable-next-line @typescript-eslint/no-require-imports
44+
const RustSdkCryptoJs = await require("@matrix-org/matrix-sdk-crypto-wasm");
45+
await RustSdkCryptoJs.initAsync();
46+
}, 10000);
47+
48+
afterEach(() => {
49+
// reset fake-indexeddb after each test, to make sure we don't leak connections
50+
// cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state
51+
// eslint-disable-next-line no-global-assign
52+
indexedDB = new IDBFactory();
53+
});
54+
55+
const ROOM_ID = "!room:example.com";
56+
const ALICE_HOMESERVER_URL = "https://alice-server.com";
57+
const BOB_HOMESERVER_URL = "https://bob-server.com";
58+
59+
async function createAndInitClient(homeserverUrl: string, userId: string) {
60+
mockInitialApiRequests(homeserverUrl, userId);
61+
62+
const client = createClient({
63+
baseUrl: homeserverUrl,
64+
userId: userId,
65+
accessToken: "akjgkrgjs",
66+
deviceId: "xzcvb",
67+
logger: new DebugLogger(mkDebug(`matrix-js-sdk:${userId}`)),
68+
});
69+
70+
await client.initRustCrypto({ cryptoDatabasePrefix: userId });
71+
await client.startClient();
72+
await client.getCrypto()!.bootstrapCrossSigning({ setupNewCrossSigning: true });
73+
return client;
74+
}
75+
76+
describe("History Sharing", () => {
77+
let aliceClient: MatrixClient;
78+
let aliceSyncResponder: SyncResponder;
79+
let bobClient: MatrixClient;
80+
let bobSyncResponder: SyncResponder;
81+
82+
beforeEach(async () => {
83+
// anything that we don't have a specific matcher for silently returns a 404
84+
fetchMock.catch(404);
85+
fetchMock.config.warnOnFallback = false;
86+
mockSetupCrossSigningRequests();
87+
88+
const aliceId = "@alice:localhost";
89+
const bobId = "@bob:xyz";
90+
91+
const aliceKeyReceiver = new E2EKeyReceiver(ALICE_HOMESERVER_URL, "alice-");
92+
const aliceKeyResponder = new E2EKeyResponder(ALICE_HOMESERVER_URL);
93+
const aliceKeyClaimResponder = new E2EOTKClaimResponder(ALICE_HOMESERVER_URL);
94+
aliceSyncResponder = new SyncResponder(ALICE_HOMESERVER_URL);
95+
96+
const bobKeyReceiver = new E2EKeyReceiver(BOB_HOMESERVER_URL, "bob-");
97+
const bobKeyResponder = new E2EKeyResponder(BOB_HOMESERVER_URL);
98+
bobSyncResponder = new SyncResponder(BOB_HOMESERVER_URL);
99+
100+
aliceKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
101+
aliceKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
102+
bobKeyResponder.addKeyReceiver(aliceId, aliceKeyReceiver);
103+
bobKeyResponder.addKeyReceiver(bobId, bobKeyReceiver);
104+
105+
aliceClient = await createAndInitClient(ALICE_HOMESERVER_URL, aliceId);
106+
bobClient = await createAndInitClient(BOB_HOMESERVER_URL, bobId);
107+
108+
aliceKeyClaimResponder.addKeyReceiver(bobId, bobClient.deviceId!, bobKeyReceiver);
109+
110+
aliceSyncResponder.sendOrQueueSyncResponse({});
111+
await syncPromise(aliceClient);
112+
113+
bobSyncResponder.sendOrQueueSyncResponse({});
114+
await syncPromise(bobClient);
115+
});
116+
117+
test("Share room key", async () => {
118+
// Alice is in an encrypted room
119+
const syncResponse = getSyncResponse([aliceClient.getSafeUserId()], ROOM_ID);
120+
aliceSyncResponder.sendOrQueueSyncResponse(syncResponse);
121+
await syncPromise(aliceClient);
122+
123+
// ... and she sends an event
124+
const msgProm = expectSendRoomEvent(ALICE_HOMESERVER_URL, "m.room.encrypted");
125+
await aliceClient.sendEvent(ROOM_ID, EventType.RoomMessage, { msgtype: MsgType.Text, body: "Hi!" });
126+
const sentMessage = await msgProm;
127+
debug(`Alice sent encrypted room event: ${JSON.stringify(sentMessage)}`);
128+
129+
// Now, Alice invites Bob
130+
const uploadProm = new Promise<Uint8Array>((resolve) => {
131+
fetchMock.postOnce(new URL("/_matrix/media/v3/upload", ALICE_HOMESERVER_URL).toString(), (url, request) => {
132+
const body = request.body as Uint8Array;
133+
debug(`Alice uploaded blob of length ${body.length}`);
134+
resolve(body);
135+
return { content_uri: "mxc://alice-server/here" };
136+
});
137+
});
138+
const toDeviceMessageProm = expectSendToDeviceMessage(ALICE_HOMESERVER_URL, "m.room.encrypted");
139+
// POST https://alice-server.com/_matrix/client/v3/rooms/!room%3Aexample.com/invite
140+
fetchMock.postOnce(`${ALICE_HOMESERVER_URL}/_matrix/client/v3/rooms/${encodeURIComponent(ROOM_ID)}/invite`, {});
141+
await aliceClient.invite(ROOM_ID, bobClient.getSafeUserId(), { shareEncryptedHistory: true });
142+
const uploadedBlob = await uploadProm;
143+
const sentToDeviceRequest = await toDeviceMessageProm;
144+
debug(`Alice sent encrypted to-device events: ${JSON.stringify(sentToDeviceRequest)}`);
145+
const bobToDeviceMessage = sentToDeviceRequest[bobClient.getSafeUserId()][bobClient.deviceId!];
146+
expect(bobToDeviceMessage).toBeDefined();
147+
148+
// Bob receives the to-device event and the room invite
149+
const inviteEvent = mkEventCustom({
150+
type: "m.room.member",
151+
sender: aliceClient.getSafeUserId(),
152+
state_key: bobClient.getSafeUserId(),
153+
content: { membership: KnownMembership.Invite },
154+
});
155+
bobSyncResponder.sendOrQueueSyncResponse({
156+
rooms: { invite: { [ROOM_ID]: { invite_state: { events: [inviteEvent] } } } },
157+
to_device: {
158+
events: [
159+
{
160+
type: "m.room.encrypted",
161+
sender: aliceClient.getSafeUserId(),
162+
content: bobToDeviceMessage,
163+
},
164+
],
165+
},
166+
});
167+
await syncPromise(bobClient);
168+
169+
const room = bobClient.getRoom(ROOM_ID);
170+
expect(room).toBeTruthy();
171+
expect(room?.getMyMembership()).toEqual(KnownMembership.Invite);
172+
173+
fetchMock.postOnce(`${BOB_HOMESERVER_URL}/_matrix/client/v3/join/${encodeURIComponent(ROOM_ID)}`, {
174+
room_id: ROOM_ID,
175+
});
176+
fetchMock.getOnce(
177+
`begin:${BOB_HOMESERVER_URL}/_matrix/client/v1/media/download/alice-server/here`,
178+
{ body: uploadedBlob },
179+
{ sendAsJson: false },
180+
);
181+
await bobClient.joinRoom(ROOM_ID, { acceptSharedHistory: true });
182+
183+
// Bob receives, should be able to decrypt, the megolm message
184+
const bobSyncResponse = getSyncResponse([aliceClient.getSafeUserId(), bobClient.getSafeUserId()], ROOM_ID);
185+
bobSyncResponse.rooms.join[ROOM_ID].timeline.events.push(
186+
mkEventCustom({
187+
type: "m.room.encrypted",
188+
sender: aliceClient.getSafeUserId(),
189+
content: sentMessage,
190+
event_id: "$event_id",
191+
}) as any,
192+
);
193+
bobSyncResponder.sendOrQueueSyncResponse(bobSyncResponse);
194+
await syncPromise(bobClient);
195+
196+
const bobRoom = bobClient.getRoom(ROOM_ID);
197+
const event = bobRoom!.getLastLiveEvent()!;
198+
expect(event.getId()).toEqual("$event_id");
199+
await event.getDecryptionPromise();
200+
expect(event.getType()).toEqual("m.room.message");
201+
expect(event.getContent().body).toEqual("Hi!");
202+
});
203+
204+
afterEach(async () => {
205+
bobClient.stopClient();
206+
aliceClient.stopClient();
207+
await flushPromises();
208+
});
209+
});
210+
211+
function expectSendRoomEvent(homeserverUrl: string, msgtype: string): Promise<IContent> {
212+
return new Promise<IContent>((resolve) => {
213+
fetchMock.putOnce(
214+
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/rooms/[^/]*/send/${escapeRegExp(msgtype)}/`),
215+
(url, request) => {
216+
const content = JSON.parse(request.body as string);
217+
resolve(content);
218+
return { event_id: "$event_id" };
219+
},
220+
{ name: "sendRoomEvent" },
221+
);
222+
});
223+
}
224+
225+
function expectSendToDeviceMessage(
226+
homeserverUrl: string,
227+
msgtype: string,
228+
): Promise<Record<string, Record<string, object>>> {
229+
return new Promise((resolve) => {
230+
fetchMock.putOnce(
231+
new RegExp(`^${escapeRegExp(homeserverUrl)}/_matrix/client/v3/sendToDevice/${escapeRegExp(msgtype)}/`),
232+
(url: string, opts: RequestInit) => {
233+
const body = JSON.parse(opts.body as string);
234+
resolve(body.messages);
235+
return {};
236+
},
237+
);
238+
});
239+
}

spec/test-utils/E2EKeyReceiver.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,9 +72,11 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
7272
* [2]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keyssignaturesupload
7373
* [3]: https://spec.matrix.org/v1.14/client-server-api/#post_matrixclientv3keysdevice_signingupload
7474
*
75-
* @param homeserverUrl - the Homeserver Url of the client under test.
75+
* @param homeserverUrl - The Homeserver Url of the client under test.
76+
* @param routeNamePrefix - An optional prefix to add to the fetchmock route names. Required if there is more than
77+
* one E2EKeyReceiver instance active.
7678
*/
77-
public constructor(homeserverUrl: string) {
79+
public constructor(homeserverUrl: string, routeNamePrefix: string = "") {
7880
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
7981

8082
// set up a listener for /keys/upload.
@@ -88,15 +90,15 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
8890
fetchMock.post(
8991
{
9092
url: new URL("/_matrix/client/v3/keys/signatures/upload", homeserverUrl).toString(),
91-
name: "upload-sigs",
93+
name: routeNamePrefix + "upload-sigs",
9294
},
9395
(url, options) => this.onSignaturesUploadRequest(options),
9496
);
9597

9698
fetchMock.post(
9799
{
98100
url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(),
99-
name: "upload-cross-signing-keys",
101+
name: routeNamePrefix + "upload-cross-signing-keys",
100102
},
101103
(url, options) => this.onSigningKeyUploadRequest(options),
102104
);

src/@types/requests.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,14 @@ export interface IJoinRoomOpts {
4141
* The server names to try and join through in addition to those that are automatically chosen.
4242
*/
4343
viaServers?: string[];
44+
45+
/**
46+
* When accepting an invite, whether to accept encrypted history shared by the inviter via the experimental
47+
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
48+
*
49+
* @experimental
50+
*/
51+
acceptSharedHistory?: boolean;
4452
}
4553

4654
/** Options object for {@link MatrixClient.invite}. */
@@ -49,6 +57,15 @@ export interface InviteOpts {
4957
* The reason for the invite.
5058
*/
5159
reason?: string;
60+
61+
/**
62+
* Before sending the invite, if the room is encrypted, share the keys for any messages sent while the history
63+
* visibility was `shared`, via the experimental
64+
* support for [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268).
65+
*
66+
* @experimental
67+
*/
68+
shareEncryptedHistory?: boolean;
5269
}
5370

5471
export interface KnockRoomOpts {

src/client.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts";
223223
import {
224224
type CrossSigningKeyInfo,
225225
type CryptoApi,
226+
type CryptoCallbacks,
226227
CryptoEvent,
227228
type CryptoEventHandlerMap,
228-
type CryptoCallbacks,
229229
} from "./crypto-api/index.ts";
230230
import {
231231
type SecretStorageKeyDescription,
@@ -2371,7 +2371,12 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
23712371
*/
23722372
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
23732373
const room = this.getRoom(roomIdOrAlias);
2374-
if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room;
2374+
const preJoinMembership = room?.getMember(this.getSafeUserId());
2375+
const preJoinMembershipSender = preJoinMembership?.events.member?.getSender() ?? null;
2376+
this.logger.debug(
2377+
`joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership?.membership}, preJoinMembershipSender=${preJoinMembershipSender}, opts=${JSON.stringify(opts)}`,
2378+
);
2379+
if (preJoinMembership?.membership == KnownMembership.Join) return room!;
23752380

23762381
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
23772382

@@ -2398,6 +2403,15 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
23982403
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
23992404

24002405
const roomId = res.room_id;
2406+
if (
2407+
opts.acceptSharedHistory &&
2408+
preJoinMembership?.membership == KnownMembership.Invite &&
2409+
preJoinMembershipSender &&
2410+
this.cryptoBackend
2411+
) {
2412+
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, preJoinMembershipSender);
2413+
}
2414+
24012415
// In case we were originally given an alias, check the room cache again
24022416
// with the resolved ID - this method is supposed to no-op if we already
24032417
// were in the room, after all.
@@ -3764,11 +3778,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
37643778
*
37653779
* @returns An empty object.
37663780
*/
3767-
public invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
3781+
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
37683782
if (typeof opts != "object") {
37693783
opts = { reason: opts };
37703784
}
3771-
return this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
3785+
3786+
if (opts.shareEncryptedHistory) {
3787+
await this.cryptoBackend?.shareRoomHistoryWithUser(roomId, userId);
3788+
}
3789+
3790+
return await this.membershipChange(roomId, userId, KnownMembership.Invite, opts.reason);
37723791
}
37733792

37743793
/**

src/common-crypto/CryptoBackend.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
7979
* @returns a promise which resolves once the keys have been imported
8080
*/
8181
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
82+
83+
/**
84+
* Having accepted an invite for the given room from the given user, attempt to
85+
* find information about a room key bundle and, if found, download the
86+
* bundle and import the room keys, as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}.
87+
*
88+
* @param roomId - The room we were invited to, for which we want to check if a room
89+
* key bundle was received.
90+
*
91+
* @param inviter - The user who invited us to the room and is expected to have
92+
* sent the room key bundle.
93+
*/
94+
maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void>;
8295
}
8396

8497
/** The methods which crypto implementations should expose to the Sync api

0 commit comments

Comments
 (0)