From 6260811ea54f8a41b3cbae385cf43fe90f871573 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 12:47:04 +0100 Subject: [PATCH 1/5] feat(crypto): Add OutboundGroupSession::encrypt_state This commit also refactors out what would be common code between ::encrypt and ::encrypt_state to a helper ::encrypt_inner. Signed-off-by: Skye Elliot --- .../src/olm/group_sessions/outbound.rs | 106 ++++++++++++++---- 1 file changed, 84 insertions(+), 22 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 2fba9aaddca..94fac15c277 100644 --- a/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs +++ b/crates/matrix-sdk-crypto/src/olm/group_sessions/outbound.rs @@ -25,6 +25,8 @@ use std::{ }; use matrix_sdk_common::{deserialized_responses::WithheldCode, locks::RwLock as StdRwLock}; +#[cfg(feature = "experimental-encrypted-state-events")] +use ruma::events::AnyStateEventContent; use ruma::{ events::{ room::{encryption::RoomEncryptionEventContent, history_visibility::HistoryVisibility}, @@ -458,6 +460,50 @@ impl OutboundGroupSession { session.encrypt(&plaintext) } + /// 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 + /// serialized to JSON and encrypted. + /// + /// # 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, + 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") + } + /// Encrypt a room message for the given room. /// /// Beware that a room key needs to be shared before this method @@ -488,35 +534,51 @@ 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" - ), - }; + self.encrypt_inner(&payload, relates_to).await + } - let content = RoomEncryptedEventContent { scheme, relates_to, other: Default::default() }; + /// 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. + #[cfg(feature = "experimental-encrypted-state-events")] + 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, + } - Raw::new(&content).expect("m.room.encrypted event content can always be serialized") + let payload = Payload { event_type, state_key, content, room_id: &self.room_id }; + self.encrypt_inner(&payload, None).await } fn elapsed(&self) -> bool { From c32877284cdb22559d506c546451fb8fc99b8f12 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 12:52:15 +0100 Subject: [PATCH 2/5] feat(crypto): Add GroupSessionManager::encrypt_state Signed-off-by: Skye Elliot --- .../src/session_manager/group_sessions/mod.rs | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) 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..69fbb86119d 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 @@ -27,6 +27,8 @@ use itertools::Itertools; use matrix_sdk_common::{ deserialized_responses::WithheldCode, executor::spawn, locks::RwLock as StdRwLock, }; +#[cfg(feature = "experimental-encrypted-state-events")] +use ruma::events::AnyStateEventContent; use ruma::{ events::{AnyMessageLikeEventContent, AnyToDeviceEventContent, ToDeviceEventType}, serde::Raw, @@ -224,6 +226,50 @@ 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 session exists for the given room ID, or the session + /// has expired. + #[cfg(feature = "experimental-encrypted-state-events")] + 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 756d50737e5693708d30d1a03a6607e059a7dee0 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Wed, 6 Aug 2025 14:16:49 +0100 Subject: [PATCH 3/5] feat(crypto): Add state event encryption methods to OlmMachine Signed-off-by: Skye Elliot --- crates/matrix-sdk-crypto/src/machine/mod.rs | 64 ++++++++++++++ .../src/machine/tests/mod.rs | 83 +++++++++++++++++++ 2 files changed, 147 insertions(+) diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 6346dfdac93..30553d744dc 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#[cfg(feature = "experimental-encrypted-state-events")] +use std::borrow::Borrow; use std::{ collections::{BTreeMap, HashMap, HashSet}, sync::Arc, @@ -31,6 +33,8 @@ use matrix_sdk_common::{ locks::RwLock as StdRwLock, BoxFuture, }; +#[cfg(feature = "experimental-encrypted-state-events")] +use ruma::events::{AnyStateEventContent, StateEventContent}; use ruma::{ api::client::{ dehydrated_device::DehydratedDeviceData, @@ -1102,6 +1106,66 @@ 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. + #[cfg(feature = "experimental-encrypted-state-events")] + 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. + /// + /// * `event_type` - The type of the event. + /// + /// * `state_key` - The associated state key of the event. + /// + /// * `content` - The plaintext content of the event that should be + /// encrypted as a raw JSON value. + #[cfg(feature = "experimental-encrypted-state-events")] + 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/mod.rs b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs index e96493f3653..ded08c1d037 100644 --- a/crates/matrix-sdk-crypto/src/machine/tests/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs @@ -26,6 +26,11 @@ use matrix_sdk_common::{ executor::spawn, }; use matrix_sdk_test::{async_test, message_like_event_content, ruma_response_from_json, test_json}; +#[cfg(feature = "experimental-encrypted-state-events")] +use ruma::events::{ + room::topic::{OriginalRoomTopicEvent, RoomTopicEventContent}, + StateEvent, +}; use ruma::{ api::client::{ keys::{get_keys, upload_keys}, @@ -727,6 +732,84 @@ async fn test_megolm_encryption() { } } +#[cfg(feature = "experimental-encrypted-state-events")] +#[async_test] +async fn test_megolm_state_encryption() { + use ruma::events::{AnyStateEvent, EmptyStateKey}; + + let (alice, bob) = + get_machine_pair_with_setup_sessions_test_helper(alice_id(), user_id(), false).await; + let room_id = room_id!("!test:example.org"); + + let to_device_requests = alice + .share_room_key(room_id, iter::once(bob.user_id()), EncryptionSettings::default()) + .await + .unwrap(); + + let event = ToDeviceEvent::new( + alice.user_id().to_owned(), + to_device_requests_to_content(to_device_requests), + ); + + let decryption_settings = + DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }; + + let group_session = bob + .store() + .with_transaction(|mut tr| async { + let res = bob + .decrypt_to_device_event( + &mut tr, + &event, + &mut Changes::default(), + &decryption_settings, + ) + .await?; + Ok((tr, res)) + }) + .await + .unwrap() + .inbound_group_session + .unwrap(); + let sessions = std::slice::from_ref(&group_session); + bob.store().save_inbound_group_sessions(sessions).await.unwrap(); + + let plaintext = "It is a secret to everybody"; + + let content = RoomTopicEventContent::new(plaintext.to_owned()); + + let encrypted_content = + alice.encrypt_state_event(room_id, content, EmptyStateKey).await.unwrap(); + + let event = json!({ + "event_id": "$xxxxx:example.org", + "origin_server_ts": MilliSecondsSinceUnixEpoch::now(), + "sender": alice.user_id(), + "type": "m.room.encrypted", + "content": encrypted_content, + }); + + let event = json_convert(&event).unwrap(); + + let decryption_settings = + DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }; + + let decryption_result = + bob.try_decrypt_room_event(&event, room_id, &decryption_settings).await.unwrap(); + assert_let!(RoomEventDecryptionResult::Decrypted(decrypted_event) = decryption_result); + let decrypted_event = decrypted_event.event.deserialize().unwrap(); + + if let AnyTimelineEvent::State(AnyStateEvent::RoomTopic(StateEvent::Original( + OriginalRoomTopicEvent { sender, content, .. }, + ))) = decrypted_event + { + assert_eq!(&sender, alice.user_id()); + assert_eq!(&content.topic, plaintext); + } else { + panic!("Decrypted room event has the wrong type"); + } +} + #[async_test] async fn test_withheld_unverified() { let (alice, bob) = From 84ebbd913cac44ecb5913b7e40e67e3a3a479d5e Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Tue, 12 Aug 2025 13:50:09 +0100 Subject: [PATCH 4/5] feat: Add naive state key verification to OlmMachine Modifies `OlmMachine::decrypt_room_event_inner` to call a new method `OlmMachine::verify_packed_state_key` which, if the event is a state event, verifies that the original event's state key, when unpacked, matches the state key and event type in the decrypted event content. Introduces MegolmError::StateKeyVerificationFailed and UnableToDecryptReason::StateKeyVerificationFailed which are thrown when the verification fails. Signed-off-by: Skye Elliot --- .../src/deserialized_responses.rs | 5 + crates/matrix-sdk-crypto/src/error.rs | 6 + .../src/gossiping/machine.rs | 2 + crates/matrix-sdk-crypto/src/machine/mod.rs | 69 +++++++++++- .../src/machine/tests/mod.rs | 104 ++++++++++++++++-- .../src/types/events/room/mod.rs | 4 + 6 files changed, 181 insertions(+), 9 deletions(-) diff --git a/crates/matrix-sdk-common/src/deserialized_responses.rs b/crates/matrix-sdk-common/src/deserialized_responses.rs index 082ad20b30b..38aa9f635cd 100644 --- a/crates/matrix-sdk-common/src/deserialized_responses.rs +++ b/crates/matrix-sdk-common/src/deserialized_responses.rs @@ -998,6 +998,11 @@ pub enum UnableToDecryptReason { /// cross-signing identity did not satisfy the requested /// `TrustRequirement`. SenderIdentityNotTrusted(VerificationLevel), + + /// The outer state key could not be verified against the inner encrypted + /// state key and type. + #[cfg(feature = "experimental-encrypted-state-events")] + StateKeyVerificationFailed, } impl UnableToDecryptReason { diff --git a/crates/matrix-sdk-crypto/src/error.rs b/crates/matrix-sdk-crypto/src/error.rs index 09915b44d5f..35877ae78ad 100644 --- a/crates/matrix-sdk-crypto/src/error.rs +++ b/crates/matrix-sdk-crypto/src/error.rs @@ -133,6 +133,12 @@ pub enum MegolmError { /// The nested value is the sender's current verification level. #[error("decryption failed because trust requirement not satisfied: {0}")] SenderIdentityNotTrusted(VerificationLevel), + + /// The outer state key could not be verified against the inner encrypted + /// state key and type. + #[cfg(feature = "experimental-encrypted-state-events")] + #[error("decryption failed because the state key failed to validate")] + StateKeyVerificationFailed, } /// Decryption failed because of a mismatch between the identity keys of the diff --git a/crates/matrix-sdk-crypto/src/gossiping/machine.rs b/crates/matrix-sdk-crypto/src/gossiping/machine.rs index 7f55b90ecea..edba4f12d03 100644 --- a/crates/matrix-sdk-crypto/src/gossiping/machine.rs +++ b/crates/matrix-sdk-crypto/src/gossiping/machine.rs @@ -1364,6 +1364,8 @@ mod tests { EncryptedEvent { sender: sender.to_owned(), event_id: event_id!("$143273582443PhrSn:example.org").to_owned(), + #[cfg(feature = "experimental-encrypted-state-events")] + state_key: None, content, origin_server_ts: ruma::MilliSecondsSinceUnixEpoch::now(), unsigned: Default::default(), diff --git a/crates/matrix-sdk-crypto/src/machine/mod.rs b/crates/matrix-sdk-crypto/src/machine/mod.rs index 30553d744dc..785d8aa4a69 100644 --- a/crates/matrix-sdk-crypto/src/machine/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/mod.rs @@ -2261,9 +2261,72 @@ impl OlmMachine { .await; } - let event = serde_json::from_value::>(decrypted_event.into())?; + let decrypted_event = + serde_json::from_value::>(decrypted_event.into())?; - Ok(DecryptedRoomEvent { event, encryption_info, unsigned_encryption_info }) + #[cfg(feature = "experimental-encrypted-state-events")] + self.verify_packed_state_key(&event, &decrypted_event)?; + + Ok(DecryptedRoomEvent { event: decrypted_event, encryption_info, unsigned_encryption_info }) + } + + /// If the passed event is a state event, verify its outer packed state key + /// matches the inner state key once unpacked. + /// + /// * `original` - The original encrypted event received over the wire. + /// * `decrypted` - The decrypted event. + /// + /// # Errors + /// + /// Returns an error if any of the following are true: + /// + /// * The original event's state key failed to unpack; + /// * The decrypted event could not be deserialised; + /// * The unpacked event type does not match the type of the decrypted + /// event; + /// * The unpacked event state key does not match the state key of the + /// decrypted event. + #[cfg(feature = "experimental-encrypted-state-events")] + fn verify_packed_state_key( + &self, + original: &EncryptedEvent, + decrypted: &Raw, + ) -> MegolmResult<()> { + use serde::Deserialize; + + // We only need to verify state events. + let Some(raw_state_key) = &original.state_key else { return Ok(()) }; + + // Unpack event type and state key from the raw state key. + let (outer_event_type, outer_state_key) = + raw_state_key.split_once(":").ok_or(MegolmError::StateKeyVerificationFailed)?; + + // Helper for deserializing. + #[derive(Deserialize)] + struct PayloadDeserializationHelper { + state_key: String, + #[serde(rename = "type")] + event_type: String, + } + + // Deserialize the decrypted event. + let PayloadDeserializationHelper { + state_key: inner_state_key, + event_type: inner_event_type, + } = decrypted + .deserialize_as_unchecked() + .map_err(|_| MegolmError::StateKeyVerificationFailed)?; + + // Check event types match, discard if not. + if outer_event_type != inner_event_type { + return Err(MegolmError::StateKeyVerificationFailed); + } + + // Check state keys match, discard if not. + if outer_state_key != inner_state_key { + return Err(MegolmError::StateKeyVerificationFailed); + } + Ok(()) } /// Try to decrypt the events bundled in the `unsigned` object of the given @@ -3034,6 +3097,8 @@ fn megolm_error_to_utd_info( JsonError(_) => UnableToDecryptReason::PayloadDeserializationFailure, MismatchedIdentityKeys(_) => UnableToDecryptReason::MismatchedIdentityKeys, SenderIdentityNotTrusted(level) => UnableToDecryptReason::SenderIdentityNotTrusted(level), + #[cfg(feature = "experimental-encrypted-state-events")] + StateKeyVerificationFailed => UnableToDecryptReason::StateKeyVerificationFailed, // Pass through crypto store errors, which indicate a problem with our // application, rather than a UTD. diff --git a/crates/matrix-sdk-crypto/src/machine/tests/mod.rs b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs index ded08c1d037..33cad333342 100644 --- a/crates/matrix-sdk-crypto/src/machine/tests/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs @@ -733,13 +733,9 @@ async fn test_megolm_encryption() { } #[cfg(feature = "experimental-encrypted-state-events")] -#[async_test] -async fn test_megolm_state_encryption() { - use ruma::events::{AnyStateEvent, EmptyStateKey}; - +async fn megolm_encryption_setup_helper(room_id: &RoomId) -> (OlmMachine, OlmMachine) { let (alice, bob) = get_machine_pair_with_setup_sessions_test_helper(alice_id(), user_id(), false).await; - let room_id = room_id!("!test:example.org"); let to_device_requests = alice .share_room_key(room_id, iter::once(bob.user_id()), EncryptionSettings::default()) @@ -774,10 +770,19 @@ async fn test_megolm_state_encryption() { let sessions = std::slice::from_ref(&group_session); bob.store().save_inbound_group_sessions(sessions).await.unwrap(); - let plaintext = "It is a secret to everybody"; + (alice, bob) +} - let content = RoomTopicEventContent::new(plaintext.to_owned()); +#[cfg(feature = "experimental-encrypted-state-events")] +#[async_test] +async fn test_megolm_state_encryption() { + use ruma::events::{AnyStateEvent, EmptyStateKey}; + let room_id = room_id!("!test:example.org"); + let (alice, bob) = megolm_encryption_setup_helper(room_id).await; + + let plaintext = "It is a secret to everybody"; + let content = RoomTopicEventContent::new(plaintext.to_owned()); let encrypted_content = alice.encrypt_state_event(room_id, content, EmptyStateKey).await.unwrap(); @@ -786,6 +791,7 @@ async fn test_megolm_state_encryption() { "origin_server_ts": MilliSecondsSinceUnixEpoch::now(), "sender": alice.user_id(), "type": "m.room.encrypted", + "state_key": "m.room.topic:", "content": encrypted_content, }); @@ -796,7 +802,9 @@ async fn test_megolm_state_encryption() { let decryption_result = bob.try_decrypt_room_event(&event, room_id, &decryption_settings).await.unwrap(); + assert_let!(RoomEventDecryptionResult::Decrypted(decrypted_event) = decryption_result); + let decrypted_event = decrypted_event.event.deserialize().unwrap(); if let AnyTimelineEvent::State(AnyStateEvent::RoomTopic(StateEvent::Original( @@ -810,6 +818,88 @@ async fn test_megolm_state_encryption() { } } +#[cfg(feature = "experimental-encrypted-state-events")] +#[async_test] +async fn test_megolm_state_encryption_bad_type() { + use ruma::events::EmptyStateKey; + + let room_id = room_id!("!test:example.org"); + let (alice, bob) = megolm_encryption_setup_helper(room_id).await; + + let plaintext = "It is a secret to everybody"; + let content = RoomTopicEventContent::new(plaintext.to_owned()); + let encrypted_content = + alice.encrypt_state_event(room_id, content, EmptyStateKey).await.unwrap(); + + // Malformed events + let bad_type_event = json!({ + "event_id": "$xxxxx:example.org", + "origin_server_ts": MilliSecondsSinceUnixEpoch::now(), + "sender": alice.user_id(), + "type": "m.room.encrypted", + "state_key": "m.room.malformed:", + "content": encrypted_content, + }); + + let bad_type_event = json_convert(&bad_type_event).unwrap(); + + let decryption_settings = + DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }; + + let bad_type_decryption_result = + bob.try_decrypt_room_event(&bad_type_event, room_id, &decryption_settings).await.unwrap(); + + assert_matches!( + bad_type_decryption_result, + RoomEventDecryptionResult::UnableToDecrypt(UnableToDecryptInfo { + reason: UnableToDecryptReason::StateKeyVerificationFailed, + .. + }) + ); +} + +#[cfg(feature = "experimental-encrypted-state-events")] +#[async_test] +async fn test_megolm_state_encryption_bad_state_key() { + use ruma::events::EmptyStateKey; + + let room_id = room_id!("!test:example.org"); + let (alice, bob) = megolm_encryption_setup_helper(room_id).await; + + let plaintext = "It is a secret to everybody"; + let content = RoomTopicEventContent::new(plaintext.to_owned()); + let encrypted_content = + alice.encrypt_state_event(room_id, content, EmptyStateKey).await.unwrap(); + + let bad_state_key_event = json!({ + "event_id": "$xxxxx:example.org", + "origin_server_ts": MilliSecondsSinceUnixEpoch::now(), + "sender": alice.user_id(), + "type": "m.room.encrypted", + "state_key": "m.room.malformed:", + "content": encrypted_content, + }); + + let bad_state_key_event = json_convert(&bad_state_key_event).unwrap(); + + let decryption_settings = + DecryptionSettings { sender_device_trust_requirement: TrustRequirement::Untrusted }; + + let bad_state_key_decryption_result = bob + .try_decrypt_room_event(&bad_state_key_event, room_id, &decryption_settings) + .await + .unwrap(); + + // Require malformed events fail verification + assert_matches!( + bad_state_key_decryption_result, + RoomEventDecryptionResult::UnableToDecrypt(UnableToDecryptInfo { + reason: UnableToDecryptReason::StateKeyVerificationFailed, + .. + }) + ); +} + #[async_test] async fn test_withheld_unverified() { let (alice, bob) = diff --git a/crates/matrix-sdk-crypto/src/types/events/room/mod.rs b/crates/matrix-sdk-crypto/src/types/events/room/mod.rs index e0858e83bd1..582bbfe7f9e 100644 --- a/crates/matrix-sdk-crypto/src/types/events/room/mod.rs +++ b/crates/matrix-sdk-crypto/src/types/events/room/mod.rs @@ -36,6 +36,10 @@ where /// The globally unique identifier for this event. pub event_id: OwnedEventId, + /// Present if and only if this event is a state event. + #[cfg(feature = "experimental-encrypted-state-events")] + pub state_key: Option, + /// The body of this event, as created by the client which sent it. pub content: C, From 13ee4c809842c9f3098548b10ea826333c75f44f Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Tue, 19 Aug 2025 10:17:14 +0100 Subject: [PATCH 5/5] tests(crypto): Document introduced tests and helper Signed-off-by: Skye Elliot --- .../src/machine/tests/mod.rs | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/crates/matrix-sdk-crypto/src/machine/tests/mod.rs b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs index 33cad333342..259b3454453 100644 --- a/crates/matrix-sdk-crypto/src/machine/tests/mod.rs +++ b/crates/matrix-sdk-crypto/src/machine/tests/mod.rs @@ -732,6 +732,19 @@ async fn test_megolm_encryption() { } } +/// Helper function to set up end-to-end Megolm encryption between two devices. +/// +/// Creates two devices, Alice and Bob, and has Alice create an outgoing Megolm +/// session in the given room, whose decryption key is shared with Bob via a +/// to-device message. +/// +/// # Arguments +/// +/// * `room_id` - The RoomId for which to set up Megolm encryption. +/// +/// # Returns +/// +/// A tuple containing the alice and bob OlmMachine instances. #[cfg(feature = "experimental-encrypted-state-events")] async fn megolm_encryption_setup_helper(room_id: &RoomId) -> (OlmMachine, OlmMachine) { let (alice, bob) = @@ -773,6 +786,9 @@ async fn megolm_encryption_setup_helper(room_id: &RoomId) -> (OlmMachine, OlmMac (alice, bob) } +/// Verifies that Megolm-encrypted state events can be encrypted and decrypted +/// correctly, and that the decrypted event matches the expected type and +/// content. #[cfg(feature = "experimental-encrypted-state-events")] #[async_test] async fn test_megolm_state_encryption() { @@ -818,6 +834,9 @@ async fn test_megolm_state_encryption() { } } +/// Verifies that decryption fails with StateKeyVerificationFailed +/// when unpacking the state_key of the decrypted event yields an event type +/// that does not exist or does not match the type in the decrypted ciphertext. #[cfg(feature = "experimental-encrypted-state-events")] #[async_test] async fn test_megolm_state_encryption_bad_type() { @@ -831,7 +850,6 @@ async fn test_megolm_state_encryption_bad_type() { let encrypted_content = alice.encrypt_state_event(room_id, content, EmptyStateKey).await.unwrap(); - // Malformed events let bad_type_event = json!({ "event_id": "$xxxxx:example.org", "origin_server_ts": MilliSecondsSinceUnixEpoch::now(), @@ -858,6 +876,9 @@ async fn test_megolm_state_encryption_bad_type() { ); } +/// Verifies that decryption fails with StateKeyVerificationFailed +/// when unpacking the state_key of the decrypted event yields a state_key +/// that does not match the state_key in the decrypted ciphertext. #[cfg(feature = "experimental-encrypted-state-events")] #[async_test] async fn test_megolm_state_encryption_bad_state_key() { @@ -890,7 +911,6 @@ async fn test_megolm_state_encryption_bad_state_key() { .await .unwrap(); - // Require malformed events fail verification assert_matches!( bad_state_key_decryption_result, RoomEventDecryptionResult::UnableToDecrypt(UnableToDecryptInfo {