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-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); } 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) } } 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. /// 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/olm/group_sessions/outbound.rs b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs index c682516f434..f35986bad65 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, @@ -488,14 +491,75 @@ 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; + self.encrypt_inner(&payload, relates_to).await + } + + /// 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 }; + self.encrypt_inner(&payload, None).await.cast_unchecked() + } + + /// 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, @@ -513,9 +577,7 @@ impl OutboundGroupSession { "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") } 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. 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, diff --git a/crates/matrix-sdk/src/room/futures.rs b/crates/matrix-sdk/src/room/futures.rs index f5dbd12a37d..54f486d0cd1 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; @@ -24,9 +24,12 @@ 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, MessageLikeEventContent}, + events::{ + AnyMessageLikeEventContent, AnyStateEventContent, MessageLikeEventContent, + StateEventContent, + }, serde::Raw, OwnedTransactionId, TransactionId, }; @@ -34,7 +37,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, }; @@ -153,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); @@ -190,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"); @@ -319,3 +327,194 @@ impl<'a> IntoFuture for SendAttachment<'a> { Box::pin(fut.instrument(tracing_span)) } } + +/// 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 + } +} + +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> { + 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 + } +} + +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.", + ); + + ensure_room_encryption_ready(room).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)) + } +} diff --git a/crates/matrix-sdk/src/room/mod.rs b/crates/matrix-sdk/src/room/mod.rs index d508ef26883..822e3ca8c95 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::{ @@ -153,6 +153,7 @@ use crate::{ media::{MediaFormat, MediaRequestParameters}, notification_settings::{IsEncrypted, IsOneToOne, RoomNotificationMode}, room::{ + futures::{SendStateEvent, SendStateEventRaw}, knock_requests::{KnockRequest, KnockRequestMemberInfo}, power_levels::{RoomPowerLevelChanges, RoomPowerLevelsExt}, privacy_settings::RoomPrivacySettings, @@ -636,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; } @@ -1916,6 +1924,28 @@ impl Room { /// ``` #[instrument(skip_all)] pub async fn enable_encryption(&self) -> Result<()> { + 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, }; @@ -1923,7 +1953,11 @@ impl Room { if !self.latest_encryption_state().await?.is_encrypted() { let content = - RoomEncryptionEventContent::new(EventEncryptionAlgorithm::MegolmV1AesSha2); + 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 @@ -1936,7 +1970,7 @@ impl Room { // 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() { + 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(); @@ -2646,11 +2680,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. @@ -2693,21 +2727,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. @@ -2745,22 +2775,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. 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 { 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(()) +}