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/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.js b/tests/helper.js deleted file mode 100644 index a900027f8..000000000 --- a/tests/helper.js +++ /dev/null @@ -1,95 +0,0 @@ -const { DeviceLists, RequestType, KeysUploadRequest, KeysQueryRequest } = require("@matrix-org/matrix-sdk-crypto-wasm"); - -function* zip(...arrays) { - const len = Math.min(...arrays.map((array) => array.length)); - - for (let nth = 0; nth < len; ++nth) { - yield [...arrays.map((array) => array.at(nth))]; - } -} - -// Add a machine to another machine, i.e. be sure a machine knows -// another exists. -async function addMachineToMachine(machineToAdd, machine) { - const toDeviceEvents = JSON.stringify([]); - const changedDevices = new DeviceLists(); - const oneTimeKeyCounts = new Map(); - const unusedFallbackKeys = new Set(); - - const receiveSyncChanges = await machineToAdd.receiveSyncChanges( - toDeviceEvents, - changedDevices, - oneTimeKeyCounts, - unusedFallbackKeys, - ); - expect(receiveSyncChanges).toEqual([]); - - const outgoingRequests = await machineToAdd.outgoingRequests(); - - expect(outgoingRequests).toHaveLength(2); - - let keysUploadRequest; - // Read the `KeysUploadRequest`. - { - expect(outgoingRequests[0]).toBeInstanceOf(KeysUploadRequest); - expect(outgoingRequests[0].id).toBeDefined(); - expect(outgoingRequests[0].type).toStrictEqual(RequestType.KeysUpload); - expect(outgoingRequests[0].body).toBeDefined(); - - const body = JSON.parse(outgoingRequests[0].body); - expect(body.device_keys).toBeDefined(); - expect(body.one_time_keys).toBeDefined(); - - // https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3keysupload - const hypotheticalResponse = JSON.stringify({ - one_time_key_counts: { - curve25519: 10, - signed_curve25519: 20, - }, - }); - const marked = await machineToAdd.markRequestAsSent( - outgoingRequests[0].id, - outgoingRequests[0].type, - hypotheticalResponse, - ); - expect(marked).toStrictEqual(true); - - keysUploadRequest = outgoingRequests[0]; - } - - { - expect(outgoingRequests[1]).toBeInstanceOf(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: {}, - }; - const userId = machineToAdd.userId.toString(); - const deviceId = machineToAdd.deviceId.toString(); - keyQueryResponse.device_keys[userId] = {}; - keyQueryResponse.device_keys[userId][deviceId] = JSON.parse(keysUploadRequest.body).device_keys; - - const keys = JSON.parse(signingKeysUploadRequest.body); - keyQueryResponse.master_keys[userId] = keys.master_key; - keyQueryResponse.self_signing_keys[userId] = keys.self_signing_key; - keyQueryResponse.user_signing_keys[userId] = keys.user_signing_key; - - const marked = await machine.markRequestAsSent( - outgoingRequests[1].id, - outgoingRequests[1].type, - JSON.stringify(keyQueryResponse), - ); - expect(marked).toStrictEqual(true); - } -} - -module.exports = { - zip, - addMachineToMachine, -}; diff --git a/tests/helper.ts b/tests/helper.ts new file mode 100644 index 000000000..159608fb5 --- /dev/null +++ b/tests/helper.ts @@ -0,0 +1,219 @@ +/* +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, + KeysQueryRequest, + KeysUploadRequest, + OlmMachine, + RequestType, + RoomId, + ToDeviceRequest, + UserId, +} 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) { + yield [...arrays.map((array) => array.at(nth))]; + } +} + +// Add a machine to another machine, i.e. be sure a machine knows +// another exists. +export async function addMachineToMachine(machineToAdd: OlmMachine, machine: OlmMachine): Promise { + const toDeviceEvents = JSON.stringify([]); + const changedDevices = new DeviceLists(); + const oneTimeKeyCounts = new Map(); + const unusedFallbackKeys = new Set(); + + const receiveSyncChanges = await machineToAdd.receiveSyncChanges( + toDeviceEvents, + changedDevices, + oneTimeKeyCounts, + unusedFallbackKeys, + ); + expect(receiveSyncChanges).toEqual([]); + + const outgoingRequests = await machineToAdd.outgoingRequests(); + + 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(); + + const body = JSON.parse(outgoingRequests[0].body); + expect(body.device_keys).toBeDefined(); + expect(body.one_time_keys).toBeDefined(); + + // https://spec.matrix.org/v1.2/client-server-api/#post_matrixclientv3keysupload + const hypotheticalResponse = JSON.stringify({ + one_time_key_counts: { + curve25519: 10, + signed_curve25519: 20, + }, + }); + const marked = await machineToAdd.markRequestAsSent( + keysUploadRequest.id, + outgoingRequests[0].type, + hypotheticalResponse, + ); + expect(marked).toStrictEqual(true); + } + + { + expect(outgoingRequests[1]).toBeInstanceOf(KeysQueryRequest); + let keysQueryRequest = outgoingRequests[1] as KeysQueryRequest; + + 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 = { + 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(); + 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; + keyQueryResponse.user_signing_keys[userId] = keys.user_signing_key; + + const marked = await machine.markRequestAsSent( + keysQueryRequest.id, + keysQueryRequest.type, + JSON.stringify(keyQueryResponse), + ); + 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, + ); +} + +/** + * 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]; +} 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"],