From aa63adace51a3b3a285f7d2f47f3f3d435a197cf Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 12:47:04 +0100 Subject: [PATCH 01/19] feat(crypto): Add OutboundGroupSession::encrypt_state Signed-off-by: Skye Elliot --- Cargo.toml | 1 + .../src/olm/group_sessions/outbound.rs | 72 ++++++++++++++++++- 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 461ed46049b..2d4d1e7f0a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -68,6 +68,7 @@ ruma = { git = "https://github.com/ruma/ruma", rev = "e73f302e4df7f5f0511fca1aa4 "compat-lax-room-create-deser", "compat-lax-room-topic-deser", "unstable-msc3401", + "unstable-msc3414", "unstable-msc3488", "unstable-msc3489", "unstable-msc4075", diff --git a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs index c682516f434..9014375c097 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs @@ -27,8 +27,11 @@ use std::{ use matrix_sdk_common::{deserialized_responses::WithheldCode, locks::RwLock as StdRwLock}; use ruma::{ events::{ - room::{encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility}, - AnyMessageLikeEventContent, + room::{ + encrypted::unstable_state::StateRoomEncryptedEventContent, + encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility, + }, + AnyMessageLikeEventContent, AnyStateEventContent, }, serde::Raw, DeviceId, OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, @@ -519,6 +522,71 @@ impl OutboundGroupSession { Raw::new(&content).expect("m.room.encrypted event content can always be serialized") } + /// Encrypt a room state event for the given room. + /// + /// Beware that a room key needs to be shared before this method + /// can be called using the `share_room_key()` method. + /// + /// # Arguments + /// + /// * `event_type` - The plaintext type of the event, the outer type of the + /// event will become `m.room.encrypted`. + /// + /// * `state_key` - The plaintext state key of the event, the outer state + /// key will be derived from this and the event type. + /// + /// * `content` - The plaintext content of the message that should be + /// encrypted in raw JSON form. + /// + /// # Panics + /// + /// Panics if the content can't be serialized. + pub async fn encrypt_state( + &self, + event_type: &str, + state_key: &str, + content: &Raw, + ) -> Raw { + #[derive(Serialize)] + struct Payload<'a> { + #[serde(rename = "type")] + event_type: &'a str, + state_key: &'a str, + content: &'a Raw, + room_id: &'a RoomId, + } + + let payload = Payload { event_type, state_key, content, room_id: &self.room_id }; + let payload_json = + serde_json::to_string(&payload).expect("payload serialization never fails"); + + let ciphertext = self.encrypt_helper(payload_json).await; + let scheme: RoomEventEncryptionScheme = match self.settings.algorithm { + EventEncryptionAlgorithm::MegolmV1AesSha2 => MegolmV1AesSha2Content { + ciphertext, + sender_key: Some(self.account_identity_keys.curve25519), + session_id: self.session_id().to_owned(), + device_id: Some(self.device_id.clone()), + } + .into(), + #[cfg(feature = "experimental-algorithms")] + EventEncryptionAlgorithm::MegolmV2AesSha2 => { + MegolmV2AesSha2Content { ciphertext, session_id: self.session_id().to_owned() } + .into() + } + _ => unreachable!( + "An outbound group session is always using one of the supported algorithms" + ), + }; + + let content = + RoomEncryptedEventContent { scheme, relates_to: None, other: Default::default() }; + + Raw::new(&content) + .expect("m.room.encrypted event content can always be serialized") + .cast_unchecked() + } + fn elapsed(&self) -> bool { let creation_time = Duration::from_secs(self.creation_time.get().into()); let now = Duration::from_secs(SecondsSinceUnixEpoch::now().get().into()); From 2173e36dd2794321103c2e620fa5571b2f925c17 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 12:52:15 +0100 Subject: [PATCH 02/19] feat(crypto): Add GroupSessionManager::encrypt_state Signed-off-by: Skye Elliot --- .../src/session_manager/group_sessions/mod.rs | 49 ++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) 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 4ba8d8fc000..01215efc0ae 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 @@ -28,7 +28,11 @@ use matrix_sdk_common::{ deserialized_responses::WithheldCode, executor::spawn, locks::RwLock as StdRwLock, }; use ruma::{ - events::{AnyMessageLikeEventContent, AnyToDeviceEventContent, ToDeviceEventType}, + events::{ + room::encrypted::unstable_state::StateRoomEncryptedEventContent, + AnyMessageLikeEventContent, AnyStateEventContent, AnyToDeviceEventContent, + ToDeviceEventType, + }, serde::Raw, to_device::DeviceIdOrAllDevices, DeviceId, OwnedDeviceId, OwnedRoomId, OwnedTransactionId, OwnedUserId, RoomId, TransactionId, @@ -224,6 +228,49 @@ impl GroupSessionManager { Ok(content) } + /// Encrypts a state event for the given room using its outbound group + /// session. + /// + /// # Arguments + /// + /// * `room_id` - The ID of the room where the state event will be sent. + /// * `event_type` - The type of the state event to encrypt. + /// * `state_key` - The state key associated with the event. + /// * `content` - The raw content of the state event to encrypt. + /// + /// # Returns + /// + /// Returns the raw encrypted state event content. + /// + /// # Errors + /// + /// Returns an error if saving changes to the store fails. + /// + /// # Panics + /// + /// Panics if no such session exists for the given room ID, or the session + /// has expired. + pub async fn encrypt_state( + &self, + room_id: &RoomId, + event_type: &str, + state_key: &str, + content: &Raw, + ) -> MegolmResult> { + let session = + self.sessions.get_or_load(room_id).await.expect("Session wasn't created nor shared"); + + assert!(!session.expired(), "Session expired"); + + let content = session.encrypt_state(event_type, state_key, content).await; + + let mut changes = Changes::default(); + changes.outbound_group_sessions.push(session); + self.store.save_changes(changes).await?; + + Ok(content) + } + /// Create a new outbound group session. /// /// This also creates a matching inbound group session. From c5fabf56739a32f689e0a9094d9d50f6f668ab56 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 14:16:49 +0100 Subject: [PATCH 03/19] feat(crypto): Add state event encryption methods to OlmMachine Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/machine/mod.rs | 60 ++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index fbe0c664165..fc8b4b4f706 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. use std::{ + borrow::Borrow, collections::{BTreeMap, HashMap, HashSet}, sync::Arc, time::Duration, @@ -44,8 +45,9 @@ use ruma::{ }, assign, events::{ + room::encrypted::unstable_state::StateRoomEncryptedEventContent, secret::request::SecretName, AnyMessageLikeEvent, AnyMessageLikeEventContent, - AnyToDeviceEvent, MessageLikeEventContent, + AnyStateEventContent, AnyToDeviceEvent, MessageLikeEventContent, StateEventContent, }, serde::{JsonObject, Raw}, DeviceId, MilliSecondsSinceUnixEpoch, OneTimeKeyAlgorithm, OwnedDeviceId, OwnedDeviceKeyId, @@ -1100,6 +1102,62 @@ impl OlmMachine { self.inner.group_session_manager.encrypt(room_id, event_type, content).await } + /// Encrypt a state event for the given room. + /// + /// # Arguments + /// + /// * `room_id` - The id of the room for which the event should be + /// encrypted. + /// + /// * `content` - The plaintext content of the event that should be + /// encrypted. + /// + /// * `state_key` - The associated state key of the event. + pub async fn encrypt_state_event( + &self, + room_id: &RoomId, + content: C, + state_key: K, + ) -> MegolmResult> + where + C: StateEventContent, + C::StateKey: Borrow, + K: AsRef, + { + let event_type = content.event_type().to_string(); + let content = Raw::new(&content)?.cast_unchecked(); + self.encrypt_state_event_raw(room_id, &event_type, state_key.as_ref(), &content).await + } + + /// Encrypt a state event for the given state event using its raw JSON + /// content and state key. + /// + /// This method is equivalent to [`OlmMachine::encrypt_state_event`] + /// method but operates on an arbitrary JSON value instead of strongly-typed + /// event content struct. + /// + /// # Arguments + /// + /// * `room_id` - The id of the room for which the message should be + /// encrypted. + /// + /// * `content` - The plaintext content of the event that should be + /// encrypted as a raw JSON value. + /// + /// * `state_key` - The associated state key of the event. + pub async fn encrypt_state_event_raw( + &self, + room_id: &RoomId, + event_type: &str, + state_key: &str, + content: &Raw, + ) -> MegolmResult> { + self.inner + .group_session_manager + .encrypt_state(room_id, event_type, state_key, content) + .await + } + /// Forces the currently active room key, which is used to encrypt messages, /// to be rotated. /// From 3192d17dfa480ad2d87aee04dfc4062d50bcc0cc Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 14:32:31 +0100 Subject: [PATCH 04/19] feat(base): Add EncryptionState::StateEncrypted Signed-off-by: Skye Elliot --- crates/matrix-sdk-base/src/room/encryption.rs | 15 +++++++++++++-- crates/matrix-sdk-base/src/room/room_info.rs | 14 +++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/matrix-sdk-base/src/room/encryption.rs b/crates/matrix-sdk-base/src/room/encryption.rs index eb8b20097b2..39813299ad7 100644 --- a/crates/matrix-sdk-base/src/room/encryption.rs +++ b/crates/matrix-sdk-base/src/room/encryption.rs @@ -36,6 +36,10 @@ pub enum EncryptionState { /// The room is encrypted. Encrypted, + /// The room is encrypted, additionally requiring state events to be + /// encrypted. + StateEncrypted, + /// The room is not encrypted. NotEncrypted, @@ -45,9 +49,16 @@ pub enum EncryptionState { } impl EncryptionState { - /// Check whether `EncryptionState` is [`Encrypted`][Self::Encrypted]. + /// Check whether `EncryptionState` is [`Encrypted`][Self::Encrypted] or + /// [`StateEncrypted`][Self::StateEncrypted]. pub fn is_encrypted(&self) -> bool { - matches!(self, Self::Encrypted) + matches!(self, Self::Encrypted | Self::StateEncrypted) + } + + /// Check whether `EncryptionState` is + /// [`StateEncrypted`][Self::StateEncrypted]. + pub fn is_state_encrypted(&self) -> bool { + matches!(self, Self::StateEncrypted) } /// Check whether `EncryptionState` is [`Unknown`][Self::Unknown]. diff --git a/crates/matrix-sdk-base/src/room/room_info.rs b/crates/matrix-sdk-base/src/room/room_info.rs index cc4b20f6866..5ea1147aba1 100644 --- a/crates/matrix-sdk-base/src/room/room_info.rs +++ b/crates/matrix-sdk-base/src/room/room_info.rs @@ -619,10 +619,18 @@ impl RoomInfo { pub fn encryption_state(&self) -> EncryptionState { if !self.encryption_state_synced { EncryptionState::Unknown - } else if self.base_info.encryption.is_some() { - EncryptionState::Encrypted } else { - EncryptionState::NotEncrypted + self.base_info + .encryption + .as_ref() + .map(|state| { + if state.encrypt_state_events { + EncryptionState::StateEncrypted + } else { + EncryptionState::Encrypted + } + }) + .unwrap_or(EncryptionState::NotEncrypted) } } From bbf047d656dfe15a4bdbb08832629cc516984db3 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 14:42:22 +0100 Subject: [PATCH 05/19] feat(sdk): Add SendStateEventRaw Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/futures.rs | 86 ++++++++++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index f5dbd12a37d..45f33e314b7 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -26,7 +26,7 @@ use ruma::events::{MessageLikeUnsigned, SyncMessageLikeEvent}; use ruma::{ api::client::message::send_message_event, assign, - events::{AnyMessageLikeEventContent, MessageLikeEventContent}, + events::{AnyMessageLikeEventContent, AnyStateEventContent, MessageLikeEventContent}, serde::Raw, OwnedTransactionId, TransactionId, }; @@ -34,7 +34,9 @@ use tracing::{info, trace, Instrument, Span}; use super::Room; use crate::{ - attachment::AttachmentConfig, config::RequestConfig, utils::IntoRawMessageLikeEventContent, + attachment::AttachmentConfig, + config::RequestConfig, + utils::{IntoRawMessageLikeEventContent, IntoRawStateEventContent}, Result, TransmissionProgress, }; @@ -319,3 +321,83 @@ impl<'a> IntoFuture for SendAttachment<'a> { Box::pin(fut.instrument(tracing_span)) } } + +/// TODO: Future returned by `Room::send_state_event_raw`. +#[allow(missing_debug_implementations)] +pub struct SendStateEventRaw<'a> { + room: &'a Room, + event_type: &'a str, + state_key: &'a str, + content: Raw, + tracing_span: Span, + request_config: Option, +} + +impl<'a> SendStateEventRaw<'a> { + pub(crate) fn new( + room: &'a Room, + event_type: &'a str, + state_key: &'a str, + content: impl IntoRawStateEventContent, + ) -> Self { + let content = content.into_raw_state_event_content(); + Self { + room, + event_type, + state_key, + content, + tracing_span: Span::current(), + request_config: None, + } + } + + /// Assign a given [`RequestConfig`] to configure how this request should + /// behave with respect to the network. + pub fn with_request_config(mut self, request_config: RequestConfig) -> Self { + self.request_config = Some(request_config); + self + } + + /// Determines whether the inner state event should be encrypted before + /// sending. + /// + /// This method checks two conditions: + /// 1. Whether the room supports encrypted state events, by inspecting the + /// room's encryption state. + /// 2. Whether the event type is considered "critical" or excluded from + /// encryption under MSC3414. + /// + /// # Returns + /// + /// Returns `true` if the event should be encrypted, otherwise returns + /// `false`. + #[cfg(feature = "e2e-encryption")] + fn should_encrypt(room: &Room, event_type: &str) -> bool { + use tracing::debug; + + if !room.encryption_state().is_state_encrypted() { + debug!("Sending plaintext event as the room does NOT support encrypted state events."); + return false; + } + + // Check the event is not critical. + if matches!( + event_type, + "m.room.create" + | "m.room.member" + | "m.room.join_rules" + | "m.room.power_levels" + | "m.room.third_party_invite" + | "m.room.history_visibility" + | "m.room.guest_access" + | "m.room.encryption" + | "m.space.child" + | "m.space.parent" + ) { + debug!("Sending plaintext event as its type is excluded from encryption."); + return false; + } + + true + } +} From 7e2c076386f8e77a0ac3f9d25776ae9f0cca43cc Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 14:48:59 +0100 Subject: [PATCH 06/19] feat(sdk): Implement IntoFuture for SendStateEventRaw Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/futures.rs | 73 ++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index 45f33e314b7..f97f836d4b4 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -24,7 +24,7 @@ use mime::Mime; #[cfg(doc)] use ruma::events::{MessageLikeUnsigned, SyncMessageLikeEvent}; use ruma::{ - api::client::message::send_message_event, + api::client::{message::send_message_event, state::send_state_event}, assign, events::{AnyMessageLikeEventContent, AnyStateEventContent, MessageLikeEventContent}, serde::Raw, @@ -401,3 +401,74 @@ impl<'a> SendStateEventRaw<'a> { true } } + +impl<'a> IntoFuture for SendStateEventRaw<'a> { + type Output = Result; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + #[cfg(feature = "e2e-encryption")] + let Self { room, mut event_type, state_key, mut content, tracing_span, request_config } = + self; + + // This is here purely to satisfy the linter on non-encrypting targets. + #[cfg(not(feature = "e2e-encryption"))] + let Self { room, event_type, state_key, content, tracing_span, request_config } = self; + + let fut = async move { + room.ensure_room_joined()?; + + #[cfg(feature = "e2e-encryption")] + let mut state_key = state_key.to_owned(); + #[cfg(not(feature = "e2e-encryption"))] + let state_key = state_key.to_owned(); + + #[cfg(feature = "e2e-encryption")] + if Self::should_encrypt(room, event_type) { + use tracing::debug; + + Span::current().record("is_room_encrypted", true); + debug!( + room_id = ?room.room_id(), + "Sending encrypted event because the room is encrypted.", + ); + + if !room.are_members_synced() { + room.sync_members().await?; + } + + room.query_keys_for_untracked_or_dirty_users().await?; + room.preshare_room_key().await?; + + let olm = room.client.olm_machine().await; + let olm = olm.as_ref().expect("Olm machine wasn't started"); + + content = olm + .encrypt_state_event_raw(room.room_id(), event_type, &state_key, &content) + .await? + .cast_unchecked(); + + state_key = format!("{event_type}:{state_key}"); + event_type = "m.room.encrypted"; + } else { + Span::current().record("is_room_encrypted", false); + } + + let request = send_state_event::v3::Request::new_raw( + room.room_id().to_owned(), + event_type.into(), + state_key.to_owned(), + content, + ); + + let response = room.client.send(request).with_request_config(request_config).await?; + + Span::current().record("event_id", tracing::field::debug(&response.event_id)); + info!("Sent event in room"); + + Ok(response) + }; + + Box::pin(fut.instrument(tracing_span)) + } +} From bde0ef1b7e145fb8202de5449a228dedbf103032 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:01:37 +0100 Subject: [PATCH 07/19] feat(sdk): Modify Room::send_state_event_raw to return SendStateEventRaw Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/futures.rs | 2 +- crates/matrix-sdk/src/room/mod.rs | 22 +++++++--------------- 2 files changed, 8 insertions(+), 16 deletions(-) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index f97f836d4b4..eb2799e0c53 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -322,7 +322,7 @@ impl<'a> IntoFuture for SendAttachment<'a> { } } -/// TODO: Future returned by `Room::send_state_event_raw`. +/// Future returned by [`Room::send_state_event_raw`]. #[allow(missing_debug_implementations)] pub struct SendStateEventRaw<'a> { room: &'a Room, diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index d508ef26883..6f44932559a 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -153,6 +153,7 @@ use crate::{ media::{MediaFormat, MediaRequestParameters}, notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode}, room::{ + futures::SendStateEventRaw, knock_requests::{KnockRequest, KnockRequestMemberInfo}, power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, privacy_settings::RoomPrivacySettings, @@ -2745,22 +2746,13 @@ impl Room { /// # anyhow::Ok(()) }; /// ``` #[instrument(skip_all)] - pub async fn send_state_event_raw( - &self, - event_type: &str, - state_key: &str, + pub fn send_state_event_raw<'a>( + &'a self, + event_type: &'a str, + state_key: &'a str, content: impl IntoRawStateEventContent, - ) -> Result { - self.ensure_room_joined()?; - - let request = send_state_event::v3::Request::new_raw( - self.room_id().to_owned(), - event_type.into(), - state_key.to_owned(), - content.into_raw_state_event_content(), - ); - - Ok(self.client.send(request).await?) + ) -> SendStateEventRaw<'a> { + SendStateEventRaw::new(self, event_type, state_key, content) } /// Strips all information out of an event of the room. From 9ef0419212da74ad9e3e38d87b363d24f7a6c539 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:04:40 +0100 Subject: [PATCH 08/19] feat(sdk): Add SendStateEvent Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/futures.rs | 38 +++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index eb2799e0c53..72f8c4227f8 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -16,7 +16,7 @@ #![deny(unreachable_pub)] -use std::future::IntoFuture; +use std::{borrow::Borrow, future::IntoFuture}; use eyeball::SharedObservable; use matrix_sdk_common::boxed_into_future; @@ -26,7 +26,10 @@ use ruma::events::{MessageLikeUnsigned, SyncMessageLikeEvent}; use ruma::{ api::client::{message::send_message_event, state::send_state_event}, assign, - events::{AnyMessageLikeEventContent, AnyStateEventContent, MessageLikeEventContent}, + events::{ + AnyMessageLikeEventContent, AnyStateEventContent, MessageLikeEventContent, + StateEventContent, + }, serde::Raw, OwnedTransactionId, TransactionId, }; @@ -322,6 +325,37 @@ impl<'a> IntoFuture for SendAttachment<'a> { } } +/// TODO: Future returned by `Room::send_state_event`. +#[allow(missing_debug_implementations)] +pub struct SendStateEvent<'a> { + room: &'a Room, + event_type: String, + state_key: String, + content: serde_json::Result, + request_config: Option, +} + +impl<'a> SendStateEvent<'a> { + pub(crate) fn new(room: &'a Room, state_key: &K, content: C) -> Self + where + C: StateEventContent, + C::StateKey: Borrow, + K: AsRef + ?Sized, + { + let event_type = content.event_type().to_string(); + let state_key = state_key.as_ref().to_owned(); + let content = serde_json::to_value(&content); + Self { room, event_type, state_key, content, request_config: None } + } + + /// Assign a given [`RequestConfig`] to configure how this request should + /// behave with respect to the network. + pub fn with_request_config(mut self, request_config: RequestConfig) -> Self { + self.request_config = Some(request_config); + self + } +} + /// Future returned by [`Room::send_state_event_raw`]. #[allow(missing_debug_implementations)] pub struct SendStateEventRaw<'a> { From 1eef067a50ebb6bb67163dac4fb0426205df1cca Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:06:07 +0100 Subject: [PATCH 09/19] feat(sdk): Implement IntoFuture for SendStateEvent Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/futures.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index 72f8c4227f8..586b82a823a 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -356,6 +356,20 @@ impl<'a> SendStateEvent<'a> { } } +impl<'a> IntoFuture for SendStateEvent<'a> { + type Output = Result; + boxed_into_future!(extra_bounds: 'a); + + fn into_future(self) -> Self::IntoFuture { + let Self { room, state_key, event_type, content, request_config } = self; + Box::pin(async move { + let content = content?; + assign!(room.send_state_event_raw(&event_type, &state_key, content), { request_config }) + .await + }) + } +} + /// Future returned by [`Room::send_state_event_raw`]. #[allow(missing_debug_implementations)] pub struct SendStateEventRaw<'a> { From d63f0eefabbc1d0da9c3ecbeefe67cda28cacc27 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:13:50 +0100 Subject: [PATCH 10/19] feat(sdk): Use SendStateEvent future in Room send_state methods Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/mod.rs | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 6f44932559a..46c129b3778 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -153,7 +153,7 @@ use crate::{ media::{MediaFormat, MediaRequestParameters}, notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode}, room::{ - futures::SendStateEventRaw, + futures::{SendStateEvent, SendStateEventRaw}, knock_requests::{KnockRequest, KnockRequestMemberInfo}, power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, privacy_settings::RoomPrivacySettings, @@ -2647,11 +2647,11 @@ impl Room { /// # anyhow::Ok(()) }; /// ``` #[instrument(skip_all)] - pub async fn send_state_event( - &self, + pub fn send_state_event<'a>( + &'a self, content: impl StateEventContent, - ) -> Result { - self.send_state_event_for_key(&EmptyStateKey, content).await + ) -> SendStateEvent<'a> { + self.send_state_event_for_key(&EmptyStateKey, content) } /// Send a state event to the homeserver. @@ -2694,21 +2694,17 @@ impl Room { /// joined_room.send_state_event_for_key("foo", content).await?; /// # anyhow::Ok(()) }; /// ``` - pub async fn send_state_event_for_key( - &self, + pub fn send_state_event_for_key<'a, C, K>( + &'a self, state_key: &K, content: C, - ) -> Result + ) -> SendStateEvent<'a> where C: StateEventContent, C::StateKey: Borrow, K: AsRef + ?Sized, { - self.ensure_room_joined()?; - let request = - send_state_event::v3::Request::new(self.room_id().to_owned(), state_key, &content)?; - let response = self.client.send(request).await?; - Ok(response) + SendStateEvent::new(self, state_key, content) } /// Send a raw room state event to the homeserver. From 89b367fa3bbec30a80d5dab314c656a4f99f3a2e Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:17:48 +0100 Subject: [PATCH 11/19] feat(sdk): Add Room::enable_encryption_with_state Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/mod.rs | 43 +++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 46c129b3778..b25478dea33 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -1955,6 +1955,49 @@ impl Room { Ok(()) } + /// Enable End-to-end encryption in this room, with experimental encrypted + /// state events. + #[instrument(skip_all)] + pub async fn enable_encryption_with_state(&self) -> Result<()> { + use ruma::{ + events::room::encryption::RoomEncryptionEventContent, EventEncryptionAlgorithm, + }; + const SYNC_WAIT_TIME: Duration = Duration::from_secs(3); + + if !self.latest_encryption_state().await?.is_encrypted() { + let content = + RoomEncryptionEventContent::new(EventEncryptionAlgorithm::MegolmV1AesSha2) + .with_encrypted_state(); + self.send_state_event(content).await?; + + // TODO do we want to return an error here if we time out? This + // could be quite useful if someone wants to enable encryption and + // send a message right after it's enabled. + _ = timeout(self.client.inner.sync_beat.listen(), SYNC_WAIT_TIME).await; + + // If after waiting for a sync, we don't have the encryption state we expect, + // assume the local encryption state is incorrect; this will cause + // the SDK to re-request it later for confirmation, instead of + // assuming it's sync'd and correct (and not encrypted). + let _sync_lock = self.client.base_client().sync_lock().lock().await; + if !self.inner.encryption_state().is_state_encrypted() { + debug!("still not marked as encrypted, marking encryption state as missing"); + + let mut room_info = self.clone_info(); + room_info.mark_encryption_state_missing(); + let mut changes = StateChanges::default(); + changes.add_room(room_info.clone()); + + self.client.state_store().save_changes(&changes).await?; + self.set_room_info(room_info, RoomInfoNotableUpdateReasons::empty()); + } else { + debug!("room successfully marked as encrypted"); + } + } + + Ok(()) + } + /// Share a room key with users in the given room. /// /// This will create Olm sessions with all the users/device pairs in the From cd56ffc6cc3a192b870ee949151d5a600310ebb9 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:32:08 +0100 Subject: [PATCH 12/19] feat(base): Add sync reponse processor for AnySyncState::RoomEncrypted Signed-off-by: Skye Elliot --- .../response_processors/room/msc4186/mod.rs | 2 + .../src/response_processors/room/sync_v2.rs | 4 + .../src/response_processors/state_events.rs | 77 +++++++++++++++++++ 3 files changed, 83 insertions(+) diff --git a/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs b/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs index e339919bb11..6989600d2b2 100644 --- a/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs +++ b/crates/matrix-sdk-base/src/response_processors/room/msc4186/mod.rs @@ -118,6 +118,8 @@ pub async fn update_any_room( ambiguity_cache, &mut new_user_ids, state_store, + #[cfg(feature = "e2e-encryption")] + e2ee.clone(), ) .await?; diff --git a/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs b/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs index c7f7416337a..14a60b0804b 100644 --- a/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs +++ b/crates/matrix-sdk-base/src/response_processors/room/sync_v2.rs @@ -76,6 +76,8 @@ pub async fn update_joined_room( ambiguity_cache, &mut new_user_ids, state_store, + #[cfg(feature = "e2e-encryption")] + e2ee.clone(), ) .await?; @@ -180,6 +182,8 @@ pub async fn update_left_room( ambiguity_cache, &mut (), state_store, + #[cfg(feature = "e2e-encryption")] + e2ee.clone(), ) .await?; diff --git a/crates/matrix-sdk-base/src/response_processors/state_events.rs b/crates/matrix-sdk-base/src/response_processors/state_events.rs index aceb4f5b822..fd38a45630d 100644 --- a/crates/matrix-sdk-base/src/response_processors/state_events.rs +++ b/crates/matrix-sdk-base/src/response_processors/state_events.rs @@ -26,6 +26,8 @@ use serde::Deserialize; use tracing::warn; use super::Context; +#[cfg(feature = "e2e-encryption")] +use super::e2ee; use crate::store::BaseStateStore; /// Collect [`AnySyncStateEvent`]. @@ -89,6 +91,7 @@ pub mod sync { ambiguity_cache: &mut AmbiguityCache, new_users: &mut U, state_store: &BaseStateStore, + #[cfg(feature = "e2e-encryption")] e2ee: super::e2ee::E2EE<'_>, ) -> StoreResult<()> where U: NewUsers, @@ -142,6 +145,80 @@ pub mod sync { } } + #[cfg(feature = "e2e-encryption")] + AnySyncStateEvent::RoomEncrypted(outer) => { + use matrix_sdk_crypto::RoomEventDecryptionResult; + use tracing::{debug, warn}; + + debug!(event_id = ?outer.event_id(), "Received encrypted state event, attempting decryption..."); + + let Some(olm_machine) = e2ee.olm_machine else { + panic!(); + }; + + let decrypted_event = olm_machine + .try_decrypt_room_event( + raw_event.cast_ref_unchecked(), + &room_info.room_id, + e2ee.decryption_settings, + ) + .await + .expect("OlmMachine was not started"); + + // Skip state events that failed to decrypt. + let RoomEventDecryptionResult::Decrypted(room_event) = decrypted_event else { + warn!(event_id = ?outer.event_id(), "Failed to decrypt state event"); + continue; + }; + + // Unpack event type and state key from outer, or discard if this fails. + let Some((outer_event_type, outer_state_key)) = + outer.state_key().split_once(":") + else { + warn!( + event_id = outer.event_id().as_str(), + state_key = event.state_key(), + "Malformed state key" + ); + continue; + }; + + let inner = + match room_event.event.deserialize_as_unchecked::() { + Ok(inner) => inner, + Err(e) => { + warn!("Malformed event body: {e}"); + continue; + } + }; + + // Check event types match, discard if not. + let inner_event_type = inner.event_type().to_string(); + if outer_event_type != inner_event_type { + warn!( + event_id = outer.event_id().as_str(), + expected = outer_event_type, + found = inner_event_type, + "Mismatched event type" + ); + continue; + } + + // Check state keys match, discard if not. + if outer_state_key != inner.state_key() { + warn!( + event_id = outer.event_id().as_str(), + expected = outer_state_key, + found = inner.state_key(), + "Mismatched state key" + ); + continue; + } + + debug!(event_id = ?outer.event_id(), "Decrypted state event successfully."); + room_info.handle_state_event(&inner); + } + _ => { room_info.handle_state_event(event); } From 7023b68df1ccc1e9cbdbf7c075e171781909d6e0 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 15:38:03 +0100 Subject: [PATCH 13/19] feat(sdk): Add assert_let_decrypted_state_event_content test utility macro Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/test_utils/mod.rs | 29 +++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/crates/matrix-sdk/src/test_utils/mod.rs b/crates/matrix-sdk/src/test_utils/mod.rs index 41efdd930a5..82d46984104 100644 --- a/crates/matrix-sdk/src/test_utils/mod.rs +++ b/crates/matrix-sdk/src/test_utils/mod.rs @@ -266,6 +266,35 @@ macro_rules! assert_decrypted_message_eq { }}; } +/// Given a [`TimelineEvent`], assert that the event is a decrypted state +/// event, and that its content matches the given pattern via a let binding. +#[macro_export] +macro_rules! assert_let_decrypted_state_event_content { + ($pat:pat = $event:expr, $($msg:tt)*) => { + assert_matches2::assert_let!( + $crate::deserialized_responses::TimelineEventKind::Decrypted(decrypted_event) = + $event.kind, + "Event was not decrypted" + ); + + let deserialized_event = decrypted_event + .event + .deserialize_as_unchecked::<$crate::ruma::events::AnyStateEvent>() + .expect("We should be able to deserialize the decrypted event"); + + let content = + deserialized_event.original_content().expect("The event should not have been redacted"); + + assert_matches2::assert_let!($pat = content); + }; + ($pat:pat = $event:expr) => { + assert_let_decrypted_state_event_content!( + $pat = $event, + "The decrypted event did not match to the expected value" + ); + }; +} + #[doc(hidden)] #[macro_export] macro_rules! assert_next_eq_with_timeout_impl { From 53d3a2e91ff411b6acea604c5eecceb7a8a4838b Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 16:51:16 +0100 Subject: [PATCH 14/19] feat(sdk): Attempt state event decryption in Room::try_decrypt_event Signed-off-by: Skye Elliot --- crates/matrix-sdk/src/room/mod.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index b25478dea33..9040580a131 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -60,8 +60,8 @@ use reply::Reply; use ruma::events::room::message::GalleryItemType; #[cfg(feature = "e2e-encryption")] use ruma::events::{ - room::encrypted::OriginalSyncRoomEncryptedEvent, AnySyncMessageLikeEvent, AnySyncTimelineEvent, - SyncMessageLikeEvent, + room::encrypted::OriginalSyncRoomEncryptedEvent, AnySyncMessageLikeEvent, AnySyncStateEvent, + AnySyncTimelineEvent, SyncMessageLikeEvent, }; use ruma::{ api::client::{ @@ -637,11 +637,18 @@ impl Room { event: Raw, push_ctx: Option<&PushContext>, ) -> TimelineEvent { + // If we have either an encrypted message-like or state event, try to decrypt. #[cfg(feature = "e2e-encryption")] - if let Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomEncrypted( - SyncMessageLikeEvent::Original(_), - ))) = event.deserialize_as::() - { + if matches!( + event.deserialize_as::(), + Ok(AnySyncTimelineEvent::MessageLike(AnySyncMessageLikeEvent::RoomEncrypted( + SyncMessageLikeEvent::Original(_), + )) | AnySyncTimelineEvent::State(AnySyncStateEvent::RoomEncrypted( + SyncStateEvent::Original(_) + ))) + ) { + // Cast safety: The state key is not used during decryption, and the types + // overlap sufficiently. if let Ok(event) = self.decrypt_event(event.cast_ref_unchecked(), push_ctx).await { return event; } From a51bec61f70b681a0f798194b5aa5c3c2137bb91 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 16:52:24 +0100 Subject: [PATCH 15/19] test(e2ee): Add integration test for encrypted state events Signed-off-by: Skye Elliot --- .../src/tests/e2ee/mod.rs | 1 + .../src/tests/e2ee/state_events.rs | 150 ++++++++++++++++++ 2 files changed, 151 insertions(+) create mode 100644 testing/matrix-sdk-integration-testing/src/tests/e2ee/state_events.rs diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs index e482037b1e8..8b551c1e66e 100644 --- a/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/mod.rs @@ -43,6 +43,7 @@ use tracing::{debug, warn}; use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; mod shared_history; +mod state_events; // This test reproduces a bug seen on clients that use the same `Client` // instance for both the usual sliding sync loop and for getting the event for a diff --git a/testing/matrix-sdk-integration-testing/src/tests/e2ee/state_events.rs b/testing/matrix-sdk-integration-testing/src/tests/e2ee/state_events.rs new file mode 100644 index 00000000000..7bf8883d7c4 --- /dev/null +++ b/testing/matrix-sdk-integration-testing/src/tests/e2ee/state_events.rs @@ -0,0 +1,150 @@ +use std::{ops::Deref, time::Duration}; + +use anyhow::Result; +use assert_matches2::assert_let; +use assign::assign; +use futures::{FutureExt, StreamExt, pin_mut}; +use matrix_sdk::{ + assert_let_decrypted_state_event_content, + encryption::EncryptionSettings, + ruma::{ + api::client::room::create_room::v3::{Request as CreateRoomRequest, RoomPreset}, + events::AnyStateEventContent, + }, +}; +use matrix_sdk_common::deserialized_responses::ProcessedToDeviceEvent; +use matrix_sdk_ui::sync_service::SyncService; +use similar_asserts::assert_eq; +use tracing::{Instrument, info}; + +use crate::helpers::{SyncTokenAwareClient, TestClientBuilder}; + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn test_e2ee_state_events() -> 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) + .enable_share_history_on_invite(true) + .build() + .await?; + + let sync_service_span = tracing::info_span!(parent: &alice_span, "sync_service"); + let alice_sync_service = 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) + .enable_share_history_on_invite(true) + .build() + .await?, + ); + + // Alice creates an encrypted room ... + let alice_room = alice + .create_room(assign!(CreateRoomRequest::new(), { + name: Some("Cat Photos".to_owned()), + preset: Some(RoomPreset::PublicChat), + })) + .await?; + alice_room.enable_encryption_with_state().await?; + + // HACK: wait for sync + let _ = tokio::time::sleep(Duration::from_secs(3)).await; + + // (sanity checks) + assert!(alice_room.encryption_state().is_encrypted(), "Encryption was not enabled."); + assert!( + alice_room.encryption_state().is_state_encrypted(), + "State encryption was not enabled." + ); + + info!(room_id = ?alice_room.room_id(), "Alice has created and enabled encryption in the room"); + + // ... and changes the room name + let rename_event_id = alice_room + .set_name("Dog Photos".to_owned()) + .await + .expect("We should be able to rename the room") + .event_id; + + let bundle_stream = bob + .encryption() + .historic_room_key_stream() + .await + .expect("We should be able to get the bundle stream"); + + // Alice invites Bob to the room + alice_room.invite_user_by_id(bob.user_id().unwrap()).await?; + + // Alice is done. Bob has been invited and the room key bundle should have been + // sent out. Let's stop syncing so the logs contain less noise. + alice_sync_service.stop().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_let!(ProcessedToDeviceEvent::Decrypted { raw, .. } = to_device_event); + assert_eq!( + raw.get_field::("type").unwrap().unwrap(), + "io.element.msc4268.room_key_bundle" + ); + + bob.get_room(alice_room.room_id()).expect("Bob should have received the invite"); + + pin_mut!(bundle_stream); + + let info = bundle_stream + .next() + .now_or_never() + .flatten() + .expect("We should be notified about the received bundle"); + + assert_eq!(Some(info.sender.deref()), alice.user_id()); + assert_eq!(info.room_id, alice_room.room_id()); + + let bob_room = bob + .join_room_by_id(alice_room.room_id()) + .instrument(bob_span.clone()) + .await + .expect("Bob should be able to accept the invitation from Alice"); + + // Sync the room, so the rename event arrives. + let _ = bob.sync_once().instrument(bob_span.clone()).await?; + + // Check it has been applied. + assert_eq!( + "Dog Photos", + bob_room.name().unwrap(), + "Bob's copy of the room name should have updated." + ); + + // Let's also check we can inspect the payload manually. + let rename_event = bob_room + .event(&rename_event_id, None) + .instrument(bob_span.clone()) + .await + .expect("Bob should be able to fetch the historic event."); + + assert_let_decrypted_state_event_content!( + AnyStateEventContent::RoomName(content) = rename_event + ); + + assert_eq!("Dog Photos", content.name); + + Ok(()) +} From 61d40bd330e1f8f914ae92e0c2fd463813c2c4b8 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Thu, 7 Aug 2025 11:41:48 +0100 Subject: [PATCH 16/19] feat(crypto): Add RoomSettings::enable_encrypted_state_events (WASM SDK) Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/machine/tests/room_settings.rs | 1 + crates/matrix-sdk-crypto/src/store/integration_tests.rs | 1 + crates/matrix-sdk-crypto/src/store/types.rs | 4 ++++ 3 files changed, 6 insertions(+) diff --git a/crates/matrix-sdk-crypto/src/machine/tests/room_settings.rs b/crates/matrix-sdk-crypto/src/machine/tests/room_settings.rs index 567d7603ca0..cfdeceb3ced 100644 --- a/crates/matrix-sdk-crypto/src/machine/tests/room_settings.rs +++ b/crates/matrix-sdk-crypto/src/machine/tests/room_settings.rs @@ -23,6 +23,7 @@ async fn test_stores_and_returns_room_settings() { let settings = RoomSettings { algorithm: EventEncryptionAlgorithm::MegolmV1AesSha2, + encrypt_state_events: false, only_allow_trusted_devices: true, session_rotation_period: Some(Duration::from_secs(10)), session_rotation_period_messages: Some(1234), diff --git a/crates/matrix-sdk-crypto/src/store/integration_tests.rs b/crates/matrix-sdk-crypto/src/store/integration_tests.rs index 06adef26a17..fa64943ffbf 100644 --- a/crates/matrix-sdk-crypto/src/store/integration_tests.rs +++ b/crates/matrix-sdk-crypto/src/store/integration_tests.rs @@ -1163,6 +1163,7 @@ macro_rules! cryptostore_integration_tests { let room_1 = room_id!("!test_1:localhost"); let settings_1 = RoomSettings { algorithm: EventEncryptionAlgorithm::MegolmV1AesSha2, + encrypt_state_events: false, only_allow_trusted_devices: true, session_rotation_period: Some(Duration::from_secs(10)), session_rotation_period_messages: Some(123), diff --git a/crates/matrix-sdk-crypto/src/store/types.rs b/crates/matrix-sdk-crypto/src/store/types.rs index 5bca20b4d6d..afe1d3385b7 100644 --- a/crates/matrix-sdk-crypto/src/store/types.rs +++ b/crates/matrix-sdk-crypto/src/store/types.rs @@ -411,6 +411,9 @@ pub struct RoomSettings { /// The encryption algorithm that should be used in the room. pub algorithm: EventEncryptionAlgorithm, + /// Whether state event encryption is enabled. + pub encrypt_state_events: bool, + /// Should untrusted devices receive the room key, or should they be /// excluded from the conversation. pub only_allow_trusted_devices: bool, @@ -428,6 +431,7 @@ impl Default for RoomSettings { fn default() -> Self { Self { algorithm: EventEncryptionAlgorithm::MegolmV1AesSha2, + encrypt_state_events: false, only_allow_trusted_devices: false, session_rotation_period: None, session_rotation_period_messages: None, From d6e15b61c129dc789b3f33c986c3419d56036471 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 8 Aug 2025 12:10:17 +0100 Subject: [PATCH 17/19] refactor(crypto): De-duplicate OutboundGroupSession::encrypt and ::encrypt_state to ::encrypt_inner --- .../src/olm/group_sessions/outbound.rs | 62 +++++++++---------- 1 file changed, 28 insertions(+), 34 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs index 9014375c097..f35986bad65 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs @@ -491,35 +491,12 @@ impl OutboundGroupSession { } let payload = Payload { event_type, content, room_id: &self.room_id }; - let payload_json = - serde_json::to_string(&payload).expect("payload serialization never fails"); let relates_to = content .get_field::("m.relates_to") .expect("serde_json::Value deserialization with valid JSON input never fails"); - let ciphertext = self.encrypt_helper(payload_json).await; - let scheme: RoomEventEncryptionScheme = match self.settings.algorithm { - EventEncryptionAlgorithm::MegolmV1AesSha2 => MegolmV1AesSha2Content { - ciphertext, - sender_key: Some(self.account_identity_keys.curve25519), - session_id: self.session_id().to_owned(), - device_id: Some(self.device_id.clone()), - } - .into(), - #[cfg(feature = "experimental-algorithms")] - EventEncryptionAlgorithm::MegolmV2AesSha2 => { - MegolmV2AesSha2Content { ciphertext, session_id: self.session_id().to_owned() } - .into() - } - _ => unreachable!( - "An outbound group session is always using one of the supported algorithms" - ), - }; - - let content = RoomEncryptedEventContent { scheme, relates_to, other: Default::default() }; - - Raw::new(&content).expect("m.room.encrypted event content can always be serialized") + self.encrypt_inner(&payload, relates_to).await } /// Encrypt a room state event for the given room. @@ -557,10 +534,32 @@ impl OutboundGroupSession { } let payload = Payload { event_type, state_key, content, room_id: &self.room_id }; - let payload_json = - serde_json::to_string(&payload).expect("payload serialization never fails"); + self.encrypt_inner(&payload, None).await.cast_unchecked() + } - let ciphertext = self.encrypt_helper(payload_json).await; + /// Encrypt an arbitrary event for the given room. + /// + /// Beware that a room key needs to be shared before this method + /// can be called using the `share_room_key()` method. + /// + /// # Arguments + /// + /// * `payload` - The plaintext content of the event that should be + /// encrypted in raw JSON form. + /// + /// # Panics + /// + /// Panics if the content can't be serialized. + async fn encrypt_inner( + &self, + payload: &T, + relates_to: Option, + ) -> Raw { + let ciphertext = self + .encrypt_helper( + serde_json::to_string(payload).expect("payload serialization never fails"), + ) + .await; let scheme: RoomEventEncryptionScheme = match self.settings.algorithm { EventEncryptionAlgorithm::MegolmV1AesSha2 => MegolmV1AesSha2Content { ciphertext, @@ -578,13 +577,8 @@ impl OutboundGroupSession { "An outbound group session is always using one of the supported algorithms" ), }; - - let content = - RoomEncryptedEventContent { scheme, relates_to: None, other: Default::default() }; - - Raw::new(&content) - .expect("m.room.encrypted event content can always be serialized") - .cast_unchecked() + let content = RoomEncryptedEventContent { scheme, relates_to, other: Default::default() }; + Raw::new(&content).expect("m.room.encrypted event content can always be serialized") } fn elapsed(&self) -> bool { From b70751750f03eab4b218578c96ac1c5d665ab7a2 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 8 Aug 2025 12:56:17 +0100 Subject: [PATCH 18/19] refactor(sdk): De-dupe room::enable_encryption and ...with_state to helper method --- crates/matrix-sdk/src/room/mod.rs | 55 +++++++++++-------------------- 1 file changed, 19 insertions(+), 36 deletions(-) diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index 9040580a131..822e3ca8c95 100644 --- a/crates/matrix-sdk/src/room/mod.rs +++ b/crates/matrix-sdk/src/room/mod.rs @@ -1924,48 +1924,28 @@ impl Room { /// ``` #[instrument(skip_all)] pub async fn enable_encryption(&self) -> Result<()> { - use ruma::{ - events::room::encryption::RoomEncryptionEventContent, EventEncryptionAlgorithm, - }; - const SYNC_WAIT_TIME: Duration = Duration::from_secs(3); - - if !self.latest_encryption_state().await?.is_encrypted() { - let content = - RoomEncryptionEventContent::new(EventEncryptionAlgorithm::MegolmV1AesSha2); - self.send_state_event(content).await?; - - // TODO do we want to return an error here if we time out? This - // could be quite useful if someone wants to enable encryption and - // send a message right after it's enabled. - _ = timeout(self.client.inner.sync_beat.listen(), SYNC_WAIT_TIME).await; - - // If after waiting for a sync, we don't have the encryption state we expect, - // assume the local encryption state is incorrect; this will cause - // the SDK to re-request it later for confirmation, instead of - // assuming it's sync'd and correct (and not encrypted). - let _sync_lock = self.client.base_client().sync_lock().lock().await; - if !self.inner.encryption_state().is_encrypted() { - debug!("still not marked as encrypted, marking encryption state as missing"); - - let mut room_info = self.clone_info(); - room_info.mark_encryption_state_missing(); - let mut changes = StateChanges::default(); - changes.add_room(room_info.clone()); - - self.client.state_store().save_changes(&changes).await?; - self.set_room_info(room_info, RoomInfoNotableUpdateReasons::empty()); - } else { - debug!("room successfully marked as encrypted"); - } - } - - Ok(()) + self.enable_encryption_inner(false).await } /// Enable End-to-end encryption in this room, with experimental encrypted /// state events. + /// + /// This method will be a noop if encryption is already enabled, otherwise + /// sends a `m.room.encryption` state event to the room. This might fail if + /// you don't have the appropriate power level to enable end-to-end + /// encryption. + /// + /// A sync needs to be received to update the local room state. This method + /// will wait for a sync to be received, this might time out if no + /// sync loop is running or if the server is slow. #[instrument(skip_all)] pub async fn enable_encryption_with_state(&self) -> Result<()> { + self.enable_encryption_inner(true).await + } + + /// Helper function for enabling encryption, optionally with support for + /// encrypted state events. + async fn enable_encryption_inner(&self, state_events: bool) -> Result<()> { use ruma::{ events::room::encryption::RoomEncryptionEventContent, EventEncryptionAlgorithm, }; @@ -1975,6 +1955,9 @@ impl Room { let content = RoomEncryptionEventContent::new(EventEncryptionAlgorithm::MegolmV1AesSha2) .with_encrypted_state(); + + let content = if state_events { content.with_encrypted_state() } else { content }; + self.send_state_event(content).await?; // TODO do we want to return an error here if we time out? This From a8b7d119d85a24189aedc0a29d3e72942f76a93b Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 8 Aug 2025 13:06:15 +0100 Subject: [PATCH 19/19] refactor(sdk): De-dupe SendRawMessageLikeEvent and SendRawStateEvent checks --- crates/matrix-sdk/src/room/futures.rs | 34 +++++++++++++-------------- 1 file changed, 16 insertions(+), 18 deletions(-) diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index 586b82a823a..54f486d0cd1 100644 --- a/crates/matrix-sdk/src/room/futures.rs +++ b/crates/matrix-sdk/src/room/futures.rs @@ -158,6 +158,20 @@ impl<'a> SendRawMessageLikeEvent<'a> { } } +/// Ensures the room is ready for encrypted events to be sent. +async fn ensure_room_encryption_ready(room: &Room) -> Result<()> { + if !room.are_members_synced() { + room.sync_members().await?; + } + // Query keys in case we don't have them for newly synced members. + // + // Note we do it all the time, because we might have sync'd members before + // sending a message (so didn't enter the above branch), but + // could have not query their keys ever. + room.query_keys_for_untracked_or_dirty_users().await?; + room.preshare_room_key().await +} + impl<'a> IntoFuture for SendRawMessageLikeEvent<'a> { type Output = Result; boxed_into_future!(extra_bounds: 'a); @@ -195,18 +209,7 @@ impl<'a> IntoFuture for SendRawMessageLikeEvent<'a> { "Sending encrypted event because the room is encrypted.", ); - if !room.are_members_synced() { - room.sync_members().await?; - } - - // Query keys in case we don't have them for newly synced members. - // - // Note we do it all the time, because we might have sync'd members before - // sending a message (so didn't enter the above branch), but - // could have not query their keys ever. - room.query_keys_for_untracked_or_dirty_users().await?; - - room.preshare_room_key().await?; + ensure_room_encryption_ready(room).await?; let olm = room.client.olm_machine().await; let olm = olm.as_ref().expect("Olm machine wasn't started"); @@ -481,12 +484,7 @@ impl<'a> IntoFuture for SendStateEventRaw<'a> { "Sending encrypted event because the room is encrypted.", ); - if !room.are_members_synced() { - room.sync_members().await?; - } - - room.query_keys_for_untracked_or_dirty_users().await?; - room.preshare_room_key().await?; + ensure_room_encryption_ready(room).await?; let olm = room.client.olm_machine().await; let olm = olm.as_ref().expect("Olm machine wasn't started");