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
@ -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];
|
||||
}
|
||||
}
|
||||
|
@ -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;
|
||||
|
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 `{}` 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",
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
Reference in New Issue
Block a user