Skip to main content

tuwunel_service/membership/
stripped_state.rs

1use ruma::{
2	CanonicalJsonObject, CanonicalJsonValue, OwnedRoomId, RoomId, RoomVersionId,
3	api::federation::membership::RawStrippedState,
4	events::AnyStrippedStateEvent,
5	room_version_rules::RoomIdFormatVersion,
6	serde::{JsonObject, Raw},
7};
8use tuwunel_core::{Event, PduEvent, Result, implement, matrix::event::gen_event_id};
9
10use super::Service;
11
12/// MSC4311 verdict for the create event carried in federated stripped state.
13#[derive(Clone, Copy, Debug, PartialEq, Eq)]
14pub enum StrippedCreateVerdict {
15	/// A full create PDU bound to the room with valid signatures.
16	Valid,
17
18	/// No `m.room.create` event was present.
19	Missing,
20
21	/// A create event was present only in the legacy stripped form.
22	NotPdu,
23
24	/// A create PDU was present but does not bind to the room.
25	WrongRoom,
26
27	/// A create PDU was present but failed signature or hash checks.
28	BadSignature,
29}
30
31/// Whether a non-`Valid` verdict warrants rejecting an invite or dropping the
32/// event from knock state, given the room version and operator policy.
33#[must_use]
34pub fn enforce_stripped_create(
35	verdict: StrippedCreateVerdict,
36	v12_room_ids: bool,
37	enforce: bool,
38) -> bool {
39	use StrippedCreateVerdict::*;
40
41	match verdict {
42		| Valid => false,
43		// A complete create PDU bound to a different room must fail for v12+
44		// rooms even during the migration window (MSC4311 Migration).
45		| WrongRoom => v12_room_ids || enforce,
46		| Missing | NotPdu | BadSignature => enforce,
47	}
48}
49
50/// Whether the room version derives room ids from the create event hash
51/// (MSC4291, room version 12 and above), which changes how a create event
52/// binds to its room.
53#[must_use]
54pub fn v12_room_ids(room_version: &RoomVersionId) -> bool {
55	room_version
56		.rules()
57		.is_some_and(|rules| matches!(rules.room_id_format, RoomIdFormatVersion::V2))
58}
59
60/// Down-convert a federation stripped-state entry to the 4-field client shape,
61/// reducing a full PDU to content, sender, optional state_key, and type.
62#[expect(
63	deprecated,
64	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
65	          compatibility."
66)]
67#[must_use]
68pub fn into_client_stripped(
69	room_id: &RoomId,
70	state: RawStrippedState,
71) -> Option<Raw<AnyStrippedStateEvent>> {
72	match state {
73		| RawStrippedState::Stripped(raw) => Some(raw),
74		| RawStrippedState::Pdu(raw) => {
75			let mut event: JsonObject = serde_json::from_str(raw.get()).ok()?;
76
77			// PduEvent requires event_id and room_id; a v12 create PDU federates
78			// with neither, and to_format() drops both from the stripped shape.
79			event.insert("event_id".into(), "$placeholder".into());
80			event
81				.entry("room_id")
82				.or_insert_with(|| room_id.as_str().into());
83
84			let pdu: PduEvent = serde_json::from_value(event.into()).ok()?;
85
86			Some(pdu.to_format())
87		},
88	}
89}
90
91/// Validate the `m.room.create` event in a federated invite's or knock's
92/// stripped state against the stated room (MSC4311). Decision-free: callers map
93/// the verdict to their own reject-or-warn policy.
94#[implement(Service)]
95#[expect(
96	deprecated,
97	reason = "Matrix 1.16 still permits receiving the legacy stripped variant for backwards \
98	          compatibility."
99)]
100#[tracing::instrument(level = "debug", skip_all, fields(%room_id))]
101pub async fn validate_stripped_create(
102	&self,
103	state: &[RawStrippedState],
104	room_id: &RoomId,
105	room_version_id: &RoomVersionId,
106) -> Result<StrippedCreateVerdict> {
107	let create = state.iter().find_map(|event| match event {
108		| RawStrippedState::Pdu(raw) => serde_json::from_str::<CanonicalJsonObject>(raw.get())
109			.ok()
110			.filter(is_create),
111		| RawStrippedState::Stripped(_) => None,
112	});
113
114	let Some(mut create) = create else {
115		let stripped = state.iter().any(|event| match event {
116			| RawStrippedState::Stripped(raw) =>
117				serde_json::from_str::<CanonicalJsonObject>(raw.json().get())
118					.is_ok_and(|json| is_create(&json)),
119			| RawStrippedState::Pdu(_) => false,
120		});
121
122		return Ok(match stripped {
123			| true => StrippedCreateVerdict::NotPdu,
124			| false => StrippedCreateVerdict::Missing,
125		});
126	};
127
128	create.remove("unsigned");
129
130	// Room-id binding: v12+ rooms hash the create event (MSC4291); earlier
131	// versions compare the create event's room_id field.
132	let bound = if v12_room_ids(room_version_id) {
133		gen_event_id(&create, room_version_id)
134			.ok()
135			.and_then(|event_id| OwnedRoomId::from_parts('!', event_id.localpart(), None).ok())
136			.is_some_and(|expected| expected == room_id)
137	} else {
138		create
139			.get("room_id")
140			.and_then(CanonicalJsonValue::as_str)
141			.is_some_and(|id| id == room_id.as_str())
142	};
143
144	if !bound {
145		return Ok(StrippedCreateVerdict::WrongRoom);
146	}
147
148	if self
149		.services
150		.server_keys
151		.verify_event(&create, Some(room_version_id))
152		.await
153		.is_err()
154	{
155		return Ok(StrippedCreateVerdict::BadSignature);
156	}
157
158	Ok(StrippedCreateVerdict::Valid)
159}
160
161fn is_create(json: &CanonicalJsonObject) -> bool {
162	let field = |key| json.get(key).and_then(CanonicalJsonValue::as_str);
163
164	field("type") == Some("m.room.create") && field("state_key") == Some("")
165}