Skip to content

feat(crypto): Add support for encrypted state events to matrix-sdk-crypto #5539

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Aug 19, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/matrix-sdk-common/src/deserialized_responses.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions crates/matrix-sdk-crypto/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions crates/matrix-sdk-crypto/src/gossiping/machine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
133 changes: 131 additions & 2 deletions crates/matrix-sdk-crypto/src/machine/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<C, K>(
&self,
room_id: &RoomId,
content: C,
state_key: K,
) -> MegolmResult<Raw<RoomEncryptedEventContent>>
where
C: StateEventContent,
C::StateKey: Borrow<K>,
K: AsRef<str>,
{
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<AnyStateEventContent>,
) -> MegolmResult<Raw<RoomEncryptedEventContent>> {
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.
///
Expand Down Expand Up @@ -2197,9 +2261,72 @@ impl OlmMachine {
.await;
}

let event = serde_json::from_value::<Raw<AnyTimelineEvent>>(decrypted_event.into())?;
let decrypted_event =
serde_json::from_value::<Raw<AnyTimelineEvent>>(decrypted_event.into())?;

#[cfg(feature = "experimental-encrypted-state-events")]
self.verify_packed_state_key(&event, &decrypted_event)?;

Ok(DecryptedRoomEvent { event, encryption_info, unsigned_encryption_info })
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<AnyTimelineEvent>,
) -> 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
Expand Down Expand Up @@ -2970,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.
Expand Down
Loading
Loading