diff --git a/src/api/client/membership/join.rs b/src/api/client/membership/join.rs index a249be09..5cabf6e6 100644 --- a/src/api/client/membership/join.rs +++ b/src/api/client/membership/join.rs @@ -657,7 +657,6 @@ async fn join_room_by_id_helper_remote( let auth_check = state_res::event_auth::auth_check( &state_res::RoomVersion::new(&room_version_id)?, &parsed_join_pdu, - None, // TODO: third party invite |k, s| state_fetch(k.clone(), s.into()), &state_fetch(StateEventType::RoomCreate, "".into()) .await diff --git a/src/core/matrix/state_res/event_auth.rs b/src/core/matrix/state_res/event_auth.rs index 24f03162..a5184034 100644 --- a/src/core/matrix/state_res/event_auth.rs +++ b/src/core/matrix/state_res/event_auth.rs @@ -15,6 +15,7 @@ use ruma::{ }, int, serde::{Base64, Raw}, + signatures::{PublicKeyMap, PublicKeySet, verify_json}, }; use serde::{ Deserialize, @@ -30,7 +31,7 @@ use super::{ }, room_version::RoomVersion, }; -use crate::{debug, error, trace, warn}; +use crate::{debug, error, trace, utils::to_canonical_object, warn}; // FIXME: field extracting could be bundled for `content` #[derive(Deserialize)] @@ -157,15 +158,14 @@ pub fn auth_types_for_event( pub async fn auth_check( room_version: &RoomVersion, incoming_event: &E, - current_third_party_invite: Option<&E>, fetch_state: F, create_event: &E, ) -> Result where - F: Fn(&StateEventType, &str) -> Fut + Send, + F: Fn(&StateEventType, &str) -> Fut + Send + Sync, Fut: Future> + Send, E: Event + Send + Sync, - for<'a> &'a E: Event + Send, + for<'a> &'a E: Event + Send + Sync, { debug!( event_id = %incoming_event.event_id(), @@ -415,13 +415,15 @@ where sender, sender_member_event.as_ref(), incoming_event, - current_third_party_invite, power_levels_event.as_ref(), join_rules_event.as_ref(), user_for_join_auth.as_deref(), &user_for_join_auth_membership, &room_create_event, - )? { + &fetch_state, + ) + .await? + { return Ok(false); } @@ -658,23 +660,25 @@ where /// event and the current State. #[allow(clippy::too_many_arguments)] #[allow(clippy::cognitive_complexity)] -fn valid_membership_change( +async fn valid_membership_change( room_version: &RoomVersion, target_user: &UserId, target_user_membership_event: Option<&E>, sender: &UserId, sender_membership_event: Option<&E>, current_event: &E, - current_third_party_invite: Option<&E>, power_levels_event: Option<&E>, join_rules_event: Option<&E>, user_for_join_auth: Option<&UserId>, user_for_join_auth_membership: &MembershipState, create_room: &E, + fetch_state: &F, ) -> Result where + F: Fn(&StateEventType, &str) -> Fut + Send + Sync, + Fut: Future> + Send, E: Event + Send + Sync, - for<'a> &'a E: Event + Send, + for<'a> &'a E: Event + Send + Sync, { #[derive(Deserialize)] struct GetThirdPartyInvite { @@ -950,68 +954,62 @@ where | MembershipState::Invite => { // If content has third_party_invite key trace!("starting target_membership=invite check"); - match third_party_invite.and_then(|i| i.deserialize().ok()) { - | Some(tp_id) => - if target_user_current_membership == MembershipState::Ban { - warn!(?target_user_membership_event_id, "Can't invite banned user"); - false - } else { - let allow = verify_third_party_invite( - Some(target_user), - sender, - &tp_id, - current_third_party_invite, - ); - if !allow { - warn!("Third party invite invalid"); - } - allow - }, - | _ => - if !sender_is_joined { - warn!( - %sender, - ?sender_membership_event_id, - ?sender_membership, - "sender cannot produce an invite without being joined to the room", - ); - false - } else if matches!( - target_user_current_membership, - MembershipState::Join | MembershipState::Ban - ) { - warn!( - ?target_user_membership_event_id, - ?target_user_current_membership, - "cannot invite a user who is banned or already joined", - ); - false - } else { - let allow = sender_creator - || sender_power - .filter(|&p| p >= &power_levels.invite) - .is_some(); - if !allow { - warn!( - %sender, - has=?sender_power, - required=?power_levels.invite, - "sender does not have enough power to produce invites", - ); - } - trace!( - %sender, - ?sender_membership_event_id, - ?sender_membership, - ?target_user_membership_event_id, - ?target_user_current_membership, - sender_pl=?sender_power, - required_pl=?power_levels.invite, - "allowing invite" - ); - allow - }, + if let Some(third_party_invite) = third_party_invite { + let allow = verify_third_party_invite( + target_user_current_membership, + &serde_json::to_value(third_party_invite)?, + target_user, + current_event, + fetch_state, + ) + .await; + if !allow { + warn!("Third party invite invalid"); + } + return Ok(allow); } + if !sender_is_joined { + warn!( + %sender, + ?sender_membership_event_id, + ?sender_membership, + "sender cannot produce an invite without being joined to the room", + ); + return Ok(false); + } else if matches!( + target_user_current_membership, + MembershipState::Join | MembershipState::Ban + ) { + warn!( + ?target_user_membership_event_id, + ?target_user_current_membership, + "cannot invite a user who is banned or already joined", + ); + return Ok(false); + } + let allow = sender_creator + || sender_power + .filter(|&p| p >= &power_levels.invite) + .is_some(); + if !allow { + warn!( + %sender, + has=?sender_power, + required=?power_levels.invite, + "sender does not have enough power to produce invites", + ); + } + trace!( + %sender, + ?sender_membership_event_id, + ?sender_membership, + ?target_user_membership_event_id, + ?target_user_current_membership, + sender_pl=?sender_power, + required_pl=?power_levels.invite, + "allowing invite" + ); + return Ok(allow); }, | MembershipState::Leave => { let can_unban = if target_user_current_membership == MembershipState::Ban { @@ -1499,399 +1497,102 @@ fn get_send_level( .unwrap_or_else(|| if state_key.is_some() { int!(50) } else { int!(0) }) } -fn verify_third_party_invite( - target_user: Option<&UserId>, - sender: &UserId, - tp_id: &ThirdPartyInvite, - current_third_party_invite: Option<&impl Event>, -) -> bool { - // 1. Check for user being banned happens before this is called - // checking for mxid and token keys is done by ruma when deserializing - - // The state key must match the invitee - if target_user != Some(&tp_id.signed.mxid) { +/// Checks a third-party invite is valid. +async fn verify_third_party_invite( + target_current_membership: MembershipState, + raw_third_party_invite: &serde_json::Value, + target: &UserId, + event: &E, + fetch_state: &F, +) -> bool +where + F: Fn(&StateEventType, &str) -> Fut + Send + Sync, + Fut: Future> + Send, + E: Event + Send + Sync, + for<'a> &'a E: Event + Send + Sync, +{ + // 4.1.1: If target user is banned, reject. + if target_current_membership == MembershipState::Ban { + warn!("invite target is banned"); return false; } - - // If there is no m.room.third_party_invite event in the current room state with - // state_key matching token, reject - #[allow(clippy::manual_let_else)] - let current_tpid = match current_third_party_invite { - | Some(id) => id, - | None => return false, + // 4.1.2: If content.third_party_invite does not have a signed property, reject. + let Some(signed) = raw_third_party_invite.get("signed") else { + warn!("invite event third_party_invite missing signed property"); + return false; }; - - if current_tpid.state_key() != Some(&tp_id.signed.token) { + // 4.2.3: If signed does not have mxid and token properties, reject. + let Some(mxid) = signed.get("mxid").and_then(|v| v.as_str()) else { + warn!("invite event third_party_invite signed missing/invalid mxid property"); return false; - } - - if sender != current_tpid.sender() { - return false; - } - - // If any signature in signed matches any public key in the - // m.room.third_party_invite event, allow - #[allow(clippy::manual_let_else)] - let tpid_ev = - match from_json_str::(current_tpid.content().get()) { - | Ok(ev) => ev, - | Err(_) => return false, - }; - - #[allow(clippy::manual_let_else)] - let decoded_invite_token = match Base64::parse(&tp_id.signed.token) { - | Ok(tok) => tok, - // FIXME: Log a warning? - | Err(_) => return false, }; + let Some(token) = signed.get("token").and_then(|v| v.as_str()) else { + warn!("invite event third_party_invite signed missing token property"); + return false; + }; + // 4.2.4: If mxid does not match state_key, reject. + if mxid != target.as_str() { + warn!("invite event third_party_invite signed mxid does not match state_key"); + return false; + } + // 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, token).await + else { + warn!("invite event third_party_invite token has no matching m.room.third_party_invite"); + return false; + }; + // 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() { + warn!("invite event sender does not match m.room.third_party_invite sender"); + return false; + } + // 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. + if third_party_invite_event + .get_content::() + .is_err() + { + warn!("m.room.third_party_invite event has invalid content"); + return false; + } - // A list of public keys in the public_keys field - for key in tpid_ev.public_keys.unwrap_or_default() { - if key.public_key == decoded_invite_token { - return true; + let Some(signatures) = signed.get("signatures").and_then(|v| v.as_object()) else { + warn!("invite event third_party_invite signed missing/invalid signatures"); + return false; + }; + 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 Some(sig_str) = sig.as_str() else { + warn!( + "invite event third_party_invite signature is not a string or is missing" + ); + return false; + }; + let Ok(sig_b64) = Base64::parse(sig_str) else { + warn!("invite event third_party_invite signature is not valid Base64"); + return false; + }; + pk_set.insert(key_id.clone(), sig_b64); + } } + public_key_map.insert(server_name.clone(), pk_set); } - - // A single public key in the public_key field - tpid_ev.public_key == decoded_invite_token -} - -#[cfg(test)] -mod tests { - use ruma::events::{ - StateEventType, TimelineEventType, - room::{ - join_rules::{ - AllowRule, JoinRule, Restricted, RoomJoinRulesEventContent, RoomMembership, - }, - member::{MembershipState, RoomMemberEventContent}, - }, - }; - use serde_json::value::to_raw_value as to_raw_json_value; - - use crate::{ - matrix::{Event, EventTypeExt, Pdu as PduEvent}, - state_res::{ - RoomVersion, StateMap, - event_auth::valid_membership_change, - test_utils::{ - INITIAL_EVENTS, INITIAL_EVENTS_CREATE_ROOM, alice, charlie, ella, event_id, - member_content_ban, member_content_join, room_id, to_pdu_event, - }, - }, - }; - - #[test] - fn test_ban_pass() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let events = INITIAL_EVENTS(); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - alice(), - TimelineEventType::RoomMember, - Some(charlie().as_str()), - member_content_ban(), - &[], - &["IMC"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = charlie(); - let sender = alice(); - - assert!( - valid_membership_change( - &RoomVersion::V6, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - None, - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } - - #[test] - fn test_join_non_creator() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let events = INITIAL_EVENTS_CREATE_ROOM(); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - charlie(), - TimelineEventType::RoomMember, - Some(charlie().as_str()), - member_content_join(), - &["CREATE"], - &["CREATE"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = charlie(); - let sender = charlie(); - - assert!( - !valid_membership_change( - &RoomVersion::V6, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - None, - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } - - #[test] - fn test_join_creator() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let events = INITIAL_EVENTS_CREATE_ROOM(); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - alice(), - TimelineEventType::RoomMember, - Some(alice().as_str()), - member_content_join(), - &["CREATE"], - &["CREATE"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = alice(); - let sender = alice(); - - assert!( - valid_membership_change( - &RoomVersion::V6, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - None, - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } - - #[test] - fn test_ban_fail() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let events = INITIAL_EVENTS(); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - charlie(), - TimelineEventType::RoomMember, - Some(alice().as_str()), - member_content_ban(), - &[], - &["IMC"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = alice(); - let sender = charlie(); - - assert!( - !valid_membership_change( - &RoomVersion::V6, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - None, - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } - - #[test] - fn test_restricted_join_rule() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let mut events = INITIAL_EVENTS(); - *events.get_mut(&event_id("IJR")).unwrap() = to_pdu_event( - "IJR", - alice(), - TimelineEventType::RoomJoinRules, - Some(""), - to_raw_json_value(&RoomJoinRulesEventContent::new(JoinRule::Restricted( - Restricted::new(vec![AllowRule::RoomMembership(RoomMembership::new( - room_id().to_owned(), - ))]), - ))) - .unwrap(), - &["CREATE", "IMA", "IPOWER"], - &["IPOWER"], - ); - - let mut member = RoomMemberEventContent::new(MembershipState::Join); - member.join_authorized_via_users_server = Some(alice().to_owned()); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - ella(), - TimelineEventType::RoomMember, - Some(ella().as_str()), - to_raw_json_value(&RoomMemberEventContent::new(MembershipState::Join)).unwrap(), - &["CREATE", "IJR", "IPOWER", "new"], - &["new"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = ella(); - let sender = ella(); - - assert!( - valid_membership_change( - &RoomVersion::V9, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - Some(alice()), - &MembershipState::Join, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - - assert!( - !valid_membership_change( - &RoomVersion::V9, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - Some(ella()), - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } - - #[test] - fn test_knock() { - let _ = tracing::subscriber::set_default( - tracing_subscriber::fmt().with_test_writer().finish(), - ); - let mut events = INITIAL_EVENTS(); - *events.get_mut(&event_id("IJR")).unwrap() = to_pdu_event( - "IJR", - alice(), - TimelineEventType::RoomJoinRules, - Some(""), - to_raw_json_value(&RoomJoinRulesEventContent::new(JoinRule::Knock)).unwrap(), - &["CREATE", "IMA", "IPOWER"], - &["IPOWER"], - ); - - let auth_events = events - .values() - .map(|ev| (ev.event_type().with_state_key(ev.state_key().unwrap()), ev.clone())) - .collect::>(); - - let requester = to_pdu_event( - "HELLO", - ella(), - TimelineEventType::RoomMember, - Some(ella().as_str()), - to_raw_json_value(&RoomMemberEventContent::new(MembershipState::Knock)).unwrap(), - &[], - &["IMC"], - ); - - let fetch_state = |ty, key| auth_events.get(&(ty, key)).cloned(); - let target_user = ella(); - let sender = ella(); - - assert!( - valid_membership_change( - &RoomVersion::V7, - target_user, - fetch_state(StateEventType::RoomMember, target_user.as_str().into()).as_ref(), - sender, - fetch_state(StateEventType::RoomMember, sender.as_str().into()).as_ref(), - &requester, - None::<&PduEvent>, - fetch_state(StateEventType::RoomPowerLevels, "".into()).as_ref(), - fetch_state(StateEventType::RoomJoinRules, "".into()).as_ref(), - None, - &MembershipState::Leave, - &fetch_state(StateEventType::RoomCreate, "".into()).unwrap(), - ) - .unwrap() - ); - } + if let Err(e) = verify_json( + &public_key_map, + to_canonical_object(signed).expect("signed was already validated"), + ) { + warn!("invite event third_party_invite signature verification failed: {e}"); + return false; + } + // If there was no error, there was a valid signature, so allow. + true } diff --git a/src/core/matrix/state_res/mod.rs b/src/core/matrix/state_res/mod.rs index 35654b31..7e944e57 100644 --- a/src/core/matrix/state_res/mod.rs +++ b/src/core/matrix/state_res/mod.rs @@ -717,9 +717,6 @@ where // The key for this is (eventType + a state_key of the signed token not sender) // so search for it - let current_third_party = auth_state.iter().find_map(|(_, pdu)| { - (*pdu.event_type() == TimelineEventType::RoomThirdPartyInvite).then_some(pdu) - }); let fetch_state = |ty: &StateEventType, key: &str| { future::ready( @@ -732,7 +729,6 @@ where let auth_result = auth_check( room_version, &event, - current_third_party, fetch_state, &fetch_state(&StateEventType::RoomCreate, "") .await diff --git a/src/service/rooms/event_handler/handle_outlier_pdu.rs b/src/service/rooms/event_handler/handle_outlier_pdu.rs index 5f8bbe76..8d540407 100644 --- a/src/service/rooms/event_handler/handle_outlier_pdu.rs +++ b/src/service/rooms/event_handler/handle_outlier_pdu.rs @@ -184,7 +184,6 @@ where let auth_check = state_res::event_auth::auth_check( &to_room_version(&room_version_id), &pdu_event, - None, // TODO: third party invite state_fetch, create_event.as_pdu(), ) diff --git a/src/service/rooms/event_handler/upgrade_outlier_pdu.rs b/src/service/rooms/event_handler/upgrade_outlier_pdu.rs index a60c2b66..41889d66 100644 --- a/src/service/rooms/event_handler/upgrade_outlier_pdu.rs +++ b/src/service/rooms/event_handler/upgrade_outlier_pdu.rs @@ -100,7 +100,6 @@ where let auth_check = state_res::event_auth::auth_check( &room_version, &incoming_pdu, - None, // TODO: third party invite |ty, sk| state_fetch(ty.clone(), sk.into()), create_event.as_pdu(), ) @@ -140,7 +139,6 @@ where let auth_check = state_res::event_auth::auth_check( &room_version, &incoming_pdu, - None, // third-party invite state_fetch, create_event.as_pdu(), ) diff --git a/src/service/rooms/timeline/create.rs b/src/service/rooms/timeline/create.rs index 40e41b08..593154de 100644 --- a/src/service/rooms/timeline/create.rs +++ b/src/service/rooms/timeline/create.rs @@ -236,15 +236,9 @@ pub async fn create_hash_and_sign_event( | _ => create_pdu.as_ref().unwrap().as_pdu(), }; - let auth_check = state_res::auth_check( - &room_version, - &pdu, - None, // TODO: third_party_invite - auth_fetch, - create_event, - ) - .await - .map_err(|e| err!(Request(Forbidden(warn!("Auth check failed: {e:?}")))))?; + let auth_check = state_res::auth_check(&room_version, &pdu, auth_fetch, create_event) + .await + .map_err(|e| err!(Request(Forbidden(warn!("Auth check failed: {e:?}")))))?; if !auth_check { return Err!(Request(Forbidden("Event is not authorized.")));