1
0
mirror of https://github.com/matrix-org/matrix-js-sdk.git synced 2025-11-25 05:23:13 +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

@@ -41,6 +41,14 @@ export interface IJoinRoomOpts {
* The server names to try and join through in addition to those that are automatically chosen.
*/
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}. */
@@ -49,6 +57,15 @@ export interface InviteOpts {
* The reason for the invite.
*/
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 {

View File

@@ -223,9 +223,9 @@ import { RUST_SDK_STORE_PREFIX } from "./rust-crypto/constants.ts";
import {
type CrossSigningKeyInfo,
type CryptoApi,
type CryptoCallbacks,
CryptoEvent,
type CryptoEventHandlerMap,
type CryptoCallbacks,
} from "./crypto-api/index.ts";
import {
type SecretStorageKeyDescription,
@@ -2371,7 +2371,17 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*/
public async joinRoom(roomIdOrAlias: string, opts: IJoinRoomOpts = {}): Promise<Room> {
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();
@@ -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 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
// with the resolved ID - this method is supposed to no-op if we already
// were in the room, after all.
@@ -3764,11 +3778,16 @@ export class MatrixClient extends TypedEventEmitter<EmittedEvents, ClientEventHa
*
* @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") {
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);
}
/**

View File

@@ -79,6 +79,19 @@ export interface CryptoBackend extends SyncCryptoCallbacks, CryptoApi {
* @returns a promise which resolves once the keys have been imported
*/
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

View File

@@ -729,6 +729,20 @@ export interface CryptoApi {
* @param secrets - The secrets bundle received from the other device
*/
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. */

View File

@@ -20,7 +20,7 @@ import * as RustSdkCryptoJs from "@matrix-org/matrix-sdk-crypto-wasm";
import type { IEventDecryptionResult, IMegolmSessionData } from "../@types/crypto.ts";
import { KnownMembership } from "../@types/membership.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 Room } from "../models/room.ts";
import { type RoomMember } from "../models/room-member.ts";
@@ -37,6 +37,7 @@ import { OutgoingRequestProcessor } from "./OutgoingRequestProcessor.ts";
import { KeyClaimManager } from "./KeyClaimManager.ts";
import { MapWithDefault } from "../utils.ts";
import {
AllDevicesIsolationMode,
type BackupTrustInfo,
type BootstrapCrossSigningOpts,
type CreateSecretStorageOpts,
@@ -45,29 +46,28 @@ import {
type CrossSigningStatus,
type CryptoApi,
type CryptoCallbacks,
CryptoEvent,
type CryptoEventHandlerMap,
DecryptionFailureCode,
deriveRecoveryKeyFromPassphrase,
type DeviceIsolationMode,
DeviceIsolationModeKind,
DeviceVerificationStatus,
encodeRecoveryKey,
type EventEncryptionInfo,
EventShieldColour,
EventShieldReason,
type GeneratedSecretStorageKey,
type ImportRoomKeysOpts,
ImportRoomKeyStage,
type KeyBackupCheck,
type KeyBackupInfo,
type OwnDeviceKeys,
UserVerificationStatus,
type VerificationRequest,
encodeRecoveryKey,
deriveRecoveryKeyFromPassphrase,
type DeviceIsolationMode,
AllDevicesIsolationMode,
DeviceIsolationModeKind,
CryptoEvent,
type CryptoEventHandlerMap,
type KeyBackupRestoreOpts,
type KeyBackupRestoreResult,
type OwnDeviceKeys,
type StartDehydrationOpts,
ImportRoomKeyStage,
UserVerificationStatus,
type VerificationRequest,
} from "../crypto-api/index.ts";
import { deviceKeysToDeviceMap, rustDeviceToJsDevice } from "./device-converter.ts";
import { type IDownloadKeyResult, type IQueryKeysRequest } from "../client.ts";
@@ -94,6 +94,7 @@ import { DehydratedDeviceManager } from "./DehydratedDeviceManager.ts";
import { VerificationMethod } from "../types.ts";
import { keyFromAuthData } from "../common-crypto/key-passphrase.ts";
import { type UIAuthCallback } from "../interactive-auth.ts";
import { getHttpUriForMxc } from "../content-repo.ts";
const ALL_VERIFICATION_METHODS = [
VerificationMethod.Sas,
@@ -321,6 +322,62 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
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
@@ -1474,6 +1531,54 @@ export class RustCrypto extends TypedEventEmitter<RustCryptoEvents, CryptoEventH
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