Skip to content

Commit c4e1e07

Browse files
richvdhuhoreg
andauthored
Experimental support for sharing encrypted history on invite (#4920)
* tests: Cross-signing keys support in `E2EKeyReceiver` Have `E2EKeyReceiver` collect uploaded cross-signing keys, so that they can be returned by `E2EKeyResponder`. * tests: Signature upload support in `E2EKeyReceiver` Have `E2EKeyReceiver` collect uploaded device signatures, so that they can be returned by `E2EKeyResponder`. * tests: Implement `E2EOTKClaimResponder` class A new test helper, which intercepts `/keys/claim`, allowing clients under test to claim OTKs uploaded by other devices. * 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. * Clarify pre-join-membership logic * Improve tests * Update spec/integ/crypto/cross-signing.spec.ts Co-authored-by: Hubert Chathi <[email protected]> --------- Co-authored-by: Hubert Chathi <[email protected]>
1 parent 56b24c0 commit c4e1e07

File tree

13 files changed

+694
-89
lines changed

13 files changed

+694
-89
lines changed

spec/integ/crypto/cross-signing.spec.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -137,9 +137,9 @@ describe("cross-signing", () => {
137137
const authDict = { type: "test" };
138138
await bootstrapCrossSigning(authDict);
139139

140-
// check the cross-signing keys upload
141-
expect(fetchMock.called("upload-keys")).toBeTruthy();
142-
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
140+
// check that the cross-signing keys have been uploaded
141+
expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy();
142+
const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!;
143143
const keysBody = JSON.parse(keysOpts!.body as string);
144144
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
145145
// there should be a key of each type
@@ -225,9 +225,6 @@ describe("cross-signing", () => {
225225
await aliceClient.startClient();
226226
await syncPromise(aliceClient);
227227

228-
// we expect a request to upload signatures for our device ...
229-
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
230-
231228
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
232229
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
233230
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
@@ -420,15 +417,18 @@ describe("cross-signing", () => {
420417
return new Promise<any>((resolve) => {
421418
fetchMock.post(
422419
{
423-
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
424-
name: "upload-keys",
420+
url: new URL(
421+
"/_matrix/client/v3/keys/device_signing/upload",
422+
aliceClient.getHomeserverUrl(),
423+
).toString(),
424+
name: "upload-cross-signing-keys",
425425
},
426426
(url, options) => {
427427
const content = JSON.parse(options.body as string);
428428
resolve(content);
429429
return {};
430430
},
431-
// Override the routes define in `mockSetupCrossSigningRequests`
431+
// Override the route defined in E2EKeyReceiver
432432
{ overwriteRoutes: true },
433433
);
434434
});

spec/integ/crypto/device-dehydration.spec.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,6 @@ async function initializeSecretStorage(
181181
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
182182
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
183183
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
184-
fetchMock.post("path:/_matrix/client/v3/keys/device_signing/upload", {});
185-
fetchMock.post("path:/_matrix/client/v3/keys/signatures/upload", {});
186184
const accountData: Map<string, object> = new Map();
187185
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
188186
const name = url.split("/").pop()!;
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("Room keys are successfully shared on invite", 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+
}

0 commit comments

Comments
 (0)