1
0
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:
Richard van der Hoff
2025-07-29 16:42:35 +01:00
committed by GitHub
parent 56b24c0bdc
commit c4e1e0723e
13 changed files with 694 additions and 89 deletions

View File

@ -14,11 +14,12 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import debugFunc from "debug";
import { type Debugger } from "debug";
import debugFunc, { type Debugger } from "debug";
import fetchMock from "fetch-mock-jest";
import type { IDeviceKeys, IOneTimeKey } from "../../src/@types/crypto";
import type { CrossSigningKeys, 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
*
@ -55,19 +56,27 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
private readonly debug: Debugger;
private deviceKeys: IDeviceKeys | null = null;
private crossSigningKeys: CrossSigningKeys | null = null;
private oneTimeKeys: Record<string, IOneTimeKey> = {};
private readonly oneTimeKeysPromise: Promise<void>;
/**
* Construct a new E2EKeyReceiver.
*
* It will immediately register an intercept of `/keys/uploads` requests for the given homeserverUrl.
* Only /upload requests made to this server will be intercepted: this allows a single test to use more than one
* It will immediately register an intercept of [`/keys/upload`][1], [`/keys/signatures/upload`][2] and
* [`/keys/device_signing/upload`][3] requests for the given homeserverUrl.
* Only requests made to this server will be intercepted: this allows a single test to use more than one
* client and have the keys collected separately.
*
* @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}]`);
// 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(
{
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> {
@ -87,8 +112,10 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
if (this.deviceKeys) {
throw new Error("Application attempted to upload E2E device keys multiple times");
}
this.debug(`received device keys`);
this.deviceKeys = content.device_keys;
this.debug(
`received device keys for user ID ${this.deviceKeys!.user_id}, device ID ${this.deviceKeys!.device_id}`,
);
}
if (content.one_time_keys && Object.keys(content.one_time_keys).length > 0) {
@ -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
*
* If device keys have not yet been uploaded, throws an error
@ -150,6 +218,13 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
return this.deviceKeys;
}
/**
* If cross-signing keys have been uploaded, return them. Else return null.
*/
public getUploadedCrossSigningKeys(): CrossSigningKeys | null {
return this.crossSigningKeys;
}
/**
* If one-time keys have already been uploaded, return them. Otherwise,
* set up an expectation that the keys will be uploaded, and wait for
@ -161,4 +236,18 @@ export class E2EKeyReceiver implements IE2EKeyReceiver {
await this.oneTimeKeysPromise;
return this.oneTimeKeys;
}
/**
* If no one-time keys have yet been uploaded, return `null`.
* Otherwise, pop a key from the uploaded list.
*/
public getOneTimeKey(): [string, IOneTimeKey] | null {
const keys = Object.entries(this.oneTimeKeys);
if (keys.length == 0) {
return null;
}
const [otkId, otk] = keys[0];
delete this.oneTimeKeys[otkId];
return [otkId, otk];
}
}

View File

@ -17,7 +17,7 @@ limitations under the License.
import fetchMock from "fetch-mock-jest";
import { MapWithDefault } from "../../src/utils";
import { type IDownloadKeyResult } from "../../src";
import { type IDownloadKeyResult, type SigningKeys } from "../../src";
import { type IDeviceKeys } from "../../src/@types/crypto";
import { type E2EKeyReceiver } from "./E2EKeyReceiver";
@ -50,18 +50,14 @@ export class E2EKeyResponder {
const content = JSON.parse(options.body as string);
const usersToReturn = Object.keys(content["device_keys"]);
const response = {
device_keys: {} as { [userId: string]: any },
master_keys: {} as { [userId: string]: any },
self_signing_keys: {} as { [userId: string]: any },
user_signing_keys: {} as { [userId: string]: any },
failures: {} as { [serverName: string]: any },
};
device_keys: {},
master_keys: {},
self_signing_keys: {},
user_signing_keys: {},
failures: {},
} as IDownloadKeyResult;
for (const user of usersToReturn) {
const userKeys = this.deviceKeysByUserByDevice.get(user);
if (userKeys !== undefined) {
response.device_keys[user] = Object.fromEntries(userKeys.entries());
}
// First see if we have an E2EKeyReceiver for this user, and if so, return any keys that have been uploaded
const e2eKeyReceiver = this.e2eKeyReceiversByUser.get(user);
if (e2eKeyReceiver !== undefined) {
const deviceKeys = e2eKeyReceiver.getUploadedDeviceKeys();
@ -69,16 +65,27 @@ export class E2EKeyResponder {
response.device_keys[user] ??= {};
response.device_keys[user][deviceKeys.device_id] = deviceKeys;
}
const crossSigningKeys = e2eKeyReceiver.getUploadedCrossSigningKeys();
if (crossSigningKeys !== null) {
response.master_keys![user] = crossSigningKeys["master_key"];
response.self_signing_keys![user] = crossSigningKeys["self_signing_key"] as SigningKeys;
}
}
// Mix in any keys that have been added explicitly to this E2EKeyResponder.
const userKeys = this.deviceKeysByUserByDevice.get(user);
if (userKeys !== undefined) {
response.device_keys[user] ??= {};
Object.assign(response.device_keys[user], Object.fromEntries(userKeys.entries()));
}
if (this.masterKeysByUser.hasOwnProperty(user)) {
response.master_keys[user] = this.masterKeysByUser[user];
response.master_keys![user] = this.masterKeysByUser[user];
}
if (this.selfSigningKeysByUser.hasOwnProperty(user)) {
response.self_signing_keys[user] = this.selfSigningKeysByUser[user];
response.self_signing_keys![user] = this.selfSigningKeysByUser[user];
}
if (this.userSigningKeysByUser.hasOwnProperty(user)) {
response.user_signing_keys[user] = this.userSigningKeysByUser[user];
response.user_signing_keys![user] = this.userSigningKeysByUser[user];
}
}
return response;

View 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);
}
}

View File

@ -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 `{}` for `POST _matrix/client/v3/keys/signatures/upload` request (named `upload-sigs` for fetchMock check)
* Return `{}` for `POST /_matrix/client/(unstable|v3)/keys/device_signing/upload` request (named `upload-keys` for fetchMock check)
*/
export function mockSetupCrossSigningRequests(): void {
// have account_data requests return an empty object
@ -55,19 +53,6 @@ export function mockSetupCrossSigningRequests(): void {
status: 404,
body: { errcode: "M_NOT_FOUND", error: "Account data not found." },
});
// we expect a request to upload signatures for our device ...
fetchMock.post({ url: "path:/_matrix/client/v3/keys/signatures/upload", name: "upload-sigs" }, {});
// ... 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",
},
{},
);
}
/**