From 6d00023588ebfd3b3d74e3510a454209ef7c6bda Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 23 Jun 2025 20:54:16 +0100 Subject: [PATCH 1/3] Convert `helper.js` to typescript --- tests/{helper.js => helper.ts} | 55 +++++++++++++++++++++++----------- tsconfig.json | 4 ++- 2 files changed, 40 insertions(+), 19 deletions(-) rename tests/{helper.js => helper.ts} (67%) diff --git a/tests/helper.js b/tests/helper.ts similarity index 67% rename from tests/helper.js rename to tests/helper.ts index a900027f8..1a0a85c30 100644 --- a/tests/helper.js +++ b/tests/helper.ts @@ -1,6 +1,28 @@ -const { DeviceLists, RequestType, KeysUploadRequest, KeysQueryRequest } = require("@matrix-org/matrix-sdk-crypto-wasm"); - -function* zip(...arrays) { +/* +Copyright 2022-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 { + DeviceLists, + RequestType, + KeysUploadRequest, + KeysQueryRequest, + OlmMachine, +} from "@matrix-org/matrix-sdk-crypto-wasm"; + +export function* zip(...arrays: Array>): Generator { const len = Math.min(...arrays.map((array) => array.length)); for (let nth = 0; nth < len; ++nth) { @@ -10,7 +32,7 @@ function* zip(...arrays) { // Add a machine to another machine, i.e. be sure a machine knows // another exists. -async function addMachineToMachine(machineToAdd, machine) { +export async function addMachineToMachine(machineToAdd: OlmMachine, machine: OlmMachine): Promise { const toDeviceEvents = JSON.stringify([]); const changedDevices = new DeviceLists(); const oneTimeKeyCounts = new Map(); @@ -29,9 +51,12 @@ async function addMachineToMachine(machineToAdd, machine) { expect(outgoingRequests).toHaveLength(2); let keysUploadRequest; + // Read the `KeysUploadRequest`. { expect(outgoingRequests[0]).toBeInstanceOf(KeysUploadRequest); + keysUploadRequest = outgoingRequests[0] as KeysUploadRequest; + expect(outgoingRequests[0].id).toBeDefined(); expect(outgoingRequests[0].type).toStrictEqual(RequestType.KeysUpload); expect(outgoingRequests[0].body).toBeDefined(); @@ -48,27 +73,26 @@ async function addMachineToMachine(machineToAdd, machine) { }, }); const marked = await machineToAdd.markRequestAsSent( - outgoingRequests[0].id, + keysUploadRequest.id, outgoingRequests[0].type, hypotheticalResponse, ); expect(marked).toStrictEqual(true); - - keysUploadRequest = outgoingRequests[0]; } { expect(outgoingRequests[1]).toBeInstanceOf(KeysQueryRequest); + let keysQueryRequest = outgoingRequests[1] as KeysQueryRequest; let bootstrapCrossSigningResult = await machineToAdd.bootstrapCrossSigning(true); let signingKeysUploadRequest = bootstrapCrossSigningResult.uploadSigningKeysRequest; // Let's forge a `KeysQuery`'s response. let keyQueryResponse = { - device_keys: {}, - master_keys: {}, - self_signing_keys: {}, - user_signing_keys: {}, + device_keys: {} as Record, + master_keys: {} as Record, + self_signing_keys: {} as Record, + user_signing_keys: {} as Record, }; const userId = machineToAdd.userId.toString(); const deviceId = machineToAdd.deviceId.toString(); @@ -81,15 +105,10 @@ async function addMachineToMachine(machineToAdd, machine) { keyQueryResponse.user_signing_keys[userId] = keys.user_signing_key; const marked = await machine.markRequestAsSent( - outgoingRequests[1].id, - outgoingRequests[1].type, + keysQueryRequest.id, + keysQueryRequest.type, JSON.stringify(keyQueryResponse), ); expect(marked).toStrictEqual(true); } } - -module.exports = { - zip, - addMachineToMachine, -}; diff --git a/tsconfig.json b/tsconfig.json index 3c64f4579..b0b31d807 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,9 @@ "extends": "@tsconfig/node18/tsconfig.json", "compilerOptions": { "lib": ["DOM"], - "allowJs": true + "allowJs": true, + // Allow .ts file extensions in `import`, otherwise Jest can't find `helper.ts`. + "allowImportingTsExtensions": true }, "typedocOptions": { "entryPoints": ["index.d.ts"], From 6804f181e62d6045220fda74014510f5bfddf69f Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 30 Jun 2025 17:12:29 +0100 Subject: [PATCH 2/3] test: pull `forwardToDeviceMessage` out to `helper.ts` This is a useful helper for other tests. --- tests/device.test.js | 35 +---------------------------------- tests/helper.ts | 44 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 34 deletions(-) diff --git a/tests/device.test.js b/tests/device.test.js index 00165138f..c45e292c1 100644 --- a/tests/device.test.js +++ b/tests/device.test.js @@ -26,7 +26,7 @@ const { QrCode, QrCodeScan, } = require("@matrix-org/matrix-sdk-crypto-wasm"); -const { zip, addMachineToMachine } = require("./helper"); +const { zip, addMachineToMachine, forwardToDeviceMessage } = require("./helper"); const { VerificationRequestPhase, QrState } = require("@matrix-org/matrix-sdk-crypto-wasm"); // Uncomment to enable debug logging for tests @@ -1122,36 +1122,3 @@ describe("VerificationMethod", () => { expect(VerificationMethod.ReciprocateV1).toStrictEqual(3); }); }); - -/** - * Forward an outgoing to-device message returned by one OlmMachine into another OlmMachine. - */ -async function forwardToDeviceMessage(sendingUser, recipientMachine, outgoingVerificationRequest) { - expect(outgoingVerificationRequest).toBeInstanceOf(ToDeviceRequest); - await sendToDeviceMessageIntoMachine( - sendingUser, - outgoingVerificationRequest.event_type, - JSON.parse(outgoingVerificationRequest.body).messages[recipientMachine.userId.toString()][ - recipientMachine.deviceId.toString() - ], - recipientMachine, - ); -} - -/** - * Send a to-device message into an OlmMachine. - */ -async function sendToDeviceMessageIntoMachine(sendingUser, eventType, content, recipientMachine) { - await recipientMachine.receiveSyncChanges( - JSON.stringify([ - { - sender: sendingUser.toString(), - type: eventType, - content: content, - }, - ]), - new DeviceLists(), - new Map(), - undefined, - ); -} diff --git a/tests/helper.ts b/tests/helper.ts index 1a0a85c30..74607c4ee 100644 --- a/tests/helper.ts +++ b/tests/helper.ts @@ -19,7 +19,9 @@ import { RequestType, KeysUploadRequest, KeysQueryRequest, + ToDeviceRequest, OlmMachine, + UserId, } from "@matrix-org/matrix-sdk-crypto-wasm"; export function* zip(...arrays: Array>): Generator { @@ -112,3 +114,45 @@ export async function addMachineToMachine(machineToAdd: OlmMachine, machine: Olm expect(marked).toStrictEqual(true); } } + +/** + * Forward an outgoing to-device message returned by one OlmMachine into another OlmMachine. + */ +export async function forwardToDeviceMessage( + sendingUser: UserId, + recipientMachine: OlmMachine, + toDeviceRequest: ToDeviceRequest, +): Promise { + expect(toDeviceRequest).toBeInstanceOf(ToDeviceRequest); + await sendToDeviceMessageIntoMachine( + sendingUser, + toDeviceRequest.event_type, + JSON.parse(toDeviceRequest.body).messages[recipientMachine.userId.toString()][ + recipientMachine.deviceId.toString() + ], + recipientMachine, + ); +} + +/** + * Send a to-device message into an OlmMachine. + */ +export async function sendToDeviceMessageIntoMachine( + sendingUser: UserId, + eventType: string, + content: object, + recipientMachine: OlmMachine, +): Promise { + await recipientMachine.receiveSyncChanges( + JSON.stringify([ + { + sender: sendingUser.toString(), + type: eventType, + content: content, + }, + ]), + new DeviceLists(), + new Map(), + undefined, + ); +} From cf82febb7b5c3d0fd94db0099732d19ef55a8794 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 1 Jul 2025 10:48:19 +0100 Subject: [PATCH 3/3] Expose encrypted-history sharing --- CHANGELOG.md | 2 + src/machine.rs | 198 +++++++++++++++++++++++++++++- src/types.rs | 54 +++++++- tests/helper.ts | 67 +++++++++- tests/shared_room_history.test.ts | 162 ++++++++++++++++++++++++ 5 files changed, 471 insertions(+), 12 deletions(-) create mode 100644 tests/shared_room_history.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 166060e6e..0b154d232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,8 @@ - Add support for received room key bundle data, as required by encrypted history sharing ((MSC4268)[https://github.com/matrix-org/matrix-spec-proposals/pull/4268)). ([#5276](https://github.com/matrix-org/matrix-rust-sdk/pull/5276)) +- Expose experimental new functionality for sharing encrypted room history, per [MSC4268](https://github.com/matrix-org/matrix-spec-proposals/pull/4268). ([#250](https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/250)) + - Extend `OlmMachine` constructor functions to accept an optional `logger` instance. This logger is associated with the machine for its lifetime, and used instead of the global `console` to write any log messages from the underlying rust library. Similarly, the legacy store migration functions (in the `Migration` class), and the `StoreHandle` open functions are extended to accept an optional logger, to be used for the duration of that operation. ([#251](https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/251)) - Add a new error code for `MegolmDecryptionError`, `DecryptionErrorCode::MismatchedSender`, indicating that the sender of the event does not match the owner of the device that established the Megolm session. ([#248](https://github.com/matrix-org/matrix-rust-sdk-crypto-wasm/pull/248)) diff --git a/src/machine.rs b/src/machine.rs index fff74d22d..ab880182c 100644 --- a/src/machine.rs +++ b/src/machine.rs @@ -2,6 +2,7 @@ use std::{ collections::{BTreeMap, HashSet}, + io::{Cursor, Read}, iter, ops::Deref, pin::{pin, Pin}, @@ -11,23 +12,30 @@ use std::{ use futures_util::{pin_mut, Stream, StreamExt}; use js_sys::{Array, Function, JsString, Map, Promise, Set}; use matrix_sdk_common::ruma::{ - self, events::secret::request::SecretName, serde::Raw, OneTimeKeyAlgorithm, OwnedDeviceId, - OwnedTransactionId, OwnedUserId, UInt, + self, + events::{ + room::{EncryptedFile, EncryptedFileInit}, + secret::request::SecretName, + }, + serde::Raw, + OneTimeKeyAlgorithm, OwnedDeviceId, OwnedTransactionId, OwnedUserId, UInt, }; use matrix_sdk_crypto::{ backups::MegolmV1BackupKey, olm::{BackedUpRoomKey, ExportedRoomKey}, store::types::{DeviceChanges, IdentityChanges}, - types::RoomKeyBackupInfo, - CryptoStoreError, EncryptionSyncChanges, GossippedSecret, + types::{events::room_key_bundle::RoomKeyBundleContent, RoomKeyBackupInfo}, + CryptoStoreError, EncryptionSyncChanges, GossippedSecret, MediaEncryptionInfo, }; use serde::{ser::SerializeSeq, Serialize, Serializer}; use serde_json::json; -use tracing::{dispatcher, instrument::WithSubscriber, warn, Dispatch}; +use tracing::{dispatcher, info, instrument::WithSubscriber, warn, Dispatch}; use wasm_bindgen::{convert::TryFromJsValue, prelude::*}; use wasm_bindgen_futures::{spawn_local, JsFuture}; +use zeroize::Zeroizing; use crate::{ + attachment, backup::{BackupDecryptionKey, BackupKeys, RoomKeyCounts}, dehydrated_devices::DehydratedDevices, device, encryption, @@ -42,7 +50,7 @@ use crate::{ tracing::{logger_to_dispatcher, JsLogger}, types::{ self, processed_to_device_event_to_js_value, RoomKeyImportResult, RoomSettings, - SignatureVerification, + SignatureVerification, StoredRoomKeyBundleData, }, verification, vodozemac, }; @@ -1681,6 +1689,184 @@ impl OlmMachine { self.inner.dehydrated_devices().into() } + /// Assemble, and encrypt, a room key bundle for sharing encrypted history, + /// as per {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268}. + /// + /// Returns `undefined` if there are no keys to share in the given room, + /// otherwise an {@link EncryptedAttachment}. + /// + /// The data should be uploaded to the media server, and the details then + /// passed to {@link shareRoomKeyBundleData}. + /// + /// @experimental + #[wasm_bindgen( + js_name = "buildRoomKeyBundle", + unchecked_return_type = "Promise" + )] + pub fn build_room_key_bundle(&self, room_id: &identifiers::RoomId) -> Promise { + let _guard = dispatcher::set_default(&self.tracing_subscriber); + let me = self.inner.clone(); + let room_id = room_id.inner.clone(); + + future_to_promise(async move { + let bundle = me.store().build_room_key_bundle(&room_id).await?; + + if bundle.is_empty() { + info!("No keys to share"); + return Ok(None); + } + + // Remember to zeroize the json as soon as we're done with it + let json = Zeroizing::new(serde_json::to_vec(&bundle)?); + + Ok(Some(attachment::Attachment::encrypt(json.as_slice())?)) + }) + } + + /// Collect the devices belonging to the given user, and send the details + /// of a room key bundle to those devices. + /// + /// Returns a list of to-device requests which must be sent. + /// + /// @experimental + #[wasm_bindgen( + js_name = "shareRoomKeyBundleData", + unchecked_return_type = "Promise" + )] + pub fn share_room_key_bundle_data( + &self, + user: &identifiers::UserId, + room: &identifiers::RoomId, + url: &str, + media_encryption_info: Option, + sharing_strategy: encryption::CollectStrategy, + ) -> Result { + let _guard = dispatcher::set_default(&self.tracing_subscriber); + let me = self.inner.clone(); + let user_id = user.inner.clone(); + + let media_encryption_info = media_encryption_info.ok_or_else(|| { + // We accept Option to save a typescript assertion on the application + // side. In practice `build_room_key_bundle` should never return an + // EncryptedAttachment with nullish encryption_info. + JsError::new("shareRoomKeyBundleData: nullish encryption info") + })?; + + let media_encryption_info: MediaEncryptionInfo = + serde_json::from_str(&media_encryption_info).map_err(|e| { + JsError::new(&format!("Unable to validate room key media encryption info: {e}")) + })?; + + let url = url.try_into().map_err(|e| JsError::new(&format!("Invalid media url: {e}")))?; + + let bundle_data = RoomKeyBundleContent { + room_id: room.inner.clone(), + file: EncryptedFile::from(EncryptedFileInit { + url, + key: media_encryption_info.key, + iv: media_encryption_info.iv, + hashes: media_encryption_info.hashes, + v: media_encryption_info.version, + }), + }; + + Ok(future_to_promise(async move { + let to_device_requests = me + .share_room_key_bundle_data(&user_id, &sharing_strategy.into(), bundle_data) + .await?; + + // convert each request to our own ToDeviceRequest struct, and then wrap it in a + // JsValue. + // + // Then collect the results into a javascript Array, throwing any errors into + // the promise. + let result = to_device_requests + .into_iter() + .map(|td| ToDeviceRequest::try_from(&td).map(JsValue::from)) + .collect::>()?; + + Ok(result) + })) + } + + /// See if we have received an {@link https://github.com/matrix-org/matrix-spec-proposals/pull/4268|MSC4268} + /// room key bundle for the given room from the given user. + /// + /// Returns either `undefined` if no suitable bundle has been received, + /// or an {@link StoredRoomKeyBundleData}, in which case, the bundle + /// should be downloaded, and then passed to {@link + /// receiveRoomKeyBundle}. + /// + /// @experimental + #[wasm_bindgen( + js_name = "getReceivedRoomKeyBundleData", + unchecked_return_type = "Promise" + )] + pub fn get_received_room_key_bundle_data( + &self, + room_id: &identifiers::RoomId, + inviter: &identifiers::UserId, + ) -> Promise { + let _guard = dispatcher::set_default(&self.tracing_subscriber); + let me = self.inner.clone(); + let room_id = room_id.inner.clone(); + let inviter = inviter.inner.clone(); + + future_to_promise(async move { + let result = me + .store() + .get_received_room_key_bundle_data(&room_id, &inviter) + .await? + .map(types::StoredRoomKeyBundleData::from); + Ok(result) + }) + } + + /// Import the message keys from a downloaded room key bundle. + /// + /// After {@link getReceivedRoomKeyBundleData} returns a truthy result, the + /// media file should be downloaded and then passed into this method to + /// actually do the import. + /// + /// @experimental + #[wasm_bindgen(js_name = "receiveRoomKeyBundle", unchecked_return_type = "Promise")] + pub fn receive_room_key_bundle( + &self, + bundle_data: &StoredRoomKeyBundleData, + encrypted_bundle: Vec, + ) -> Result { + let _guard = dispatcher::set_default(&self.tracing_subscriber); + + let deserialized_bundle = { + let mut cursor = Cursor::new(encrypted_bundle.as_slice()); + let mut decryptor = matrix_sdk_crypto::AttachmentDecryptor::new( + &mut cursor, + serde_json::from_str(&bundle_data.encryption_info)?, + )?; + + let mut decrypted_bundle = Zeroizing::new(Vec::new()); + decryptor.read_to_end(&mut decrypted_bundle)?; + + serde_json::from_slice(&decrypted_bundle)? + }; + + let me = self.inner.clone(); + let bundle_data = bundle_data.clone(); + Ok(future_to_promise(async move { + me.store() + .receive_room_key_bundle( + &bundle_data.room_id.inner, + &bundle_data.sender_user.inner, + &bundle_data.sender_data, + deserialized_bundle, + /* TODO: Use the progress listener and expose an argument for it. */ + |_, _| {}, + ) + .await?; + Ok(JsValue::UNDEFINED) + })) + } + /// Shut down the `OlmMachine`. /// /// The `OlmMachine` cannot be used after this method has been called. diff --git a/src/types.rs b/src/types.rs index 2e8aa6969..cefe5981d 100644 --- a/src/types.rs +++ b/src/types.rs @@ -7,15 +7,19 @@ use std::{ use js_sys::{Array, JsString, Map, Set}; use matrix_sdk_common::ruma::OwnedRoomId; -use matrix_sdk_crypto::backups::{ - SignatureState as InnerSignatureState, SignatureVerification as InnerSignatureVerification, +use matrix_sdk_crypto::{ + backups::{ + SignatureState as InnerSignatureState, SignatureVerification as InnerSignatureVerification, + }, + olm::SenderData, + MediaEncryptionInfo, }; use tracing::warn; use wasm_bindgen::prelude::*; use crate::{ encryption::EncryptionAlgorithm, - identifiers::{DeviceKeyId, UserId}, + identifiers::{DeviceKeyId, RoomId, UserId}, impl_from_to_inner, responses::ToDeviceEncryptionInfo, vodozemac::Ed25519Signature, @@ -528,3 +532,47 @@ pub fn processed_to_device_event_to_js_value( }; Some(result) } + +/// Information on a stored room key bundle data event. +#[wasm_bindgen] +#[derive(Debug, Clone)] +pub struct StoredRoomKeyBundleData { + /// The user that sent us this data. + #[wasm_bindgen(readonly, getter_with_clone, js_name = "senderUser")] + pub sender_user: UserId, + + /// Information about the sender of this data and how much we trust that + /// information. + /// + /// This is held here because we need the `SenderData` back again in + /// [`OlmMachine::receive_room_key_bundle`]. It's not exposed over the wasm + /// boundary because `SenderData` doesn't implement `Into`. + pub(crate) sender_data: SenderData, + + /// The room that these keys are for. + #[wasm_bindgen(readonly, getter_with_clone, js_name = "roomId")] + pub room_id: RoomId, + + /// The location of the bundle. + #[wasm_bindgen(readonly, getter_with_clone)] + pub url: String, + + /// The JSON-encoded encryption info for the key bundle. + #[wasm_bindgen(readonly, getter_with_clone, js_name = "encryptionInfo")] + pub encryption_info: String, +} + +impl From for StoredRoomKeyBundleData { + fn from(value: matrix_sdk_crypto::store::types::StoredRoomKeyBundleData) -> Self { + let url = value.bundle_data.file.url.to_string(); + let encryption_info = + serde_json::to_string(&MediaEncryptionInfo::from(value.bundle_data.file)).unwrap(); + Self { + sender_user: value.sender_user.into(), + sender_data: value.sender_data, + room_id: value.bundle_data.room_id.into(), + url, + encryption_info, + } + } +} diff --git a/tests/helper.ts b/tests/helper.ts index 74607c4ee..159608fb5 100644 --- a/tests/helper.ts +++ b/tests/helper.ts @@ -16,11 +16,12 @@ limitations under the License. import { DeviceLists, - RequestType, - KeysUploadRequest, KeysQueryRequest, - ToDeviceRequest, + KeysUploadRequest, OlmMachine, + RequestType, + RoomId, + ToDeviceRequest, UserId, } from "@matrix-org/matrix-sdk-crypto-wasm"; @@ -88,6 +89,7 @@ export async function addMachineToMachine(machineToAdd: OlmMachine, machine: Olm let bootstrapCrossSigningResult = await machineToAdd.bootstrapCrossSigning(true); let signingKeysUploadRequest = bootstrapCrossSigningResult.uploadSigningKeysRequest; + const uploadSignaturesRequestBody = JSON.parse(bootstrapCrossSigningResult.uploadSignaturesRequest.body); // Let's forge a `KeysQuery`'s response. let keyQueryResponse = { @@ -101,6 +103,13 @@ export async function addMachineToMachine(machineToAdd: OlmMachine, machine: Olm keyQueryResponse.device_keys[userId] = {}; keyQueryResponse.device_keys[userId][deviceId] = JSON.parse(keysUploadRequest.body).device_keys; + // Hack in the cross-signing signature on the device from `bootstrapCrossSigning` + expect(uploadSignaturesRequestBody[userId][deviceId]).toBeDefined(); + Object.assign( + keyQueryResponse.device_keys[userId][deviceId].signatures[userId], + uploadSignaturesRequestBody[userId][deviceId].signatures[userId], + ); + const keys = JSON.parse(signingKeysUploadRequest.body); keyQueryResponse.master_keys[userId] = keys.master_key; keyQueryResponse.self_signing_keys[userId] = keys.self_signing_key; @@ -156,3 +165,55 @@ export async function sendToDeviceMessageIntoMachine( undefined, ); } + +/** + * Have `senderMachine` claim one of `receiverMachine`'s OTKs, to establish an olm session. + * + * Requires that `senderMachine` already know about `receiverMachine`, normally via + * `addMachineToMachine(receiverMachine, senderMachine)`. + */ +export async function establishOlmSession(senderMachine: OlmMachine, receiverMachine: OlmMachine) { + const keysClaimRequest = await senderMachine.getMissingSessions([receiverMachine.userId]); + const keysClaimRequestBody = JSON.parse(keysClaimRequest!.body); + expect(keysClaimRequestBody.one_time_keys).toEqual({ + [receiverMachine.userId.toString()]: { [receiverMachine.deviceId.toString()]: "signed_curve25519" }, + }); + + const outgoing = await receiverMachine.outgoingRequests(); + const keysUploadRequest = outgoing.find((req) => req instanceof KeysUploadRequest); + expect(keysUploadRequest).toBeDefined(); + const keysUploadRequestBody = JSON.parse(keysUploadRequest!.body); + const [otk_id, otk] = Object.entries(keysUploadRequestBody.one_time_keys)[0]; + + const keysClaimResponse = { + one_time_keys: { + [receiverMachine.userId.toString()]: { [receiverMachine.deviceId.toString()]: { [otk_id]: otk } }, + }, + }; + + await senderMachine.markRequestAsSent( + keysClaimRequest!.id, + RequestType.KeysClaim, + JSON.stringify(keysClaimResponse), + ); +} + +/** + * Encrypt an event using the given OlmMachine. Returns the JSON for the encrypted event. + * + * Assumes that a megolm session has already been established. + */ +export async function encryptEvent( + senderMachine: OlmMachine, + room: RoomId, + eventType: string, + eventContent: string, +): Promise { + return JSON.stringify({ + sender: senderMachine.userId.toString(), + event_id: `\$${Date.now()}:example.com`, + type: "m.room.encrypted", + origin_server_ts: Date.now(), + content: JSON.parse(await senderMachine.encryptRoomEvent(room, eventType, eventContent)), + }); +} diff --git a/tests/shared_room_history.test.ts b/tests/shared_room_history.test.ts new file mode 100644 index 000000000..581df2dde --- /dev/null +++ b/tests/shared_room_history.test.ts @@ -0,0 +1,162 @@ +/* +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 { + Attachment, + CollectStrategy, + DecryptionSettings, + DeviceId, + EncryptionSettings, + OlmMachine, + RoomId, + ToDeviceRequest, + TrustRequirement, + UserId, +} from "@matrix-org/matrix-sdk-crypto-wasm"; +import { addMachineToMachine, encryptEvent, establishOlmSession, forwardToDeviceMessage } from "./helper.ts"; + +import "fake-indexeddb/auto"; + +const room = new RoomId("!test:localhost"); +const otherUser = new UserId("@example:localhost"); +const otherUserDeviceId = new DeviceId("B"); + +afterEach(() => { + // reset fake-indexeddb after each test, to make sure we don't leak data + // cf https://github.com/dumbmatter/fakeIndexedDB#wipingresetting-the-indexeddb-for-a-fresh-state + // eslint-disable-next-line no-global-assign + indexedDB = new IDBFactory(); +}); + +describe("encrypted history sharing/decrypting", () => { + let senderMachine: OlmMachine; + let receiverMachine: OlmMachine; + + beforeEach(async () => { + // We need two devices, both cross-signed, where each knows about the other. + senderMachine = await OlmMachine.initialize(new UserId("@alice:example.org"), new DeviceId("A")); + receiverMachine = await OlmMachine.initialize(otherUser, otherUserDeviceId); + await addMachineToMachine(receiverMachine, senderMachine); + await addMachineToMachine(senderMachine, receiverMachine); + }); + + describe("buildRoomKeyBundle", () => { + test("returns `undefined` if there are no keys", async () => { + const bundle = await senderMachine.buildRoomKeyBundle(room); + expect(bundle).toBe(undefined); + }); + + test("creates a key bundle", async () => { + // Create the megolm session + await senderMachine.shareRoomKey(room, [], new EncryptionSettings()); + + // Now build the bundle, which should include the session. + const bundle = await senderMachine.buildRoomKeyBundle(room); + expect(bundle).toBeDefined(); + const decryptedBundle = JSON.parse(new TextDecoder().decode(Attachment.decrypt(bundle!))); + + expect(decryptedBundle.withheld).toEqual([]); + expect(decryptedBundle.room_keys).toHaveLength(1); + expect(decryptedBundle.room_keys[0].room_id).toEqual(room.toString()); + expect(decryptedBundle.room_keys[0].sender_claimed_keys).toEqual({ + ed25519: senderMachine.identityKeys.ed25519.toBase64(), + }); + expect(decryptedBundle.room_keys[0].sender_key).toEqual(senderMachine.identityKeys.curve25519.toBase64()); + }); + }); + + describe("shareKeyBundle", () => { + test("returns to-device messages", async () => { + // Create the megolm session and build a bundle so that we have something to share + await senderMachine.shareRoomKey(room, [], new EncryptionSettings()); + const bundle = await senderMachine.buildRoomKeyBundle(room); + + // Now initiate the share request + const request = await shareRoomKeyBundleData(senderMachine, receiverMachine, bundle!.mediaEncryptionInfo); + + expect(request.event_type).toEqual("m.room.encrypted"); + const requestBody = JSON.parse(request.body); + expect(requestBody.messages[otherUser.toString()][otherUserDeviceId.toString()]).toBeDefined(); + }); + }); + + describe("getReceivedRoomKeyBundleData", () => { + test("returns details about received room key bundles", async () => { + // Create the megolm session and build a bundle so that we have something to share + await senderMachine.shareRoomKey(room, [], new EncryptionSettings()); + const bundle = await senderMachine.buildRoomKeyBundle(room); + + // Share the bundle details with the recipient + const request = await shareRoomKeyBundleData(senderMachine, receiverMachine, bundle!.mediaEncryptionInfo); + await forwardToDeviceMessage(senderMachine.userId, receiverMachine, request); + + // The recipient should now know about the details of the bundle. + const data = await receiverMachine.getReceivedRoomKeyBundleData(room, senderMachine.userId); + expect(data).toBeDefined(); + expect(data!.senderUser.toString()).toEqual(senderMachine.userId.toString()); + expect(data!.url).toEqual("mxc://test/test"); + expect(data!.encryptionInfo).toEqual(bundle!.mediaEncryptionInfo); + }); + }); + + describe("receiveRoomKeyBundle", () => { + test("imports room keys", async () => { + // Create the megolm session and send a message that the recipient should be able to decrypt in the end. + await senderMachine.shareRoomKey(room, [], new EncryptionSettings()); + const encryptedEvent = await encryptEvent(senderMachine, room, "m.room.message", '{ "body": "Hi!" }'); + + // Build the bundle, and share details with the recipient + const bundle = await senderMachine.buildRoomKeyBundle(room); + + // Share the bundle details with the recipient + const request = await shareRoomKeyBundleData(senderMachine, receiverMachine, bundle!.mediaEncryptionInfo); + await forwardToDeviceMessage(senderMachine.userId, receiverMachine, request); + + // The recipient should now know about the details of the bundle. + const receivedBundleData = await receiverMachine.getReceivedRoomKeyBundleData(room, senderMachine.userId); + expect(receivedBundleData).toBeDefined(); + + // ... and receives the bundle itself. + await receiverMachine.receiveRoomKeyBundle(receivedBundleData!, bundle!.encryptedData); + + // The recipient should now be able to decrypt the encrypted event. + const decryptionSettings = new DecryptionSettings(TrustRequirement.Untrusted); + const decryptedData = await receiverMachine.decryptRoomEvent(encryptedEvent, room, decryptionSettings); + const decryptedEvent = JSON.parse(decryptedData.event); + expect(decryptedEvent.content.body).toEqual("Hi!"); + }); + }); +}); + +/** Make a to-device request for sharing a room key bundle from senderMachine to receiverMachine. */ +async function shareRoomKeyBundleData( + senderMachine: OlmMachine, + receiverMachine: OlmMachine, + mediaEncryptionInfo?: string, +): Promise { + await establishOlmSession(senderMachine, receiverMachine); + + const requests = await senderMachine.shareRoomKeyBundleData( + receiverMachine.userId, + room, + "mxc://test/test", + mediaEncryptionInfo, + CollectStrategy.identityBasedStrategy(), + ); + + expect(requests).toHaveLength(1); + return requests[0]; +}