feat: Add invite membership check
This commit is contained in:
parent
0899985476
commit
bd404e808c
2 changed files with 207 additions and 15 deletions
|
|
@ -70,9 +70,13 @@ where
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rank fetches the creatorship and power level of the target user
|
/// Rank fetches the creatorship and power level of the target user
|
||||||
|
///
|
||||||
|
/// Returns (UserPower, power_level, Option<RoomPowerLevelsEventContent>)
|
||||||
|
/// If UserPower::Creator is returned, the power_level and
|
||||||
|
/// RoomPowerLevelsEventContent will be meaningless and can be ignored.
|
||||||
pub async fn get_rank<FS>(
|
pub async fn get_rank<FS>(
|
||||||
room_version: &RoomVersion,
|
room_version: &RoomVersion,
|
||||||
fetch_state: FS,
|
fetch_state: &FS,
|
||||||
user_id: &UserId,
|
user_id: &UserId,
|
||||||
) -> Result<(UserPower, Int, Option<RoomPowerLevelsEventContent>), Error>
|
) -> Result<(UserPower, Int, Option<RoomPowerLevelsEventContent>), Error>
|
||||||
where
|
where
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,18 @@
|
||||||
//! See: https://spec.matrix.org/v1.16/rooms/v12/#authorization-rules
|
//! See: https://spec.matrix.org/v1.16/rooms/v12/#authorization-rules
|
||||||
|
|
||||||
use ruma::{
|
use ruma::{
|
||||||
EventId, OwnedUserId, RoomId, UserId,
|
EventId, OwnedUserId, UserId,
|
||||||
events::{
|
events::{
|
||||||
StateEventType,
|
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::{
|
use crate::{
|
||||||
Event, EventTypeExt, Pdu, RoomVersion,
|
Event, EventTypeExt, Pdu, RoomVersion,
|
||||||
|
|
@ -17,22 +23,48 @@ use crate::{
|
||||||
Error,
|
Error,
|
||||||
event_auth::context::{UserPower, get_rank},
|
event_auth::context::{UserPower, get_rank},
|
||||||
},
|
},
|
||||||
|
utils::to_canonical_object,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize, Default)]
|
||||||
struct PartialMembershipObject {
|
struct PartialMembershipObject {
|
||||||
membership: Option<String>,
|
membership: Option<String>,
|
||||||
join_authorized_via_users_server: Option<OwnedUserId>,
|
join_authorized_via_users_server: Option<OwnedUserId>,
|
||||||
third_party_invite: Option<serde_json::Value>,
|
third_party_invite: Option<serde_json::Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fetches the membership *content* of the target.
|
||||||
|
/// If there is not one, an empty leave membership is returned.
|
||||||
|
async fn fetch_membership<FS>(
|
||||||
|
fetch_state: &FS,
|
||||||
|
target: &UserId,
|
||||||
|
) -> Result<PartialMembershipObject, Error>
|
||||||
|
where
|
||||||
|
FS: AsyncFn((StateEventType, StateKey)) -> Result<Option<Pdu>, Error>,
|
||||||
|
{
|
||||||
|
fetch_state(StateEventType::RoomMember.with_state_key(target.as_str()))
|
||||||
|
.await
|
||||||
|
.map(|pdu| {
|
||||||
|
if let Some(ev) = pdu {
|
||||||
|
ev.get_content::<PartialMembershipObject>().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<FE, FS>(
|
async fn check_join_event<FE, FS>(
|
||||||
room_version: RoomVersion,
|
room_version: &RoomVersion,
|
||||||
event: &Pdu,
|
event: &Pdu,
|
||||||
membership: &PartialMembershipObject,
|
membership: &PartialMembershipObject,
|
||||||
target: &UserId,
|
target: &UserId,
|
||||||
fetch_event: FE,
|
fetch_event: &FE,
|
||||||
fetch_state: FS,
|
fetch_state: &FS,
|
||||||
) -> Result<(), Error>
|
) -> Result<(), Error>
|
||||||
where
|
where
|
||||||
FE: AsyncFn(&EventId) -> Result<Option<Pdu>, Error>,
|
FE: AsyncFn(&EventId) -> Result<Option<Pdu>, Error>,
|
||||||
|
|
@ -119,7 +151,7 @@ where
|
||||||
}
|
}
|
||||||
let join_authed_by = membership.join_authorized_via_users_server.as_ref();
|
let join_authed_by = membership.join_authorized_via_users_server.as_ref();
|
||||||
if let Some(user_id) = join_authed_by {
|
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 {
|
if rank.0 == UserPower::Standard {
|
||||||
// This user is not a creator, check that they have
|
// This user is not a creator, check that they have
|
||||||
// sufficient power level
|
// sufficient power level
|
||||||
|
|
@ -168,9 +200,161 @@ where
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn check_invite_event<FE, FS>(
|
||||||
|
room_version: &RoomVersion,
|
||||||
|
event: &Pdu,
|
||||||
|
membership: &PartialMembershipObject,
|
||||||
|
target: &UserId,
|
||||||
|
fetch_state: &FS,
|
||||||
|
) -> Result<(), Error>
|
||||||
|
where
|
||||||
|
FE: AsyncFn(&EventId) -> Result<Option<Pdu>, Error>,
|
||||||
|
FS: AsyncFn((StateEventType, StateKey)) -> Result<Option<Pdu>, 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::<RoomThirdPartyInviteEventContent>()
|
||||||
|
.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<FE, FS>(
|
pub async fn check_member_event<FE, FS>(
|
||||||
room_version: RoomVersion,
|
room_version: RoomVersion,
|
||||||
room_id: &RoomId,
|
|
||||||
event: &Pdu,
|
event: &Pdu,
|
||||||
fetch_event: FE,
|
fetch_event: FE,
|
||||||
fetch_state: FS,
|
fetch_state: FS,
|
||||||
|
|
@ -199,7 +383,6 @@ where
|
||||||
"m.room.member event missing membership in content".to_owned(),
|
"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
|
// 2: If content has a join_authorised_via_users_server key
|
||||||
//
|
//
|
||||||
|
|
@ -210,10 +393,15 @@ where
|
||||||
todo!("Implement join_authorised_via_users_server check");
|
todo!("Implement join_authorised_via_users_server check");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3: If membership is join:
|
match content.membership.as_deref().unwrap() {
|
||||||
if membership == "join" {
|
| "join" =>
|
||||||
check_join_event(room_version, event, &content, &target, fetch_event, fetch_state)
|
check_join_event(&room_version, event, &content, &target, &fetch_event, &fetch_state)
|
||||||
.await?;
|
.await?,
|
||||||
}
|
| "invite" =>
|
||||||
|
check_invite_event(&room_version, event, &content, &target, &fetch_state).await?,
|
||||||
|
| _ => {
|
||||||
|
todo!()
|
||||||
|
},
|
||||||
|
};
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue