You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-07-31 15:24:23 +03:00
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 <hubertc@matrix.org> --------- Co-authored-by: Hubert Chathi <hubertc@matrix.org>
This commit is contained in:
committed by
GitHub
parent
56b24c0bdc
commit
c4e1e0723e
@ -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 that 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
|
||||
@ -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<string>((resolve) =>
|
||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||
@ -420,15 +417,18 @@ describe("cross-signing", () => {
|
||||
return new Promise<any>((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 },
|
||||
);
|
||||
});
|
||||
|
@ -181,8 +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<string, object> = new Map();
|
||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||
const name = url.split("/").pop()!;
|
||||
|
239
spec/integ/crypto/history-sharing.spec.ts
Normal file
239
spec/integ/crypto/history-sharing.spec.ts
Normal file
@ -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("Room keys are successfully shared on invite", 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<Uint8Array>((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<IContent> {
|
||||
return new Promise<IContent>((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<Record<string, Record<string, object>>> {
|
||||
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 {};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user