Skip to main content

tuwunel_service/rooms/event_handler/
policy_server.rs

1use std::{
2	collections::BTreeMap,
3	time::{Duration, SystemTime, UNIX_EPOCH},
4};
5
6use http::StatusCode;
7use ruma::{
8	CanonicalJsonObject, CanonicalJsonValue, EventId, OwnedServerName, RoomId, RoomVersionId,
9	ServerName, SigningKeyAlgorithm,
10	api::{
11		error::{ErrorKind, RetryAfter},
12		federation::policy::sign_event::v1 as sign_event,
13	},
14	events::{
15		StateEventType,
16		room::policy::{POLICY_SERVER_ED25519_SIGNING_KEY_ID, RoomPolicyEventContent},
17	},
18	serde::Base64,
19	signatures::{to_canonical_json_string_for_signing, verify_canonical_json_bytes},
20};
21use serde::{Deserialize, Serialize};
22use serde_json::value::to_raw_value;
23use tuwunel_core::{
24	Err, Result, at, debug, implement,
25	matrix::{Event, pdu::into_outgoing_federation, room_version::rules as room_version_rules},
26	trace,
27	utils::time::now_secs,
28	warn,
29};
30use tuwunel_database::{Cbor, Deserialized};
31
32#[cfg(test)]
33mod tests;
34
35/// MSC4284 unstable state event type. The merged spec stabilised this to
36/// `m.room.policy`, but the reference policy server (and Element's default
37/// deployments as of 2026-05) still write the unstable type with the singular
38/// `public_key` field; reading both keeps the gate live for those rooms.
39const UNSTABLE_POLICY_TYPE: &str = "org.matrix.msc4284.policy";
40const POLICY_REFUSAL_TTL: Duration = Duration::from_hours(24);
41
42/// Outcome of an inbound policy-server signature check.
43#[derive(Clone, Copy, Debug)]
44pub enum PolicyCheck {
45	/// No policy server is configured for this room (or feature is off, or
46	/// the event is the policy state event itself). The caller should not
47	/// modify its soft-fail decision based on policy considerations.
48	NotApplicable,
49
50	/// Policy server signature is present and verifies cleanly.
51	Pass,
52
53	/// Policy server signature is absent. Per MSC4284, the homeserver SHOULD
54	/// either fetch one from the policy server or soft-fail.
55	Missing,
56
57	/// Policy server signature is present but failed cryptographic
58	/// verification. Soft-fail.
59	Invalid,
60}
61
62/// Outcome of a `/sign` round-trip to the policy server.
63#[derive(Debug)]
64enum FetchOutcome {
65	/// Policy server returned a valid signature.
66	Signed(String),
67
68	/// Network error or timeout; the caller should fail open.
69	FailOpen,
70
71	/// Policy server explicitly refused the event.
72	///
73	/// A 400 `M_FORBIDDEN` is ambiguous because policyserv also uses it for
74	/// unknown rooms and invalid origin signatures. A 200 without a signature
75	/// for `via` is the unstable refusal form.
76	Refused {
77		status: StatusCode,
78		errcode: Option<ErrorKind>,
79	},
80
81	/// Policy server returned `M_LIMIT_EXCEEDED`. The caller should record
82	/// the unix-secs deadline so subsequent attempts before then are
83	/// short-circuited.
84	RateLimited {
85		until_secs: u64,
86	},
87}
88
89/// Persisted per-event policy-server outcome in `eventid_policysigstate`.
90/// Absence of a row means "no prior decision recorded; proceed with `/sign`".
91#[derive(Debug, Serialize, Deserialize)]
92enum PolicySigState {
93	/// Policy server refused this event.
94	///
95	/// Requests wait until this unix-secs deadline before retrying.
96	Refused {
97		until_secs: u64,
98	},
99
100	/// Policy server is rate-limiting; do not retry before this unix-secs
101	/// deadline.
102	BackoffUntil {
103		until_secs: u64,
104	},
105}
106
107/// Lenient deserialiser that accepts either the stable
108/// `public_keys: { ed25519: ... }` shape or the MSC4284 unstable singular
109/// `public_key: <ed25519>` shape, and folds the latter into the former.
110#[derive(Deserialize)]
111struct UnstablePolicyContent {
112	via: OwnedServerName,
113
114	#[serde(default)]
115	public_keys: BTreeMap<SigningKeyAlgorithm, Base64>,
116
117	#[serde(default)]
118	public_key: Option<Base64>,
119}
120
121#[implement(UnstablePolicyContent)]
122fn into_stable(
123	Self { via, mut public_keys, public_key }: Self,
124) -> Option<RoomPolicyEventContent> {
125	if let Some(key) = public_key {
126		public_keys
127			.entry(SigningKeyAlgorithm::Ed25519)
128			.or_insert(key);
129	}
130
131	let ed25519 = public_keys.remove(&SigningKeyAlgorithm::Ed25519)?;
132
133	Some(RoomPolicyEventContent::new(via, ed25519))
134}
135
136/// Clears the cached policy server decision for an event.
137///
138/// A later validation can contact the policy server again.
139#[implement(super::Service)]
140pub fn clear_policy_signature_state(&self, event_id: &EventId) {
141	self.db.eventid_policysigstate.remove(event_id);
142}
143
144#[implement(super::Service)]
145fn cache_policy_refused(&self, event_id: &EventId) {
146	let until_secs = now_secs().saturating_add(POLICY_REFUSAL_TTL.as_secs());
147
148	self.db
149		.eventid_policysigstate
150		.raw_put(event_id.as_str(), Cbor(&PolicySigState::Refused { until_secs }));
151}
152
153#[implement(super::Service)]
154fn cache_policy_backoff(&self, event_id: &EventId, until_secs: u64) {
155	self.db
156		.eventid_policysigstate
157		.raw_put(event_id.as_str(), Cbor(&PolicySigState::BackoffUntil { until_secs }));
158}
159
160#[implement(super::Service)]
161async fn cached_policy_state(&self, event_id: &EventId) -> Option<PolicySigState> {
162	let state = self
163		.db
164		.eventid_policysigstate
165		.get(event_id.as_str())
166		.await
167		.deserialized::<Cbor<_>>();
168
169	current_policy_state(state, now_secs())
170}
171
172fn current_policy_state(
173	state: Result<Cbor<PolicySigState>>,
174	current_secs: u64,
175) -> Option<PolicySigState> {
176	state
177		.map(at!(0))
178		.ok()
179		.filter(|state| match state {
180			| PolicySigState::Refused { until_secs }
181			| PolicySigState::BackoffUntil { until_secs } => *until_secs > current_secs,
182		})
183}
184
185/// Returns the room's policy event content when a policy server is in effect:
186/// state event present (stable `m.room.policy`, falling back to MSC4284's
187/// unstable `org.matrix.msc4284.policy`), parses cleanly under either the
188/// stable `public_keys` map or the unstable singular `public_key` field, and
189/// the `via` server has a joined user in the room. Any failure returns `None`,
190/// signalling "no policy server configured" so the caller skips the gate
191/// entirely.
192#[implement(super::Service)]
193pub async fn lookup_policy_server(&self, room_id: &RoomId) -> Option<RoomPolicyEventContent> {
194	let read = async |event_type: &StateEventType| {
195		self.services
196			.state_accessor
197			.room_state_get_content::<UnstablePolicyContent>(room_id, event_type, "")
198			.await
199			.ok()
200			.and_then(UnstablePolicyContent::into_stable)
201	};
202
203	let content = match read(&StateEventType::RoomPolicy).await {
204		| Some(content) => content,
205		| None => read(&StateEventType::from(UNSTABLE_POLICY_TYPE.to_owned())).await?,
206	};
207
208	self.services
209		.state_cache
210		.server_in_room(&content.via, room_id)
211		.await
212		.then_some(content)
213}
214
215/// MSC4284: ask the room's policy server to sign an outgoing event. The
216/// signature is folded into `pdu_json["signatures"]` so it persists with the
217/// event and federates transitively to other servers in the room. Returns
218/// `Forbidden` when the policy server explicitly refuses; network errors and
219/// timeouts fail open with a warn log.
220#[implement(super::Service)]
221#[tracing::instrument(name = "policy_sign", level = "debug", skip_all)]
222pub async fn sign_outgoing_pdu<E>(&self, pdu_json: &mut CanonicalJsonObject, pdu: &E) -> Result
223where
224	E: Event,
225{
226	if !self.services.server.config.enable_policy_servers {
227		return Ok(());
228	}
229
230	if is_policy_state_event(pdu) {
231		return Ok(());
232	}
233
234	let Ok(room_version) = self
235		.services
236		.state
237		.get_room_version(pdu.room_id())
238		.await
239	else {
240		return Ok(());
241	};
242
243	let Some(policy) = self.lookup_policy_server(pdu.room_id()).await else {
244		trace!(room_id = %pdu.room_id(), "no policy server configured");
245		return Ok(());
246	};
247
248	let event_id = pdu.event_id();
249	match self.cached_policy_state(event_id).await {
250		| Some(PolicySigState::Refused { .. }) =>
251			return Err!(Request(Forbidden("Event was rejected by the room's policy server."))),
252
253		| Some(PolicySigState::BackoffUntil { until_secs }) if until_secs > now_secs() => {
254			debug!(via = %policy.via, until_secs, "skipping outbound /sign during policy backoff");
255			return Ok(());
256		},
257		| _ => {},
258	}
259
260	match self
261		.fetch_policy_signature(&policy, pdu_json, &room_version)
262		.await
263	{
264		| FetchOutcome::Signed(signature) => {
265			insert_policy_signature(pdu_json, &policy.via, &signature);
266			debug!(via = %policy.via, event_id = %event_id, "folded policy server signature");
267		},
268		| FetchOutcome::Refused { status, errcode } => {
269			warn!(
270				via = %policy.via,
271				event_id = %event_id,
272				room_id = %pdu.room_id(),
273				status = status.as_u16(),
274				?errcode,
275				"policy server refused to sign outbound PDU"
276			);
277
278			self.cache_policy_refused(event_id);
279			return Err!(Request(Forbidden("Event was rejected by the room's policy server.")));
280		},
281		| FetchOutcome::RateLimited { until_secs } => {
282			self.cache_policy_backoff(event_id, until_secs);
283		},
284		| FetchOutcome::FailOpen => {},
285	}
286
287	Ok(())
288}
289
290/// Calls the policy server's `/sign` endpoint. The classification of the
291/// response (`Signed` / `Refused` / `RateLimited` / `FailOpen`) lets each
292/// caller choose its own reaction.
293#[implement(super::Service)]
294#[tracing::instrument(
295	name = "policy_fetch",
296	level = "debug",
297	skip_all,
298	fields(via = %policy.via)
299)]
300async fn fetch_policy_signature(
301	&self,
302	policy: &RoomPolicyEventContent,
303	pdu_json: &CanonicalJsonObject,
304	room_version: &RoomVersionId,
305) -> FetchOutcome {
306	let outgoing = into_outgoing_federation(pdu_json.clone(), room_version);
307	let Ok(raw) = to_raw_value(&outgoing) else {
308		warn!(via = %policy.via, "failed to serialize PDU for policy /sign; failing open");
309		return FetchOutcome::FailOpen;
310	};
311
312	let timeout = Duration::from_secs(
313		self.services
314			.server
315			.config
316			.policy_server_request_timeout,
317	);
318
319	let response = match tokio::time::timeout(
320		timeout,
321		self.services
322			.federation
323			.execute(&policy.via, sign_event::Request::new(raw)),
324	)
325	.await
326	{
327		| Ok(Ok(response)) => response,
328		| Ok(Err(error)) => {
329			let status = error.status_code();
330			let errcode = error.kind();
331			let outcome = classify_fetch_error(status, &errcode, parse_rate_limit(&error));
332
333			if let FetchOutcome::RateLimited { until_secs } = &outcome {
334				warn!(
335					via = %policy.via,
336					status = status.as_u16(),
337					?errcode,
338					until_secs,
339					"policy server /sign rate-limited"
340				);
341			}
342
343			if matches!(&outcome, FetchOutcome::FailOpen) {
344				let expected_not_found = status == StatusCode::NOT_FOUND
345					&& (errcode == ErrorKind::NotFound || errcode == ErrorKind::Unrecognized);
346
347				if expected_not_found {
348					debug!(
349						via = %policy.via,
350						status = status.as_u16(),
351						?errcode,
352						%error,
353						"policy server does not support /sign; failing open"
354					);
355				} else {
356					warn!(
357						via = %policy.via,
358						status = status.as_u16(),
359						?errcode,
360						%error,
361						"policy server /sign failed; failing open"
362					);
363				}
364			}
365
366			return outcome;
367		},
368		| Err(elapsed) => {
369			warn!(via = %policy.via, %elapsed, "policy server /sign timed out; failing open");
370			return FetchOutcome::FailOpen;
371		},
372	};
373
374	// MSC4284 unstable: a 200 OK with no signature for `via` is also refusal.
375	response
376		.ed25519_signature(&policy.via)
377		.map(ToOwned::to_owned)
378		.map_or(
379			FetchOutcome::Refused { status: StatusCode::OK, errcode: None },
380			FetchOutcome::Signed,
381		)
382}
383
384fn classify_fetch_error(
385	status: StatusCode,
386	errcode: &ErrorKind,
387	rate_limit_until: Option<u64>,
388) -> FetchOutcome {
389	match rate_limit_until {
390		| Some(until_secs) => FetchOutcome::RateLimited { until_secs },
391		| None if status == StatusCode::BAD_REQUEST && errcode == &ErrorKind::Forbidden =>
392			FetchOutcome::Refused { status, errcode: Some(errcode.clone()) },
393		| None => FetchOutcome::FailOpen,
394	}
395}
396
397fn parse_rate_limit(error: &tuwunel_core::Error) -> Option<u64> {
398	let ErrorKind::LimitExceeded(data) = error.kind() else {
399		return None;
400	};
401
402	let until = match data.retry_after.as_ref()? {
403		| RetryAfter::Delay(d) => SystemTime::now().checked_add(*d)?,
404		| RetryAfter::DateTime(t) => *t,
405	};
406
407	until
408		.duration_since(UNIX_EPOCH)
409		.ok()
410		.map(|d| d.as_secs())
411}
412
413/// MSC4284: verify the inbound PDU's policy server signature.
414///
415/// Missing or invalid signatures are fetched again because the policy server's
416/// key may have rotated. A fetched signature is folded into the event.
417#[implement(super::Service)]
418#[tracing::instrument(name = "policy_verify_or_fetch", level = "debug", skip_all)]
419pub async fn verify_or_fetch_inbound_policy_signature<E>(
420	&self,
421	pdu_json: &mut CanonicalJsonObject,
422	pdu: &E,
423) -> PolicyCheck
424where
425	E: Event,
426{
427	match self
428		.check_inbound_policy_signature(pdu_json, pdu)
429		.await
430	{
431		| PolicyCheck::Missing | PolicyCheck::Invalid =>
432			self.fetch_inbound_policy_signature(pdu_json, pdu)
433				.await,
434		| other => other,
435	}
436}
437
438/// MSC4284: fetch a missing or invalid inbound policy server signature.
439///
440/// The signature is folded into `pdu_json` so it persists and federates
441/// onward. Refusals map to `Invalid`; transient failures map to `Pass`.
442#[implement(super::Service)]
443#[tracing::instrument(name = "policy_fetch_inbound", level = "debug", skip_all)]
444async fn fetch_inbound_policy_signature<E>(
445	&self,
446	pdu_json: &mut CanonicalJsonObject,
447	pdu: &E,
448) -> PolicyCheck
449where
450	E: Event,
451{
452	let Some(policy) = self.lookup_policy_server(pdu.room_id()).await else {
453		return PolicyCheck::NotApplicable;
454	};
455
456	let Ok(room_version) = self
457		.services
458		.state
459		.get_room_version(pdu.room_id())
460		.await
461	else {
462		return PolicyCheck::NotApplicable;
463	};
464
465	let event_id = pdu.event_id();
466	match self.cached_policy_state(event_id).await {
467		| Some(PolicySigState::Refused { .. }) => return PolicyCheck::Invalid,
468		| Some(PolicySigState::BackoffUntil { until_secs }) if until_secs > now_secs() => {
469			debug!(
470				until_secs,
471				via = %policy.via,
472				"policy server in backoff; failing open"
473			);
474
475			return PolicyCheck::Pass;
476		},
477		| _ => {},
478	}
479
480	match self
481		.fetch_policy_signature(&policy, pdu_json, &room_version)
482		.await
483	{
484		| FetchOutcome::Signed(signature) => {
485			debug!(
486				via = %policy.via,
487				event_id = %event_id,
488				"folded inbound policy server signature"
489			);
490
491			insert_policy_signature(pdu_json, &policy.via, &signature);
492			PolicyCheck::Pass
493		},
494		| FetchOutcome::Refused { status, errcode } => {
495			warn!(
496				via = %policy.via,
497				event_id = %event_id,
498				room_id = %pdu.room_id(),
499				status = status.as_u16(),
500				?errcode,
501				"policy server refused to sign inbound PDU; soft-failing"
502			);
503
504			self.cache_policy_refused(event_id);
505			PolicyCheck::Invalid
506		},
507		| FetchOutcome::RateLimited { until_secs } => {
508			self.cache_policy_backoff(event_id, until_secs);
509			PolicyCheck::Pass
510		},
511		| FetchOutcome::FailOpen => PolicyCheck::Pass,
512	}
513}
514
515/// MSC4284: verify the policy server signature on an inbound PDU. Returns
516/// `NotApplicable` for rooms without a configured policy server (the gate is
517/// skipped); `Pass` when the signature verifies; `Missing` when no signature
518/// is present for the configured server; `Invalid` when the signature is
519/// present but cryptographic verification fails.
520#[implement(super::Service)]
521#[tracing::instrument(name = "policy_verify", level = "debug", skip_all)]
522pub async fn check_inbound_policy_signature<E>(
523	&self,
524	pdu_json: &CanonicalJsonObject,
525	pdu: &E,
526) -> PolicyCheck
527where
528	E: Event,
529{
530	if !self.services.server.config.enable_policy_servers {
531		return PolicyCheck::NotApplicable;
532	}
533
534	if is_policy_state_event(pdu) {
535		return PolicyCheck::NotApplicable;
536	}
537
538	let Some(policy) = self.lookup_policy_server(pdu.room_id()).await else {
539		return PolicyCheck::NotApplicable;
540	};
541
542	let Ok(room_version) = self
543		.services
544		.state
545		.get_room_version(pdu.room_id())
546		.await
547	else {
548		return PolicyCheck::NotApplicable;
549	};
550
551	// `lookup_policy_server` already verified the ed25519 entry is present.
552	let Some(public_key) = policy
553		.public_keys
554		.get(&SigningKeyAlgorithm::Ed25519)
555	else {
556		return PolicyCheck::NotApplicable;
557	};
558
559	check_policy_signature(pdu_json, &room_version, &policy.via, public_key)
560}
561
562fn check_policy_signature(
563	pdu_json: &CanonicalJsonObject,
564	room_version: &RoomVersionId,
565	via: &ServerName,
566	public_key: &Base64,
567) -> PolicyCheck {
568	let Ok(rules) = room_version_rules(room_version) else {
569		return PolicyCheck::NotApplicable;
570	};
571
572	let Some(signature_b64) = extract_policy_signature(pdu_json, via) else {
573		return PolicyCheck::Missing;
574	};
575
576	let Ok(signature) = Base64::<ruma::serde::base64::Standard>::parse(signature_b64) else {
577		return PolicyCheck::Invalid;
578	};
579
580	let Ok(redacted) = ruma::canonical_json::redact(pdu_json.clone(), &rules.redaction, None)
581	else {
582		return PolicyCheck::Invalid;
583	};
584
585	let Ok(canonical) = to_canonical_json_string_for_signing(&redacted) else {
586		return PolicyCheck::Invalid;
587	};
588
589	verify_canonical_json_bytes(
590		&SigningKeyAlgorithm::Ed25519,
591		public_key.as_bytes(),
592		signature.as_bytes(),
593		canonical.as_bytes(),
594	)
595	.map(|()| PolicyCheck::Pass)
596	.unwrap_or_else(|error| {
597		debug!(%via, %error, "policy server signature failed verification");
598		PolicyCheck::Invalid
599	})
600}
601
602fn is_policy_state_event<E: Event>(pdu: &E) -> bool {
603	if pdu.state_key() != Some("") {
604		return false;
605	}
606
607	let kind = pdu.kind().to_cow_str();
608
609	kind == "m.room.policy" || kind == UNSTABLE_POLICY_TYPE
610}
611
612fn extract_policy_signature<'a>(
613	pdu_json: &'a CanonicalJsonObject,
614	via: &ServerName,
615) -> Option<&'a str> {
616	let CanonicalJsonValue::Object(server_map) = pdu_json.get("signatures")? else {
617		return None;
618	};
619
620	let CanonicalJsonValue::Object(key_map) = server_map.get(via.as_str())? else {
621		return None;
622	};
623
624	let CanonicalJsonValue::String(signature) =
625		key_map.get(POLICY_SERVER_ED25519_SIGNING_KEY_ID)?
626	else {
627		return None;
628	};
629
630	Some(signature.as_str())
631}
632
633fn insert_policy_signature(
634	pdu_json: &mut CanonicalJsonObject,
635	via: &ServerName,
636	signature: &str,
637) {
638	let signatures = pdu_json
639		.entry("signatures".into())
640		.or_insert_with(|| CanonicalJsonValue::Object(BTreeMap::new()));
641
642	let CanonicalJsonValue::Object(server_map) = signatures else {
643		return;
644	};
645
646	let entry = server_map
647		.entry(via.as_str().into())
648		.or_insert_with(|| CanonicalJsonValue::Object(BTreeMap::new()));
649
650	if let CanonicalJsonValue::Object(key_map) = entry {
651		key_map.insert(
652			POLICY_SERVER_ED25519_SIGNING_KEY_ID.into(),
653			CanonicalJsonValue::String(signature.to_owned()),
654		);
655	}
656}