From 7b25a50a5119e1ced8d5b9c54f4689668da0cb46 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 17 Mar 2025 12:54:19 +0000 Subject: [PATCH 1/5] sdk: add `Room::share_history` The next step in our work on sharing encrypted room history. Add a method to `matrix_sdk::room::Room` which will upload an encrypted key bundle. --- .../src/types/room_history.rs | 7 +++ crates/matrix-sdk/src/room/mod.rs | 23 +++++++- .../src/room/shared_room_history.rs | 56 +++++++++++++++++++ 3 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 crates/matrix-sdk/src/room/shared_room_history.rs diff --git a/crates/matrix-sdk-crypto/src/types/room_history.rs b/crates/matrix-sdk-crypto/src/types/room_history.rs index 0e1e12a9025..6d834bef253 100644 --- a/crates/matrix-sdk-crypto/src/types/room_history.rs +++ b/crates/matrix-sdk-crypto/src/types/room_history.rs @@ -47,6 +47,13 @@ pub struct RoomKeyBundle { pub withheld: Vec, } +impl RoomKeyBundle { + /// Returns true if there is nothing of value in this bundle. + pub fn is_empty(&self) -> bool { + self.room_keys.is_empty() && self.withheld.is_empty() + } +} + /// An [`InboundGroupSession`] for sharing as part of a [`RoomKeyBundle`]. /// /// Note: unlike a room key received via an `m.room_key` message (i.e., a diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index fea3bd28142..ebb213d3f25 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -158,7 +158,10 @@ use crate::{ BaseRoom, Client, Error, HttpResult, Result, RoomState, TransmissionProgress, }; #[cfg(feature = "e2e-encryption")] -use crate::{crypto::types::events::CryptoContextInfo, encryption::backups::BackupState}; +use crate::{ + crypto::types::events::CryptoContextInfo, encryption::backups::BackupState, + room::shared_room_history::share_room_history, +}; pub mod edit; pub mod futures; @@ -173,6 +176,9 @@ pub mod reply; /// Contains all the functionality for modifying the privacy settings in a room. pub mod privacy_settings; +#[cfg(feature = "e2e-encryption")] +mod shared_room_history; + /// A struct containing methods that are common for Joined, Invited and Left /// Rooms #[derive(Debug, Clone)] @@ -1800,6 +1806,21 @@ impl Room { Ok(()) } + /// Share any shareable E2EE history in this room with the given recipient, + /// as per [MSC4268]. + /// + /// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268 + /// + /// This is temporarily exposed for integration testing as part of + /// experimental work on history sharing. In future, it will be combined + /// with sending an invite. + #[cfg(feature = "e2e-encryption")] + #[doc(hidden)] + #[instrument(skip_all, fields(room_id = ?self.room_id(), ?user_id))] + pub async fn share_history<'a>(&'a self, user_id: &UserId) -> Result<()> { + share_room_history(self, user_id.to_owned()).await + } + /// Wait for the room to be fully synced. /// /// This method makes sure the room that was returned when joining a room diff --git a/crates/matrix-sdk/src/room/shared_room_history.rs b/crates/matrix-sdk/src/room/shared_room_history.rs new file mode 100644 index 00000000000..9a13e8bee9e --- /dev/null +++ b/crates/matrix-sdk/src/room/shared_room_history.rs @@ -0,0 +1,56 @@ +// 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. + +use std::iter; + +use ruma::OwnedUserId; + +use crate::{Error, Result, Room}; + +/// Share any shareable E2EE history in the given room with the given recipient, +/// as per [MSC4268]. +/// +/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268 +pub async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> { + tracing::info!("Sharing message history in {} with {}", room.room_id(), user_id); + + // 1. Construct the key bundle + let bundle = { + let olm_machine = room.client.olm_machine().await; + let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?; + olm_machine.store().build_room_key_bundle(room.room_id()).await? + }; + + if bundle.is_empty() { + tracing::info!("No keys to share"); + return Ok(()); + } + + // 2. Upload to the server as an encrypted file + let json = serde_json::to_vec(&bundle)?; + let upload = + room.client.upload_encrypted_file(&mime::APPLICATION_JSON, &mut (json.as_slice())).await?; + + tracing::info!( + media_url = ?upload.url, + shared_keys = bundle.room_keys.len(), + withheld_keys = bundle.withheld.len(), + "Uploaded encrypted key blob" + ); + + // 3. Send to-device messages to the recipient to share the keys. + // TODO + + Ok(()) +} From 84a030aed0c48fa0667cedb02081a77f58f7c451 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 29 Mar 2025 15:33:43 +0000 Subject: [PATCH 2/5] crypto: Support for encrypting and sending room key history bundle data For each device belonging to the user, encrypt and send to-device messages containing the bundle data --- crates/matrix-sdk-crypto/src/machine/mod.rs | 21 +- .../src/session_manager/group_sessions/mod.rs | 302 +++++++++++++++++- .../matrix-sdk-crypto/src/types/events/mod.rs | 1 + .../src/types/events/room_key_bundle.rs | 39 +++ 4 files changed, 346 insertions(+), 17 deletions(-) create mode 100644 crates/matrix-sdk-crypto/src/types/events/room_key_bundle.rs diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 54b7da16f57..908e5249223 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -85,6 +85,7 @@ use crate::{ RoomEventEncryptionScheme, SupportedEventEncryptionSchemes, }, room_key::{MegolmV1AesSha2Content, RoomKeyContent}, + room_key_bundle::RoomKeyBundleContent, room_key_withheld::{ MegolmV1AesSha2WithheldContent, RoomKeyWithheldContent, RoomKeyWithheldEvent, }, @@ -98,8 +99,8 @@ use crate::{ }, utilities::timestamp_to_iso8601, verification::{Verification, VerificationMachine, VerificationRequest}, - CrossSigningKeyExport, CryptoStoreError, DecryptionSettings, DeviceData, LocalTrust, - RoomEventDecryptionResult, SignatureError, TrustRequirement, + CollectStrategy, CrossSigningKeyExport, CryptoStoreError, DecryptionSettings, DeviceData, + LocalTrust, RoomEventDecryptionResult, SignatureError, TrustRequirement, }; /// State machine implementation of the Olm/Megolm encryption protocol used for @@ -1089,6 +1090,22 @@ impl OlmMachine { self.inner.group_session_manager.share_room_key(room_id, users, encryption_settings).await } + /// 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. + pub async fn share_room_key_bundle_data( + &self, + user_id: &UserId, + collect_strategy: &CollectStrategy, + bundle_data: RoomKeyBundleContent, + ) -> OlmResult> { + self.inner + .group_session_manager + .share_room_key_bundle_data(user_id, collect_strategy, bundle_data) + .await + } + /// Receive an unencrypted verification event. /// /// This method can be used to pass verification events that are happening diff --git a/crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs b/crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs index 6ee633ccebc..efda93c4f5f 100644 --- a/crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs +++ b/crates/matrix-sdk-crypto/src/session_manager/group_sessions/mod.rs @@ -17,6 +17,8 @@ mod share_strategy; use std::{ collections::{BTreeMap, BTreeSet}, fmt::Debug, + iter, + iter::zip, sync::Arc, }; @@ -29,11 +31,13 @@ use ruma::{ events::{AnyMessageLikeEventContent, AnyToDeviceEventContent, ToDeviceEventType}, serde::Raw, to_device::DeviceIdOrAllDevices, - OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, TransactionId, UserId, + DeviceId, OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, TransactionId, + UserId, }; +use serde::Serialize; pub(crate) use share_strategy::CollectRecipientsResult; pub use share_strategy::CollectStrategy; -use tracing::{debug, error, info, instrument, trace}; +use tracing::{debug, error, info, instrument, trace, warn, Instrument}; use crate::{ error::{EventError, MegolmResult, OlmResult}, @@ -43,7 +47,14 @@ use crate::{ ShareInfo, ShareState, }, store::{Changes, CryptoStoreWrapper, Result as StoreResult, Store}, - types::{events::room::encrypted::RoomEncryptedEventContent, requests::ToDeviceRequest}, + types::{ + events::{ + room::encrypted::{RoomEncryptedEventContent, ToDeviceEncryptedEventContent}, + room_key_bundle::RoomKeyBundleContent, + EventType, + }, + requests::ToDeviceRequest, + }, Device, DeviceData, EncryptionSettings, OlmError, }; @@ -254,8 +265,12 @@ impl GroupSessionManager { } } - /// Encrypt the given content for the given devices and create to-device - /// requests that sends the encrypted content to them. + /// Encrypt the given group session key for the given devices and create + /// to-device requests that sends the encrypted content to them. + /// + /// See also [`encrypt_content_for_devices`] which is similar + /// but is not specific to group sessions, and does not return the + /// [`ShareInfo`] data. async fn encrypt_session_for( store: Arc, group_session: OutboundGroupSession, @@ -408,11 +423,7 @@ impl GroupSessionManager { ) -> OlmResult> { // If we have some recipients, log them here. if !recipient_devices.is_empty() { - #[allow(unknown_lints, clippy::unwrap_or_default)] // false positive - let recipients = recipient_devices.iter().fold(BTreeMap::new(), |mut acc, d| { - acc.entry(d.user_id()).or_insert_with(BTreeSet::new).insert(d.device_id()); - acc - }); + let recipients = recipient_list_to_users_and_devices(&recipient_devices); // If there are new recipients we need to persist the outbound group // session as the to-device requests are persisted with the session. @@ -746,9 +757,179 @@ impl GroupSessionManager { Ok(requests) } + + /// 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. + /// + /// For security reasons, only "safe" [`CollectStrategy`]s are supported, in + /// which the recipient must have signed their + /// devices. [`CollectStrategy::AllDevices`] and + /// [`CollectStrategy::ErrorOnVerifiedUserProblem`] are "unsafe" in this + /// respect,and are treated the same as + /// [`CollectStrategy::IdentityBasedStrategy`]. + #[instrument(skip(self, bundle_data))] + pub async fn share_room_key_bundle_data( + &self, + user_id: &UserId, + collect_strategy: &CollectStrategy, + bundle_data: RoomKeyBundleContent, + ) -> OlmResult> { + // Only allow conservative sharing strategies + let collect_strategy = match collect_strategy { + CollectStrategy::AllDevices | CollectStrategy::ErrorOnVerifiedUserProblem => { + warn!("Ignoring request to use unsafe sharing strategy {:?} for room key history sharing", collect_strategy); + &CollectStrategy::IdentityBasedStrategy + } + CollectStrategy::IdentityBasedStrategy | CollectStrategy::OnlyTrustedDevices => { + collect_strategy + } + }; + + let mut changes = Changes::default(); + + let CollectRecipientsResult { devices, .. } = + share_strategy::collect_recipients_for_share_strategy( + &self.store, + iter::once(user_id), + collect_strategy, + None, + ) + .await?; + + let devices = devices.into_values().flatten().collect(); + let event_type = bundle_data.event_type().to_owned(); + let (requests, _) = self + .encrypt_content_for_devices(devices, &event_type, bundle_data, &mut changes) + .await?; + + // TODO: figure out what to do with withheld devices + + // Persist any changes we might have collected. + if !changes.is_empty() { + let session_count = changes.sessions.len(); + + self.store.save_changes(changes).await?; + + trace!( + session_count = session_count, + "Stored the changed sessions after encrypting an room key" + ); + } + + Ok(requests) + } + + /// Encrypt the given content for the given devices and build to-device + /// requests to send the encrypted content to them. + /// + /// Returns a tuple containing (1) the list of to-device requests, and (2) + /// the list of devices that we could not find an olm session for (so + /// need a withheld message). + async fn encrypt_content_for_devices( + &self, + recipient_devices: Vec, + event_type: &str, + content: impl Serialize + Clone + Send + 'static, + changes: &mut Changes, + ) -> OlmResult<(Vec, Vec<(DeviceData, WithheldCode)>)> { + let recipients = recipient_list_to_users_and_devices(&recipient_devices); + info!(?recipients, "Encrypting content of type {}", event_type); + + // Chunk the recipients out so each to-device request will contain a + // limited amount of to-device messages. + // + // Create concurrent tasks for each chunk of recipients. + let tasks: Vec<_> = recipient_devices + .chunks(Self::MAX_TO_DEVICE_MESSAGES) + .map(|chunk| { + spawn( + encrypt_content_for_devices( + self.store.crypto_store(), + event_type.to_owned(), + content.clone(), + chunk.to_vec(), + ) + .in_current_span(), + ) + }) + .collect(); + + let mut no_olm_devices = Vec::new(); + let mut to_device_requests = Vec::new(); + + // Wait for all the tasks to finish up and queue up the Olm session that + // was used to encrypt the room key to be persisted again. This is + // needed because each encryption step will mutate the Olm session, + // ratcheting its state forward. + for result in join_all(tasks).await { + let result = result.expect("Encryption task panicked")?; + if let Some(request) = result.to_device_request { + to_device_requests.push(request); + } + changes.sessions.extend(result.updated_olm_sessions); + no_olm_devices.extend(result.no_olm_devices); + } + + Ok((to_device_requests, no_olm_devices)) + } +} + +/// Helper for [`GroupSessionManager::encrypt_content_for_devices`]. +/// +/// Encrypt the given content for the given devices and build a to-device +/// request to send the encrypted content to them. +/// +/// See also [`GroupSessionManager::encrypt_session_for`], which is similar +/// but applies specifically to `m.room_key` messages that hold a megolm +/// session key. +async fn encrypt_content_for_devices( + store: Arc, + event_type: String, + content: impl Serialize + Clone + Send + 'static, + devices: Vec, +) -> OlmResult { + let mut result_builder = EncryptForDevicesResultBuilder::default(); + + async fn encrypt( + store: Arc, + device: DeviceData, + event_type: String, + bundle_data: impl Serialize, + ) -> OlmResult<(Session, Raw)> { + device.encrypt(store.as_ref(), &event_type, bundle_data).await + } + + let tasks = devices.iter().map(|device| { + spawn( + encrypt(store.clone(), device.clone(), event_type.clone(), content.clone()) + .in_current_span(), + ) + }); + + let results = join_all(tasks).await; + + for (device, result) in zip(devices, results) { + let encryption_result = result.expect("Encryption task panicked"); + + match encryption_result { + Ok((used_session, message)) => { + result_builder.on_successful_encryption(&device, used_session, message.cast()); + } + Err(OlmError::MissingSession) => { + // There is no established Olm session for this device + result_builder.on_missing_session(device); + } + Err(e) => return Err(e), + } + } + + Ok(result_builder.into_result()) } -/// Result of [`GroupSessionManager::encrypt_session_for`] +/// Result of [`GroupSessionManager::encrypt_session_for`] and +/// [`encrypt_content_for_devices`]. #[derive(Debug)] struct EncryptForDevicesResult { /// The request to send the to-device messages containing the encrypted @@ -830,6 +1011,16 @@ impl EncryptForDevicesResultBuilder { } } +fn recipient_list_to_users_and_devices( + recipient_devices: &[DeviceData], +) -> BTreeMap<&UserId, BTreeSet<&DeviceId>> { + #[allow(unknown_lints, clippy::unwrap_or_default)] // false positive + recipient_devices.iter().fold(BTreeMap::new(), |mut acc, d| { + acc.entry(d.user_id()).or_insert_with(BTreeSet::new).insert(d.device_id()); + acc + }) +} + #[cfg(test)] mod tests { use std::{ @@ -848,21 +1039,27 @@ mod tests { to_device::send_event_to_device::v3::Response as ToDeviceResponse, }, device_id, - events::room::history_visibility::HistoryVisibility, - room_id, + events::room::{ + history_visibility::HistoryVisibility, EncryptedFileInit, JsonWebKey, JsonWebKeyInit, + }, + owned_room_id, room_id, + serde::Base64, to_device::DeviceIdOrAllDevices, - user_id, DeviceId, OneTimeKeyAlgorithm, TransactionId, UInt, UserId, + user_id, DeviceId, OneTimeKeyAlgorithm, OwnedMxcUri, TransactionId, UInt, UserId, }; use serde_json::{json, Value}; use crate::{ identities::DeviceData, - machine::EncryptionSyncChanges, + machine::{ + test_helpers::get_machine_pair_with_setup_sessions_test_helper, EncryptionSyncChanges, + }, olm::{Account, SenderData}, session_manager::{group_sessions::CollectRecipientsResult, CollectStrategy}, types::{ events::{ room::encrypted::EncryptedToDeviceEvent, + room_key_bundle::RoomKeyBundleContent, room_key_withheld::RoomKeyWithheldContent::{self, MegolmV1AesSha2}, }, requests::ToDeviceRequest, @@ -1555,4 +1752,79 @@ mod tests { assert_eq!(event_count, 0); } } + + #[async_test] + async fn test_room_key_bundle_sharing() { + let (alice, bob) = get_machine_pair_with_setup_sessions_test_helper( + user_id!("@alice:localhost"), + user_id!("@bob:localhost"), + false, + ) + .await; + + // Alice trusts Bob's device + let device = alice.get_device(bob.user_id(), bob.device_id(), None).await.unwrap().unwrap(); + device.set_local_trust(LocalTrust::Verified).await.unwrap(); + + let content = RoomKeyBundleContent { + room_id: owned_room_id!("!room:id"), + file: (EncryptedFileInit { + url: OwnedMxcUri::from("test"), + key: JsonWebKey::from(JsonWebKeyInit { + kty: "oct".to_owned(), + key_ops: vec!["encrypt".to_owned(), "decrypt".to_owned()], + alg: "A256CTR".to_owned(), + #[allow(clippy::unnecessary_to_owned)] + k: Base64::new(vec![0u8; 0]), + ext: true, + }), + iv: Base64::new(vec![0u8; 0]), + hashes: Default::default(), + v: "".to_owned(), + }) + .into(), + }; + + let requests = alice + .share_room_key_bundle_data( + bob.user_id(), + &CollectStrategy::OnlyTrustedDevices, + content, + ) + .await + .unwrap(); + + // There should be exactly one message + let requests: Vec<_> = + requests.iter().filter(|r| r.event_type == "m.room.encrypted".into()).collect(); + let message_count: usize = requests.iter().map(|r| r.message_count()).sum(); + assert_eq!(message_count, 1); + + // Bob decrypts the message + let bob_message = requests[0] + .messages + .get(bob.user_id()) + .unwrap() + .get(&(bob.device_id().to_owned().into())) + .unwrap(); + let to_device = EncryptedToDeviceEvent::new( + alice.user_id().to_owned(), + bob_message.cast_ref().deserialize().unwrap(), + ); + + let sync_changes = EncryptionSyncChanges { + to_device_events: vec![crate::utilities::json_convert(&to_device).unwrap()], + changed_devices: &Default::default(), + one_time_keys_counts: &Default::default(), + unused_fallback_keys: None, + next_batch_token: None, + }; + let (decrypted, _) = bob.receive_sync_changes(sync_changes).await.unwrap(); + assert_eq!(1, decrypted.len()); + use crate::types::events::EventType; + assert_eq!( + decrypted[0].get_field::("type").unwrap().unwrap(), + RoomKeyBundleContent::EVENT_TYPE, + ); + } } diff --git a/crates/matrix-sdk-crypto/src/types/events/mod.rs b/crates/matrix-sdk-crypto/src/types/events/mod.rs index bb9618d898c..5a2f06c1676 100644 --- a/crates/matrix-sdk-crypto/src/types/events/mod.rs +++ b/crates/matrix-sdk-crypto/src/types/events/mod.rs @@ -23,6 +23,7 @@ pub mod forwarded_room_key; pub mod olm_v1; pub mod room; pub mod room_key; +pub mod room_key_bundle; pub mod room_key_request; pub mod room_key_withheld; pub mod secret_send; diff --git a/crates/matrix-sdk-crypto/src/types/events/room_key_bundle.rs b/crates/matrix-sdk-crypto/src/types/events/room_key_bundle.rs new file mode 100644 index 00000000000..5a026ed522f --- /dev/null +++ b/crates/matrix-sdk-crypto/src/types/events/room_key_bundle.rs @@ -0,0 +1,39 @@ +// 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. + +//! Types for `io.element.msc4268.room_key_bundle` to-device events, per +//! [MSC4268]. +//! +//! [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268 + +use ruma::OwnedRoomId; +use serde::{Deserialize, Serialize}; + +use super::EventType; + +/// The `io.element.msc4268.room_key_bundle` event content. See [MSC4268]. +/// +/// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268 +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RoomKeyBundleContent { + /// The room that these keys are for + pub room_id: OwnedRoomId, + + /// The location and encryption info of the key bundle. + pub file: ruma::events::room::EncryptedFile, +} + +impl EventType for RoomKeyBundleContent { + const EVENT_TYPE: &'static str = "io.element.msc4268.room_key_bundle"; +} From f11158ab6c904a584b3a319cfb224d99d737e045 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Tue, 15 Apr 2025 12:10:12 +0100 Subject: [PATCH 3/5] sdk: send out the to-device requests created by `Room::share_history` --- .../src/room/shared_room_history.rs | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk/src/room/shared_room_history.rs b/crates/matrix-sdk/src/room/shared_room_history.rs index 9a13e8bee9e..225df848892 100644 --- a/crates/matrix-sdk/src/room/shared_room_history.rs +++ b/crates/matrix-sdk/src/room/shared_room_history.rs @@ -16,7 +16,7 @@ use std::iter; use ruma::OwnedUserId; -use crate::{Error, Result, Room}; +use crate::{crypto::types::events::room_key_bundle::RoomKeyBundleContent, Error, Result, Room}; /// Share any shareable E2EE history in the given room with the given recipient, /// as per [MSC4268]. @@ -24,10 +24,11 @@ use crate::{Error, Result, Room}; /// [MSC4268]: https://github.com/matrix-org/matrix-spec-proposals/pull/4268 pub async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> { tracing::info!("Sharing message history in {} with {}", room.room_id(), user_id); + let client = &room.client; // 1. Construct the key bundle let bundle = { - let olm_machine = room.client.olm_machine().await; + let olm_machine = client.olm_machine().await; let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?; olm_machine.store().build_room_key_bundle(room.room_id()).await? }; @@ -40,7 +41,7 @@ pub async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> // 2. Upload to the server as an encrypted file let json = serde_json::to_vec(&bundle)?; let upload = - room.client.upload_encrypted_file(&mime::APPLICATION_JSON, &mut (json.as_slice())).await?; + client.upload_encrypted_file(&mime::APPLICATION_JSON, &mut (json.as_slice())).await?; tracing::info!( media_url = ?upload.url, @@ -49,8 +50,26 @@ pub async fn share_room_history(room: &Room, user_id: OwnedUserId) -> Result<()> "Uploaded encrypted key blob" ); - // 3. Send to-device messages to the recipient to share the keys. - // TODO + // 3. Establish Olm sessions with all of the recipient's devices. + client.claim_one_time_keys(iter::once(user_id.as_ref())).await?; + // 4. Send to-device messages to the recipient to share the keys. + let content = RoomKeyBundleContent { room_id: room.room_id().to_owned(), file: upload }; + let requests = { + let olm_machine = client.olm_machine().await; + let olm_machine = olm_machine.as_ref().ok_or(Error::NoOlmMachine)?; + olm_machine + .share_room_key_bundle_data( + &user_id, + &client.base_client().room_key_recipient_strategy, + content, + ) + .await? + }; + + for request in requests { + let response = client.send_to_device(&request).await?; + client.mark_request_as_sent(&request.txn_id, &response).await?; + } Ok(()) } From 9ef1a040dd23a94344531a9510fc34baa2657d76 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Mon, 17 Mar 2025 13:05:11 +0000 Subject: [PATCH 4/5] test: add the start of an integration test for room history sharing This is only a partial test, since we haven't yet implemented the receiver side of the history-sharing messages. --- .../src/helpers.rs | 7 +- .../src/tests/e2ee.rs | 141 +++++++++++++++++- 2 files changed, 140 insertions(+), 8 deletions(-) diff --git a/testing/matrix-sdk-integration-testing/src/helpers.rs b/testing/matrix-sdk-integration-testing/src/helpers.rs index 18614c6ed4b..b34b0f94a76 100644 --- a/testing/matrix-sdk-integration-testing/src/helpers.rs +++ b/testing/matrix-sdk-integration-testing/src/helpers.rs @@ -16,6 +16,7 @@ use matrix_sdk::{ RoomId, }, sliding_sync::VersionBuilder, + sync::SyncResponse, Client, ClientBuilder, Room, }; use once_cell::sync::Lazy; @@ -185,7 +186,7 @@ impl SyncTokenAwareClient { Self { client, token: Arc::new(None.into()) } } - pub async fn sync_once(&self) -> Result<()> { + pub async fn sync_once(&self) -> Result { let mut settings = SyncSettings::default().timeout(Duration::from_secs(1)); let token = { self.token.lock().unwrap().clone() }; @@ -197,9 +198,9 @@ impl SyncTokenAwareClient { let mut prev_token = self.token.lock().unwrap(); if prev_token.as_ref() != Some(&response.next_batch) { - *prev_token = Some(response.next_batch); + *prev_token = Some(response.next_batch.clone()); } - Ok(()) + Ok(response) } } diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee.rs index f0896905e96..af4160b65f5 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/e2ee.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee.rs @@ -1,4 +1,5 @@ use std::{ + future::IntoFuture, sync::{Arc, Mutex}, time::Duration, }; @@ -9,7 +10,7 @@ use assert_matches2::assert_let; use assign::assign; use matrix_sdk::{ assert_next_eq_with_timeout, - crypto::{format_emojis, SasState}, + crypto::{format_emojis, SasState, UserDevices}, encryption::{ backups::BackupState, recovery::{Recovery, RecoveryState}, @@ -19,7 +20,10 @@ use matrix_sdk::{ BackupDownloadStrategy, EncryptionSettings, LocalTrust, }, ruma::{ - api::client::room::create_room::v3::Request as CreateRoomRequest, + api::client::{ + message::send_message_event, + room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, + }, events::{ key::verification::{request::ToDeviceKeyVerificationRequestEvent, VerificationMethod}, room::message::{ @@ -27,9 +31,10 @@ use matrix_sdk::{ SyncRoomMessageEvent, }, secret_storage::secret::SecretEventContent, - GlobalAccountDataEventType, OriginalSyncMessageLikeEvent, + GlobalAccountDataEventType, MessageLikeEventType, OriginalSyncMessageLikeEvent, }, - OwnedEventId, + serde::Raw, + OwnedEventId, TransactionId, UserId, }, timeout::timeout, Client, @@ -39,7 +44,7 @@ use matrix_sdk_ui::{ sync_service::SyncService, }; use similar_asserts::assert_eq; -use tracing::{debug, warn}; +use tracing::{debug, info, warn, Instrument}; use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; @@ -1175,3 +1180,129 @@ async fn test_recovery_disabling_deletes_secret_storage_secrets() -> Result<()> Ok(()) } + +/// When we invite another user to a room with "joined" history visibility, we +/// share the encryption history. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_history_share_on_invite() -> Result<()> { + let alice_span = tracing::info_span!("alice"); + let bob_span = tracing::info_span!("bob"); + + let encryption_settings = + EncryptionSettings { auto_enable_cross_signing: true, ..Default::default() }; + + let alice = TestClientBuilder::new("alice") + .use_sqlite() + .encryption_settings(encryption_settings) + .build() + .await?; + + let sync_service_span = tracing::info_span!(parent: &alice_span, "sync_service"); + let alice_sync_service = Arc::new( + SyncService::builder(alice.clone()) + .with_parent_span(sync_service_span) + .build() + .await + .expect("Could not build alice sync service"), + ); + + alice.encryption().wait_for_e2ee_initialization_tasks().await; + alice_sync_service.start().await; + + let bob = SyncTokenAwareClient::new( + TestClientBuilder::new("bob").encryption_settings(encryption_settings).build().await?, + ); + + { + // Alice and Bob share an encrypted room + // TODO: get rid of all of this: history sharing should work even if Bob and + // Alice do not share a room + let alice_shared_room = alice + .create_room(assign!(CreateRoomRequest::new(), {preset: Some(RoomPreset::PublicChat)})) + .await?; + let shared_room_id = alice_shared_room.room_id(); + alice_shared_room.enable_encryption().await?; + bob.join_room_by_id(shared_room_id) + .instrument(bob_span.clone()) + .await + .expect("Bob should have joined the room"); + + // Bob sends a message to trigger another sync from Alice, which causes her to + // send out the outgoing requests + // + // FIXME: this appears to be needed due to a bug in the sliding sync client, + // which means it does not send out outgoing requests caused by a + // /sync response + let request = send_message_event::v3::Request::new_raw( + shared_room_id.to_owned(), + TransactionId::new(), + MessageLikeEventType::Message, + Raw::new(&RoomMessageEventContent::text_plain("")).unwrap().cast(), + ); + bob.send(request).into_future().instrument(bob_span.clone()).await?; + + // Sanity check: Both users see the others' device + async fn devices_seen(client: &Client, other: &UserId) -> UserDevices { + client + .olm_machine_for_testing() + .await + .as_ref() + .unwrap() + .get_user_devices(other, Some(Duration::from_secs(1))) + .await + .unwrap() + } + + timeout( + async { + loop { + let bob_devices = devices_seen(&alice, bob.user_id().unwrap()).await; + if bob_devices.devices().count() >= 1 { + return; + } + } + }, + Duration::from_secs(30), // This can take quite a while to happen on the CI runners. + ) + .await + .expect("Alice did not see bob's device"); + + bob.sync_once().instrument(bob_span.clone()).await?; + let alice_devices = devices_seen(&bob, alice.user_id().unwrap()).await; + assert_eq!(alice_devices.devices().count(), 1, "Bob did not see Alice's device"); + } + + // Alice creates a room ... + let alice_room = alice + .create_room(assign!(CreateRoomRequest::new(), { + preset: Some(RoomPreset::PublicChat), + })) + .await?; + alice_room.enable_encryption().await?; + + info!(room_id = ?alice_room.room_id(), "Alice has created and enabled encryption in the room"); + + // ... and sends a message + alice_room + .send(RoomMessageEventContent::text_plain("Hello Bob")) + .await + .expect("We should be able to send a message to the room"); + + // Alice invites Bob to the room + // TODO: invite Bob rather than just call `share_history` + alice_room.share_history(bob.user_id().unwrap()).await?; + + let bob_response = bob.sync_once().instrument(bob_span.clone()).await?; + + // Bob should have received a to-device event with the payload + assert_eq!(bob_response.to_device.len(), 1); + let to_device_event = &bob_response.to_device[0]; + assert_eq!( + to_device_event.get_field::("type").unwrap().unwrap(), + "io.element.msc4268.room_key_bundle" + ); + + // TODO: ensure Bob can decrypt the content + + Ok(()) +} From 80db096be8c471f44574c1aa5a1edb0de6f9a689 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff Date: Sat, 29 Mar 2025 18:18:35 +0000 Subject: [PATCH 5/5] crypto: fix changelog --- crates/matrix-sdk-crypto/CHANGELOG.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-crypto/CHANGELOG.md b/crates/matrix-sdk-crypto/CHANGELOG.md index 40de97f40f8..2d8095da709 100644 --- a/crates/matrix-sdk-crypto/CHANGELOG.md +++ b/crates/matrix-sdk-crypto/CHANGELOG.md @@ -6,6 +6,9 @@ All notable changes to this project will be documented in this file. ## [Unreleased] - ReleaseDate +- Add experimental APIs for sharing encrypted room key history with new members, `Store::build_room_key_bundle` and `OlmMachine::share_room_key_bundle_data`. + ([#4775](https://github.com/matrix-org/matrix-rust-sdk/pull/4775), [#4864](https://github.com/matrix-org/matrix-rust-sdk/pull/4864)) + ## [0.11.0] - 2025-04-11 ### Features @@ -35,7 +38,7 @@ All notable changes to this project will be documented in this file. - Room keys are not shared with unsigned dehydrated devices. ([#4551](https://github.com/matrix-org/matrix-rust-sdk/pull/4551)) - + - Have the `RoomIdentityProvider` return processing changes when identities transition to `IdentityState::Verified` too. ([#4670](https://github.com/matrix-org/matrix-rust-sdk/pull/4670))