From bd404e808cc2a2ca29cb880dafebca61cd19424d Mon Sep 17 00:00:00 2001 From: timedout Date: Fri, 16 Jan 2026 21:00:45 +0000 Subject: [PATCH] feat: Add invite membership check --- .../matrix/state_res/event_auth/context.rs | 6 +- .../state_res/event_auth/member_event.rs | 216 ++++++++++++++++-- 2 files changed, 207 insertions(+), 15 deletions(-) diff --git a/src/core/matrix/state_res/event_auth/context.rs b/src/core/matrix/state_res/event_auth/context.rs index f4cf0731..97f42bfd 100644 --- a/src/core/matrix/state_res/event_auth/context.rs +++ b/src/core/matrix/state_res/event_auth/context.rs @@ -70,9 +70,13 @@ where } /// Rank fetches the creatorship and power level of the target user +/// +/// Returns (UserPower, power_level, Option) +/// If UserPower::Creator is returned, the power_level and +/// RoomPowerLevelsEventContent will be meaningless and can be ignored. pub async fn get_rank( room_version: &RoomVersion, - fetch_state: FS, + fetch_state: &FS, user_id: &UserId, ) -> Result<(UserPower, Int, Option), Error> where diff --git a/src/core/matrix/state_res/event_auth/member_event.rs b/src/core/matrix/state_res/event_auth/member_event.rs index de56fe4a..23b5df44 100644 --- a/src/core/matrix/state_res/event_auth/member_event.rs +++ b/src/core/matrix/state_res/event_auth/member_event.rs @@ -3,12 +3,18 @@ //! See: https://spec.matrix.org/v1.16/rooms/v12/#authorization-rules use ruma::{ - EventId, OwnedUserId, RoomId, UserId, + EventId, OwnedUserId, UserId, events::{ StateEventType, - room::join_rules::{JoinRule, RoomJoinRulesEventContent}, + room::{ + join_rules::{JoinRule, RoomJoinRulesEventContent}, + third_party_invite::{PublicKey, RoomThirdPartyInviteEventContent}, + }, }, + serde::Base64, + signatures::{PublicKeyMap, PublicKeySet, verify_json}, }; +use serde::Deserializer; use crate::{ Event, EventTypeExt, Pdu, RoomVersion, @@ -17,22 +23,48 @@ use crate::{ Error, event_auth::context::{UserPower, get_rank}, }, + utils::to_canonical_object, }; -#[derive(serde::Deserialize)] +#[derive(serde::Deserialize, Default)] struct PartialMembershipObject { membership: Option, join_authorized_via_users_server: Option, third_party_invite: Option, } +/// Fetches the membership *content* of the target. +/// If there is not one, an empty leave membership is returned. +async fn fetch_membership( + fetch_state: &FS, + target: &UserId, +) -> Result +where + FS: AsyncFn((StateEventType, StateKey)) -> Result, Error>, +{ + fetch_state(StateEventType::RoomMember.with_state_key(target.as_str())) + .await + .map(|pdu| { + if let Some(ev) = pdu { + ev.get_content::().map_err(|e| { + Error::InvalidPdu(format!("m.room.member event has invalid content: {}", e)) + }) + } else { + Ok(PartialMembershipObject { + membership: Some("leave".to_owned()), + ..Default::default() + }) + } + })? +} + async fn check_join_event( - room_version: RoomVersion, + room_version: &RoomVersion, event: &Pdu, membership: &PartialMembershipObject, target: &UserId, - fetch_event: FE, - fetch_state: FS, + fetch_event: &FE, + fetch_state: &FS, ) -> Result<(), Error> where FE: AsyncFn(&EventId) -> Result, Error>, @@ -119,7 +151,7 @@ where } let join_authed_by = membership.join_authorized_via_users_server.as_ref(); if let Some(user_id) = join_authed_by { - let rank = get_rank(&room_version, &fetch_state, user_id).await?; + let rank = get_rank(&room_version, fetch_state, user_id).await?; if rank.0 == UserPower::Standard { // This user is not a creator, check that they have // sufficient power level @@ -168,9 +200,161 @@ where } } +async fn check_invite_event( + room_version: &RoomVersion, + event: &Pdu, + membership: &PartialMembershipObject, + target: &UserId, + fetch_state: &FS, +) -> Result<(), Error> +where + FE: AsyncFn(&EventId) -> Result, Error>, + FS: AsyncFn((StateEventType, StateKey)) -> Result, Error>, +{ + let target_current_membership = fetch_membership(fetch_state, target).await?; + + // 4.1: If content has a third_party_invite property: + if let Some(raw_third_party_invite) = &membership.third_party_invite { + // 4.1.1: If target user is banned, reject. + if target_current_membership + .membership + .is_some_and(|m| m == "ban") + { + return Err(Error::AuthConditionFailed("invite target is banned".to_owned())); + } + // 4.1.2: If content.third_party_invite does not have a signed property, reject. + let signed = raw_third_party_invite.get("signed").ok_or_else(|| { + Error::AuthConditionFailed( + "invite event third_party_invite missing signed property".to_owned(), + ) + })?; + // 4.2.3: If signed does not have mxid and token properties, reject. + let mxid = signed.get("mxid").and_then(|v| v.as_str()).ok_or_else(|| { + Error::AuthConditionFailed( + "invite event third_party_invite signed missing/invalid mxid property".to_owned(), + ) + })?; + let token = signed + .get("token") + .and_then(|v| v.as_str()) + .ok_or_else(|| { + Error::AuthConditionFailed( + "invite event third_party_invite signed missing token property".to_owned(), + ) + })?; + // 4.2.4: If mxid does not match state_key, reject. + if mxid != target.as_str() { + return Err(Error::AuthConditionFailed( + "invite event third_party_invite signed mxid does not match state_key".to_owned(), + )); + } + // 4.2.5: If there is no m.room.third_party_invite event in the room + // state matching the token, reject. + let Some(third_party_invite_event) = + fetch_state(StateEventType::RoomThirdPartyInvite.with_state_key(token)).await? + else { + return Err(Error::AuthConditionFailed( + "invite event third_party_invite token has no matching m.room.third_party_invite" + .to_owned(), + )); + }; + // 4.2.6: If sender does not match sender of the m.room.third_party_invite, + // reject. + if third_party_invite_event.sender() != event.sender() { + return Err(Error::AuthConditionFailed( + "invite event sender does not match m.room.third_party_invite sender".to_owned(), + )); + } + // 4.2.7: If any signature in signed matches any public key in the + // m.room.third_party_invite event, allow. The public keys are in + // content of m.room.third_party_invite as: + // 1. A single public key in the public_key property. + // 2. A list of public keys in the public_keys property. + let tpi_content = third_party_invite_event + .get_content::() + .or_else(|_| { + Err(Error::InvalidPdu( + "m.room.third_party_invite event has invalid content".to_owned(), + )) + })?; + let mut public_keys = tpi_content.public_keys.unwrap_or_default(); + public_keys.push(PublicKey { + public_key: tpi_content.public_key, + key_validity_url: None, + }); + + let signatures = signed + .get("signatures") + .and_then(|v| v.as_object()) + .ok_or_else(|| { + Error::InvalidPdu( + "invite event third_party_invite signed missing/invalid signatures" + .to_owned(), + ) + })?; + let mut public_key_map = PublicKeyMap::new(); + for (server_name, sig_map) in signatures { + let mut pk_set = PublicKeySet::new(); + if let Some(sig_map) = sig_map.as_object() { + for (key_id, sig) in sig_map { + let sig_b64 = Base64::parse(sig.as_str().ok_or(Error::InvalidPdu( + "invite event third_party_invite signature is not a string".to_owned(), + ))?) + .map_err(|_| { + Error::InvalidPdu( + "invite event third_party_invite signature is not valid Base64" + .to_owned(), + ) + })?; + pk_set.insert(key_id.clone(), sig_b64); + } + } + public_key_map.insert(server_name.clone(), pk_set); + } + verify_json( + &public_key_map, + to_canonical_object(signed).expect("signed was already validated"), + ) + .map_err(|e| { + Error::AuthConditionFailed(format!( + "invite event third_party_invite signature verification failed: {e}" + )) + })?; + // If there was no error, there was a valid signature, so allow. + return Ok(()); + } + + // 4.2: If the sender’s current membership state is not join, reject. + let sender_membership = fetch_membership(fetch_state, event.sender()).await?; + if sender_membership.membership.is_none_or(|m| m != "join") { + return Err(Error::AuthConditionFailed("invite sender is not joined".to_owned())); + } + + // 4.3: If target user’s current membership state is join or ban, reject. + if target_current_membership + .membership + .is_some_and(|m| m == "join" || m == "ban") + { + return Err(Error::AuthConditionFailed( + "invite target is already joined or banned".to_owned(), + )); + } + + // 4.4: If the sender’s power level is greater than or equal to the invite + // level, allow. + let (rank, pl, pl_evt) = get_rank(&room_version, fetch_state, event.sender()).await?; + if rank == UserPower::Creator || pl >= pl_evt.unwrap_or_default().invite { + return Ok(()); + } + + // 4.5: Otherwise, reject. + Err(Error::AuthConditionFailed( + "invite sender does not have sufficient power level to invite".to_owned(), + )) +} + pub async fn check_member_event( room_version: RoomVersion, - room_id: &RoomId, event: &Pdu, fetch_event: FE, fetch_state: FS, @@ -199,7 +383,6 @@ where "m.room.member event missing membership in content".to_owned(), )); } - let membership = content.membership.as_ref().unwrap(); // 2: If content has a join_authorised_via_users_server key // @@ -210,10 +393,15 @@ where todo!("Implement join_authorised_via_users_server check"); } - // 3: If membership is join: - if membership == "join" { - check_join_event(room_version, event, &content, &target, fetch_event, fetch_state) - .await?; - } + match content.membership.as_deref().unwrap() { + | "join" => + check_join_event(&room_version, event, &content, &target, &fetch_event, &fetch_state) + .await?, + | "invite" => + check_invite_event(&room_version, event, &content, &target, &fetch_state).await?, + | _ => { + todo!() + }, + }; Ok(()) }