You've already forked matrix-js-sdk
mirror of
https://github.com/matrix-org/matrix-js-sdk.git
synced 2025-08-05 00:42:10 +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" };
|
const authDict = { type: "test" };
|
||||||
await bootstrapCrossSigning(authDict);
|
await bootstrapCrossSigning(authDict);
|
||||||
|
|
||||||
// check the cross-signing keys upload
|
// check that the cross-signing keys have been uploaded
|
||||||
expect(fetchMock.called("upload-keys")).toBeTruthy();
|
expect(fetchMock.called("upload-cross-signing-keys")).toBeTruthy();
|
||||||
const [, keysOpts] = fetchMock.lastCall("upload-keys")!;
|
const [, keysOpts] = fetchMock.lastCall("upload-cross-signing-keys")!;
|
||||||
const keysBody = JSON.parse(keysOpts!.body as string);
|
const keysBody = JSON.parse(keysOpts!.body as string);
|
||||||
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
expect(keysBody.auth).toEqual(authDict); // check uia dict was passed
|
||||||
// there should be a key of each type
|
// there should be a key of each type
|
||||||
@@ -225,9 +225,6 @@ describe("cross-signing", () => {
|
|||||||
await aliceClient.startClient();
|
await aliceClient.startClient();
|
||||||
await syncPromise(aliceClient);
|
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
|
// we expect the UserTrustStatusChanged event to be fired after the cross signing keys import
|
||||||
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
const userTrustStatusChangedPromise = new Promise<string>((resolve) =>
|
||||||
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
aliceClient.on(CryptoEvent.UserTrustStatusChanged, resolve),
|
||||||
@@ -420,15 +417,18 @@ describe("cross-signing", () => {
|
|||||||
return new Promise<any>((resolve) => {
|
return new Promise<any>((resolve) => {
|
||||||
fetchMock.post(
|
fetchMock.post(
|
||||||
{
|
{
|
||||||
url: new RegExp("/_matrix/client/v3/keys/device_signing/upload"),
|
url: new URL(
|
||||||
name: "upload-keys",
|
"/_matrix/client/v3/keys/device_signing/upload",
|
||||||
|
aliceClient.getHomeserverUrl(),
|
||||||
|
).toString(),
|
||||||
|
name: "upload-cross-signing-keys",
|
||||||
},
|
},
|
||||||
(url, options) => {
|
(url, options) => {
|
||||||
const content = JSON.parse(options.body as string);
|
const content = JSON.parse(options.body as string);
|
||||||
resolve(content);
|
resolve(content);
|
||||||
return {};
|
return {};
|
||||||
},
|
},
|
||||||
// Override the routes define in `mockSetupCrossSigningRequests`
|
// Override the route defined in E2EKeyReceiver
|
||||||
{ overwriteRoutes: true },
|
{ overwriteRoutes: true },
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
@@ -181,8 +181,6 @@ async function initializeSecretStorage(
|
|||||||
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
const e2eKeyReceiver = new E2EKeyReceiver(homeserverUrl);
|
||||||
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
const e2eKeyResponder = new E2EKeyResponder(homeserverUrl);
|
||||||
e2eKeyResponder.addKeyReceiver(userId, e2eKeyReceiver);
|
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();
|
const accountData: Map<string, object> = new Map();
|
||||||
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
fetchMock.get("glob:http://*/_matrix/client/v3/user/*/account_data/*", (url, opts) => {
|
||||||
const name = url.split("/").pop()!;
|
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 {};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
@@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
|
|||||||
limitations under the License.
|
limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import debugFunc from "debug";
|
import debugFunc, { type Debugger } from "debug";
|
||||||
import { type Debugger } from "debug";
|
|
||||||
import fetchMock from "fetch-mock-jest";
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
|
||||||
|
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
|
/** Interface implemented by classes that intercept `/keys/upload` requests from test clients to catch the uploaded keys
|
||||||
*
|
*
|
||||||
@@ -55,19 +56,27 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
private readonly debug: Debugger;
|
private readonly debug: Debugger;
|
||||||
|
|
||||||
private deviceKeys: IDeviceKeys | null = null;
|
private deviceKeys: IDeviceKeys | null = null;
|
||||||
|
private crossSigningKeys: CrossSigningKeys | null = null;
|
||||||
private oneTimeKeys: Record<string, IOneTimeKey> = {};
|
private oneTimeKeys: Record<string, IOneTimeKey> = {};
|
||||||
private readonly oneTimeKeysPromise: Promise<void>;
|
private readonly oneTimeKeysPromise: Promise<void>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Construct a new E2EKeyReceiver.
|
* Construct a new E2EKeyReceiver.
|
||||||
*
|
*
|
||||||
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
|
* It will immediately register an intercept of [`/keys/upload`][1], [`/keys/signatures/upload`][2] and
|
||||||
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
|
* [`/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.
|
* client and have the keys collected separately.
|
||||||
*
|
*
|
||||||
* @param homeserverUrl - the Homeserver Url of the client under test.
|
* [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.
|
||||||
|
* @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}]`);
|
this.debug = debugFunc(`e2e-key-receiver:[${homeserverUrl}]`);
|
||||||
|
|
||||||
// set up a listener for /keys/upload.
|
// set up a listener for /keys/upload.
|
||||||
@@ -77,6 +86,22 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
|
|
||||||
fetchMock.post(new URL("/_matrix/client/v3/keys/upload", homeserverUrl).toString(), listener);
|
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: routeNamePrefix + "upload-sigs",
|
||||||
|
},
|
||||||
|
(url, options) => this.onSignaturesUploadRequest(options),
|
||||||
|
);
|
||||||
|
|
||||||
|
fetchMock.post(
|
||||||
|
{
|
||||||
|
url: new URL("/_matrix/client/v3/keys/device_signing/upload", homeserverUrl).toString(),
|
||||||
|
name: routeNamePrefix + "upload-cross-signing-keys",
|
||||||
|
},
|
||||||
|
(url, options) => this.onSigningKeyUploadRequest(options),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
|
private async onKeyUploadRequest(onOnTimeKeysUploaded: () => void, options: RequestInit): Promise<object> {
|
||||||
@@ -87,8 +112,10 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
if (this.deviceKeys) {
|
if (this.deviceKeys) {
|
||||||
throw new Error("Application attempted to upload E2E device keys multiple times");
|
throw new Error("Application attempted to upload E2E device keys multiple times");
|
||||||
}
|
}
|
||||||
this.debug(`received device keys`);
|
|
||||||
this.deviceKeys = content.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) {
|
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
|
||||||
@@ -113,6 +140,47 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async onSignaturesUploadRequest(request: RequestInit): Promise<object> {
|
||||||
|
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<object> {
|
||||||
|
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
|
/** Get the uploaded Ed25519 key
|
||||||
*
|
*
|
||||||
* If device keys have not yet been uploaded, throws an error
|
* If device keys have not yet been uploaded, throws an error
|
||||||
@@ -150,6 +218,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
return this.deviceKeys;
|
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,
|
* If one-time keys have already been uploaded, return them. Otherwise,
|
||||||
* set up an expectation that the keys will be uploaded, and wait for
|
* set up an expectation that the keys will be uploaded, and wait for
|
||||||
@@ -161,4 +236,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
|
|||||||
await this.oneTimeKeysPromise;
|
await this.oneTimeKeysPromise;
|
||||||
return this.oneTimeKeys;
|
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];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -17,7 +17,7 @@ limitations under the License.
|
|||||||
import fetchMock from "fetch-mock-jest";
|
import fetchMock from "fetch-mock-jest";
|
||||||
|
|
||||||
import { MapWithDefault } from "../../src/utils";
|
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 IDeviceKeys } from "../../src/@types/crypto";
|
||||||
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
|
||||||
|
|
||||||
@@ -50,18 +50,14 @@ export class E2EKeyResponder {
|
|||||||
const content = JSON.parse(options.body as string);
|
const content = JSON.parse(options.body as string);
|
||||||
const usersToReturn = Object.keys(content["device_keys"]);
|
const usersToReturn = Object.keys(content["device_keys"]);
|
||||||
const response = {
|
const response = {
|
||||||
device_keys: {} as { [userId: string]: any },
|
device_keys: {},
|
||||||
master_keys: {} as { [userId: string]: any },
|
master_keys: {},
|
||||||
self_signing_keys: {} as { [userId: string]: any },
|
self_signing_keys: {},
|
||||||
user_signing_keys: {} as { [userId: string]: any },
|
user_signing_keys: {},
|
||||||
failures: {} as { [serverName: string]: any },
|
failures: {},
|
||||||
};
|
} as IDownloadKeyResult;
|
||||||
for (const user of usersToReturn) {
|
for (const user of usersToReturn) {
|
||||||
const userKeys = this.deviceKeysByUserByDevice.get(user);
|
// First see if we have an E2EKeyReceiver for this user, and if so, return any keys that have been uploaded
|
||||||
if (userKeys !== undefined) {
|
|
||||||
response.device_keys[user] = Object.fromEntries(userKeys.entries());
|
|
||||||
}
|
|
||||||
|
|
||||||
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
|
||||||
if (e2eKeyReceiver !== undefined) {
|
if (e2eKeyReceiver !== undefined) {
|
||||||
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
|
||||||
@@ -69,16 +65,27 @@ export class E2EKeyResponder {
|
|||||||
response.device_keys[user] ??= {};
|
response.device_keys[user] ??= {};
|
||||||
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
|
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)) {
|
if (this.masterKeysByUser.hasOwnProperty(user)) {
|
||||||
response.master_keys[user] = this.masterKeysByUser[user];
|
response.master_keys![user] = this.masterKeysByUser[user];
|
||||||
}
|
}
|
||||||
if (this.selfSigningKeysByUser.hasOwnProperty(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)) {
|
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
|
||||||
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
|
response.user_signing_keys![user] = this.userSigningKeysByUser[user];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return response;
|
return response;
|
||||||
|
73
spec/test-utils/E2EOTKClaimResponder.ts
Normal file
73
spec/test-utils/E2EOTKClaimResponder.ts
Normal file
@@ -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<string, Map<string, E2EKeyReceiver>>(() => 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);
|
||||||
|
}
|
||||||
|
}
|
@@ -43,11 +43,9 @@ 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 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 {
|
export function mockSetupCrossSigningRequests(): void {
|
||||||
// have account_data requests return an empty object
|
// have account_data requests return an empty object
|
||||||
@@ -55,19 +53,6 @@ export function mockSetupCrossSigningRequests(): void {
|
|||||||
status: 404,
|
status: 404,
|
||||||
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
|
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" }, {});
|
|
||||||
|
|
||||||
// ... 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",
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -1558,14 +1558,6 @@ describe("RustCrypto", () => {
|
|||||||
const e2eKeyReceiver = new E2EKeyReceiver("http://server");
|
const e2eKeyReceiver = new E2EKeyReceiver("http://server");
|
||||||
const e2eKeyResponder = new E2EKeyResponder("http://server");
|
const e2eKeyResponder = new E2EKeyResponder("http://server");
|
||||||
e2eKeyResponder.addKeyReceiver(TEST_USER, e2eKeyReceiver);
|
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: {},
|
|
||||||
});
|
|
||||||
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
await rustCrypto.bootstrapCrossSigning({ setupNewCrossSigning: true });
|
||||||
await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow(
|
await expect(rustCrypto.pinCurrentUserIdentity(TEST_USER)).rejects.toThrow(
|
||||||
"Cannot pin identity of own user",
|
"Cannot pin identity of own user",
|
||||||
@@ -1803,14 +1795,6 @@ describe("RustCrypto", () => {
|
|||||||
error: "Not found",
|
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: {},
|
|
||||||
});
|
|
||||||
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
const rustCrypto1 = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||||
|
|
||||||
// dehydration requires secret storage and cross signing
|
// dehydration requires secret storage and cross signing
|
||||||
@@ -1944,14 +1928,6 @@ describe("RustCrypto", () => {
|
|||||||
error: "Not found",
|
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: {},
|
|
||||||
});
|
|
||||||
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
rustCrypto = await makeTestRustCrypto(makeMatrixHttpApi(), TEST_USER, TEST_DEVICE_ID, secretStorage);
|
||||||
|
|
||||||
// dehydration requires secret storage and cross signing
|
// dehydration requires secret storage and cross signing
|
||||||
@@ -2370,6 +2346,76 @@ describe("RustCrypto", () => {
|
|||||||
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
expect(dehydratedDeviceIsDeleted).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("maybeAcceptKeyBundle", () => {
|
||||||
|
let mockOlmMachine: Mocked<OlmMachine>;
|
||||||
|
let rustCrypto: RustCrypto;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
mockOlmMachine = {
|
||||||
|
getReceivedRoomKeyBundleData: jest.fn(),
|
||||||
|
receiveRoomKeyBundle: jest.fn(),
|
||||||
|
} as unknown as Mocked<OlmMachine>;
|
||||||
|
|
||||||
|
const http = new MatrixHttpApi(new TypedEventEmitter<HttpApiEvent, HttpApiEventHandlerMap>(), {
|
||||||
|
baseUrl: "http://server/",
|
||||||
|
prefix: "",
|
||||||
|
onlyData: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
rustCrypto = new RustCrypto(
|
||||||
|
new DebugLogger(debug("matrix-js-sdk:test:rust-crypto.spec:maybeAcceptKeyBundle")),
|
||||||
|
mockOlmMachine,
|
||||||
|
http,
|
||||||
|
TEST_USER,
|
||||||
|
TEST_DEVICE_ID,
|
||||||
|
{} as ServerSideSecretStorage,
|
||||||
|
{} as CryptoCallbacks,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does nothing if there is no key bundle", async () => {
|
||||||
|
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(undefined);
|
||||||
|
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||||
|
expect(mockOlmMachine.getReceivedRoomKeyBundleData).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][0].toString()).toEqual("!room_id");
|
||||||
|
expect(mockOlmMachine.getReceivedRoomKeyBundleData.mock.calls[0][1].toString()).toEqual("@bob:example.org");
|
||||||
|
|
||||||
|
expect(mockOlmMachine.receiveRoomKeyBundle).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the bundle via http and throws an error on failure", async () => {
|
||||||
|
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||||
|
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||||
|
|
||||||
|
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||||
|
status: 404,
|
||||||
|
body: {
|
||||||
|
errcode: "M_NOT_FOUND",
|
||||||
|
error: "Not found",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(() => rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org")).rejects.toMatchObject({
|
||||||
|
errcode: "M_NOT_FOUND",
|
||||||
|
httpStatus: 404,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the bundle via http and passes it back into the OlmMachine", async () => {
|
||||||
|
const bundleData = { url: "mxc://server/data" } as RustSdkCryptoJs.StoredRoomKeyBundleData;
|
||||||
|
mockOlmMachine.getReceivedRoomKeyBundleData.mockResolvedValue(bundleData);
|
||||||
|
|
||||||
|
fetchMock.get("http://server/_matrix/client/v1/media/download/server/data?allow_redirect=true", {
|
||||||
|
body: "asdfghjkl",
|
||||||
|
});
|
||||||
|
|
||||||
|
await rustCrypto.maybeAcceptKeyBundle("!room_id", "@bob:example.org");
|
||||||
|
expect(mockOlmMachine.receiveRoomKeyBundle).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][0]).toBe(bundleData);
|
||||||
|
expect(mockOlmMachine.receiveRoomKeyBundle.mock.calls[0][1]).toEqual(new TextEncoder().encode("asdfghjkl"));
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Build a MatrixHttpApi instance */
|
/** Build a MatrixHttpApi instance */
|
||||||
|
@@ -41,6 +41,14 @@ export interface IJoinRoomOpts {
|
|||||||
* The server names to try and join through in addition to those that are automatically chosen.
|
* The server names to try and join through in addition to those that are automatically chosen.
|
||||||
*/
|
*/
|
||||||
viaServers?: string[];
|
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}. */
|
/** Options object for {@link MatrixClient.invite}. */
|
||||||
@@ -49,6 +57,15 @@ export interface InviteOpts {
|
|||||||
* The reason for the invite.
|
* The reason for the invite.
|
||||||
*/
|
*/
|
||||||
reason?: string;
|
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 {
|
export interface KnockRoomOpts {
|
||||||
|
@@ -223,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts";
|
|||||||
import {
|
import {
|
||||||
type CrossSigningKeyInfo,
|
type CrossSigningKeyInfo,
|
||||||
type CryptoApi,
|
type CryptoApi,
|
||||||
|
type CryptoCallbacks,
|
||||||
CryptoEvent,
|
CryptoEvent,
|
||||||
type CryptoEventHandlerMap,
|
type CryptoEventHandlerMap,
|
||||||
type CryptoCallbacks,
|
|
||||||
} from "./crypto-api/index.ts";
|
} from "./crypto-api/index.ts";
|
||||||
import {
|
import {
|
||||||
type SecretStorageKeyDescription,
|
type SecretStorageKeyDescription,
|
||||||
@@ -2371,7 +2371,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
*/
|
*/
|
||||||
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
|
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
|
||||||
const room = this.getRoom(roomIdOrAlias);
|
const room = this.getRoom(roomIdOrAlias);
|
||||||
if (room?.hasMembershipState(this.credentials.userId!, KnownMembership.Join)) return room;
|
const roomMember = room?.getMember(this.getSafeUserId());
|
||||||
|
const preJoinMembership = roomMember?.membership;
|
||||||
|
|
||||||
|
// If we were invited to the room, the ID of the user that sent the invite. Otherwise, `null`.
|
||||||
|
const inviter =
|
||||||
|
preJoinMembership == KnownMembership.Invite ? (roomMember?.events.member?.getSender() ?? null) : null;
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`joinRoom[${roomIdOrAlias}]: preJoinMembership=${preJoinMembership}, inviter=${inviter}, opts=${JSON.stringify(opts)}`,
|
||||||
|
);
|
||||||
|
if (preJoinMembership == KnownMembership.Join) return room!;
|
||||||
|
|
||||||
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
|
let signPromise: Promise<IThirdPartySigned | void> = Promise.resolve();
|
||||||
|
|
||||||
@@ -2398,6 +2408,10 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
const res = await this.http.authedRequest<{ room_id: string }>(Method.Post, path, queryParams, data);
|
||||||
|
|
||||||
const roomId = res.room_id;
|
const roomId = res.room_id;
|
||||||
|
if (opts.acceptSharedHistory && inviter && this.cryptoBackend) {
|
||||||
|
await this.cryptoBackend.maybeAcceptKeyBundle(roomId, inviter);
|
||||||
|
}
|
||||||
|
|
||||||
// In case we were originally given an alias, check the room cache again
|
// 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
|
// with the resolved ID - this method is supposed to no-op if we already
|
||||||
// were in the room, after all.
|
// were in the room, after all.
|
||||||
@@ -3764,11 +3778,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
|
|||||||
*
|
*
|
||||||
* @returns An empty object.
|
* @returns An empty object.
|
||||||
*/
|
*/
|
||||||
public invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
|
public async invite(roomId: string, userId: string, opts: InviteOpts | string = {}): Promise<EmptyObject> {
|
||||||
if (typeof opts != "object") {
|
if (typeof opts != "object") {
|
||||||
opts = { reason: opts };
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
|
|||||||
* @returns a promise which resolves once the keys have been imported
|
* @returns a promise which resolves once the keys have been imported
|
||||||
*/
|
*/
|
||||||
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
|
importBackedUpRoomKeys(keys: IMegolmSessionData[], backupVersion: string, opts?: ImportRoomKeysOpts): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The methods which crypto implementations should expose to the Sync api
|
/** The methods which crypto implementations should expose to the Sync api
|
||||||
|
@@ -729,6 +729,20 @@ export interface CryptoApi {
|
|||||||
* @param secrets - The secrets bundle received from the other device
|
* @param secrets - The secrets bundle received from the other device
|
||||||
*/
|
*/
|
||||||
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
importSecretsBundle?(secrets: Awaited<ReturnType<SecretsBundle["to_json"]>>): Promise<void>;
|
||||||
|
|
||||||
|
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||||
|
//
|
||||||
|
// 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<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** A reason code for a failure to decrypt an event. */
|
/** A reason code for a failure to decrypt an event. */
|
||||||
|
@@ -20,7 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
|
|||||||
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
|
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
|
||||||
import { KnownMembership } from "../@types/membership.ts";
|
import { KnownMembership } from "../@types/membership.ts";
|
||||||
import { type IDeviceLists, type IToDeviceEvent, type ReceivedToDeviceMessage } from "../sync-accumulator.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 MatrixEvent, MatrixEventEvent } from "../models/event.ts";
|
||||||
import { type Room } from "../models/room.ts";
|
import { type Room } from "../models/room.ts";
|
||||||
import { type RoomMember } from "../models/room-member.ts";
|
import { type RoomMember } from "../models/room-member.ts";
|
||||||
@@ -37,6 +37,7 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
|
|||||||
import { KeyClaimManager } from "./KeyClaimManager.ts";
|
import { KeyClaimManager } from "./KeyClaimManager.ts";
|
||||||
import { MapWithDefault } from "../utils.ts";
|
import { MapWithDefault } from "../utils.ts";
|
||||||
import {
|
import {
|
||||||
|
AllDevicesIsolationMode,
|
||||||
type BackupTrustInfo,
|
type BackupTrustInfo,
|
||||||
type BootstrapCrossSigningOpts,
|
type BootstrapCrossSigningOpts,
|
||||||
type CreateSecretStorageOpts,
|
type CreateSecretStorageOpts,
|
||||||
@@ -45,29 +46,28 @@ import {
|
|||||||
type CrossSigningStatus,
|
type CrossSigningStatus,
|
||||||
type CryptoApi,
|
type CryptoApi,
|
||||||
type CryptoCallbacks,
|
type CryptoCallbacks,
|
||||||
|
CryptoEvent,
|
||||||
|
type CryptoEventHandlerMap,
|
||||||
DecryptionFailureCode,
|
DecryptionFailureCode,
|
||||||
|
deriveRecoveryKeyFromPassphrase,
|
||||||
|
type DeviceIsolationMode,
|
||||||
|
DeviceIsolationModeKind,
|
||||||
DeviceVerificationStatus,
|
DeviceVerificationStatus,
|
||||||
|
encodeRecoveryKey,
|
||||||
type EventEncryptionInfo,
|
type EventEncryptionInfo,
|
||||||
EventShieldColour,
|
EventShieldColour,
|
||||||
EventShieldReason,
|
EventShieldReason,
|
||||||
type GeneratedSecretStorageKey,
|
type GeneratedSecretStorageKey,
|
||||||
type ImportRoomKeysOpts,
|
type ImportRoomKeysOpts,
|
||||||
|
ImportRoomKeyStage,
|
||||||
type KeyBackupCheck,
|
type KeyBackupCheck,
|
||||||
type KeyBackupInfo,
|
type KeyBackupInfo,
|
||||||
type OwnDeviceKeys,
|
|
||||||
UserVerificationStatus,
|
|
||||||
type VerificationRequest,
|
|
||||||
encodeRecoveryKey,
|
|
||||||
deriveRecoveryKeyFromPassphrase,
|
|
||||||
type DeviceIsolationMode,
|
|
||||||
AllDevicesIsolationMode,
|
|
||||||
DeviceIsolationModeKind,
|
|
||||||
CryptoEvent,
|
|
||||||
type CryptoEventHandlerMap,
|
|
||||||
type KeyBackupRestoreOpts,
|
type KeyBackupRestoreOpts,
|
||||||
type KeyBackupRestoreResult,
|
type KeyBackupRestoreResult,
|
||||||
|
type OwnDeviceKeys,
|
||||||
type StartDehydrationOpts,
|
type StartDehydrationOpts,
|
||||||
ImportRoomKeyStage,
|
UserVerificationStatus,
|
||||||
|
type VerificationRequest,
|
||||||
} from "../crypto-api/index.ts";
|
} from "../crypto-api/index.ts";
|
||||||
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
|
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
|
||||||
import { type IDownloadKeyResult, type IQueryKeysRequest } from "../client.ts";
|
import { type IDownloadKeyResult, type IQueryKeysRequest } from "../client.ts";
|
||||||
@@ -94,6 +94,7 @@ import { DehydratedDeviceManager } from "./DehydratedDeviceManager.ts";
|
|||||||
import { VerificationMethod } from "../types.ts";
|
import { VerificationMethod } from "../types.ts";
|
||||||
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
|
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
|
||||||
import { type UIAuthCallback } from "../interactive-auth.ts";
|
import { type UIAuthCallback } from "../interactive-auth.ts";
|
||||||
|
import { getHttpUriForMxc } from "../content-repo.ts";
|
||||||
|
|
||||||
const ALL_VERIFICATION_METHODS = [
|
const ALL_VERIFICATION_METHODS = [
|
||||||
VerificationMethod.Sas,
|
VerificationMethod.Sas,
|
||||||
@@ -321,6 +322,62 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
return await this.backupManager.importBackedUpRoomKeys(keys, backupVersion, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link CryptoBackend.maybeAcceptKeyBundle}.
|
||||||
|
*/
|
||||||
|
public async maybeAcceptKeyBundle(roomId: string, inviter: string): Promise<void> {
|
||||||
|
// 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<Blob>(
|
||||||
|
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
|
// CryptoApi implementation
|
||||||
@@ -1474,6 +1531,54 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
|
|||||||
await this.secretStorage.setDefaultKeyId(null);
|
await this.secretStorage.setDefaultKeyId(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Implementation of {@link CryptoApi#shareRoomHistoryWithUser}.
|
||||||
|
*/
|
||||||
|
public async shareRoomHistoryWithUser(roomId: string, userId: string): Promise<void> {
|
||||||
|
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
|
// SyncCryptoCallbacks implementation
|
||||||
|
Reference in New Issue
Block a user