diff --git a/changelog.d/19127.feature b/changelog.d/19127.feature new file mode 100644 index 00000000000..7dc3a49f368 --- /dev/null +++ b/changelog.d/19127.feature @@ -0,0 +1 @@ +Add experimental support for [MSC4388: Secure out-of-band channel for sign in with QR](https://github.com/matrix-org/matrix-spec-proposals/pull/4388). diff --git a/rust/src/lib.rs b/rust/src/lib.rs index fe880af2eae..83b8de7b64a 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -12,6 +12,7 @@ pub mod http; pub mod http_client; pub mod identifier; pub mod matrix_const; +pub mod msc4388_rendezvous; pub mod push; pub mod rendezvous; pub mod segmenter; @@ -55,6 +56,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { events::register_module(py, m)?; http_client::register_module(py, m)?; rendezvous::register_module(py, m)?; + msc4388_rendezvous::register_module(py, m)?; segmenter::register_module(py, m)?; Ok(()) diff --git a/rust/src/msc4388_rendezvous/mod.rs b/rust/src/msc4388_rendezvous/mod.rs new file mode 100644 index 00000000000..bc9463639fd --- /dev/null +++ b/rust/src/msc4388_rendezvous/mod.rs @@ -0,0 +1,370 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2026 Element Creations Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * . + */ + +use std::{ + collections::BTreeMap, + time::{Duration, SystemTime}, +}; + +use http::StatusCode; +use pyo3::{ + pyclass, pymethods, + types::{PyAnyMethods, PyModule, PyModuleMethods}, + Bound, IntoPyObject, Py, PyAny, PyResult, Python, +}; +use serde::Deserialize; +use ulid::Ulid; + +use self::session::Session; +use crate::{ + duration::SynapseDuration, + errors::{NotFoundError, SynapseError}, + http::http_request_from_twisted, + msc4388_rendezvous::session::{GetResponse, PostResponse, PutResponse}, + UnwrapInfallible, +}; + +mod session; + +#[pyclass] +struct MSC4388RendezvousHandler { + clock: Py, + sessions: BTreeMap, + soft_limit: usize, + hard_limit: usize, + max_content_length: u64, + ttl: Duration, +} + +impl MSC4388RendezvousHandler { + /// Check the length of the data parameter and throw error if invalid. + fn check_data_length(&self, data: &str) -> PyResult<()> { + let data_length = data.len() as u64; + if data_length > self.max_content_length { + return Err(SynapseError::new( + StatusCode::PAYLOAD_TOO_LARGE, + "Payload too large".to_owned(), + "M_TOO_LARGE", + None, + None, + )); + } + Ok(()) + } + + /// Evict expired sessions and remove the oldest sessions until we're under the capacity. + fn evict(&mut self, now: SystemTime) { + // First remove all the entries which expired + self.sessions.retain(|_, session| !session.expired(now)); + + // Then we remove the oldest entries until we're under the soft limit + while self.sessions.len() > self.soft_limit { + self.sessions.pop_first(); + } + } +} + +#[derive(Deserialize)] +pub struct PostRequest { + data: String, +} + +#[derive(Deserialize)] +pub struct PutRequest { + sequence_token: String, + data: String, +} + +#[pymethods] +impl MSC4388RendezvousHandler { + #[new] + #[pyo3(signature = (homeserver, /, soft_limit=100, hard_limit=200,max_content_length=4*1024, eviction_interval=60*1000, ttl=2*60*1000))] + fn new( + py: Python<'_>, + homeserver: &Bound<'_, PyAny>, + soft_limit: usize, + hard_limit: usize, + max_content_length: u64, + eviction_interval: u64, + ttl: u64, + ) -> PyResult> { + let clock = homeserver + .call_method0("get_clock")? + .into_pyobject(py) + .unwrap_infallible() + .unbind(); + + // Construct a Python object so that we can get a reference to the + // evict method and schedule it to run. + let self_ = Py::new( + py, + Self { + clock, + sessions: BTreeMap::new(), + soft_limit, + hard_limit, + max_content_length, + ttl: Duration::from_millis(ttl), + }, + )?; + + let eviction_duration = SynapseDuration::from_milliseconds(eviction_interval); + + let evict = self_.getattr(py, "_evict")?; + homeserver.call_method0("get_clock")?.call_method( + "looping_call", + (evict, &eviction_duration), + None, + )?; + + Ok(self_) + } + + fn _evict(&mut self, py: Python<'_>) -> PyResult<()> { + let clock = self.clock.bind(py); + let now: u64 = clock.call_method0("time_msec")?.extract()?; + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now); + self.evict(now); + + Ok(()) + } + + fn handle_post( + &mut self, + py: Python<'_>, + twisted_request: &Bound<'_, PyAny>, + ) -> PyResult<(u8, PostResponse)> { + let clock = self.clock.bind(py); + let now: u64 = clock.call_method0("time_msec")?.extract()?; + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now); + + // We trigger an immediate eviction if we're at the hard limit + if self.sessions.len() >= self.hard_limit { + self.evict(now); + } + + // Generate a new ULID for the session from the current time. + let id = Ulid::from_datetime(now); + + let request = http_request_from_twisted(twisted_request)?; + // parse JSON body + let post_request: PostRequest = + serde_json::from_slice(&request.into_body()).map_err(|_| { + SynapseError::new( + StatusCode::BAD_REQUEST, + "Invalid JSON in request body".to_owned(), + "M_INVALID_PARAM", + None, + None, + ) + })?; + + let data: String = post_request.data; + self.check_data_length(&data)?; + + let session = Session::new(id, data, now, self.ttl); + let response = session.post_response(now); + self.sessions.insert(id, session); + + Ok((200, response)) + } + + fn handle_get( + &mut self, + py: Python<'_>, + id: &str, + twisted_request: &Bound<'_, PyAny>, + ) -> PyResult<(u8, GetResponse)> { + let request = http_request_from_twisted(twisted_request)?; + + // As per the MSC, we check the Sec-Fetch-* headers to ensure this request did not come from somewhere that will + // be rendered directly to the user, as the response may contain sensitive data. These headers are added by + // well behaved browsers so are helpful for protecting regular users. + + // Sec-Fetch-Dest: https://www.w3.org/TR/fetch-metadata/#sec-fetch-dest-header + // + // If the header is present then this must be "empty". All other values such as document, image etc. + // are considered potentially dangerous as they might be rendered to the user. + // + // Note that because we only ever return JSON, so it is unlikely that it could somehow be rendered as an image, + // video or other media. + let sec_fetch_dest: Option = request + .headers() + .get("sec-fetch-dest") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + if sec_fetch_dest.is_some() && sec_fetch_dest.as_deref() != Some("empty") { + return Err(SynapseError::new( + StatusCode::FORBIDDEN, + "Rendezvous content is not accessible from the request destination".to_owned(), + "M_FORBIDDEN", + None, + None, + )); + } + + // Sec-Fetch-Mode: https://www.w3.org/TR/fetch-metadata/#sec-fetch-mode-header + // + // A request mode of "navigate" is not allowed as this indicates the request is being made by the + // browser to navigate to a URL, which could lead to the response being rendered directly to the user. + // + // Note that usually Sec-Fetch-Dest would be "document" in this case and so the request would be rejected earlier, + // but we check the mode just in case the destination is not set correctly. + let sec_fetch_mode: Option = request + .headers() + .get("sec-fetch-mode") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + if sec_fetch_mode.as_deref() == Some("navigate") { + return Err(SynapseError::new( + StatusCode::FORBIDDEN, + "Rendezvous content is not accessible via top-level navigation".to_owned(), + "M_FORBIDDEN", + None, + None, + )); + } + + // Sec-Fetch-User: https://www.w3.org/TR/fetch-metadata/#sec-fetch-user-header + // + // If the request has a Sec-Fetch-User header with a value of "?1", this indicates that the + // request was triggered by user activation, such as a click. + // + // Note that usually Sec-Fetch-Mode would be "navigate" or the Sec-Fetch-Dest would be "document" in this case + // and so the request would be rejected earlier, but we check the user activation just in case those headers are + // not set correctly. + let sec_fetch_user: Option = request + .headers() + .get("sec-fetch-user") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + if sec_fetch_user.as_deref() == Some("?1") { + return Err(SynapseError::new( + StatusCode::FORBIDDEN, + "Rendezvous content is not accessible from requests with user activation" + .to_owned(), + "M_FORBIDDEN", + None, + None, + )); + } + + // Sec-Fetch-Site: https://www.w3.org/TR/fetch-metadata/#sec-fetch-site-header + // + // "none" indicates the request did not originate from a web page + // (e.g. typed URL, bookmark, or browser extension), so we disallow it. + let sec_fetch_site: Option = request + .headers() + .get("sec-fetch-site") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_owned()); + if sec_fetch_site.as_deref() == Some("none") { + return Err(SynapseError::new( + StatusCode::FORBIDDEN, + "Rendezvous content is not accessible from requests from user interaction" + .to_owned(), + "M_FORBIDDEN", + None, + None, + )); + } + + let clock = self.clock.bind(py); + let now: u64 = clock.call_method0("time_msec")?.extract()?; + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now); + + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let session = self + .sessions + .get(&id) + .filter(|s| !s.expired(now)) + .ok_or_else(NotFoundError::new)?; + + Ok((200, session.get_response(now))) + } + + fn handle_put( + &mut self, + py: Python<'_>, + id: &str, + twisted_request: &Bound<'_, PyAny>, + ) -> PyResult<(u8, PutResponse)> { + let request = http_request_from_twisted(twisted_request)?; + // parse JSON body + let put_request: PutRequest = + serde_json::from_slice(&request.into_body()).map_err(|_| { + SynapseError::new( + StatusCode::BAD_REQUEST, + "Invalid JSON in request body".to_owned(), + "M_INVALID_PARAM", + None, + None, + ) + })?; + + let sequence_token: String = put_request.sequence_token; + + let data: String = put_request.data; + + self.check_data_length(&data)?; + + let clock = self.clock.bind(py); + let now: u64 = clock.call_method0("time_msec")?.extract()?; + let now = SystemTime::UNIX_EPOCH + Duration::from_millis(now); + + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let session = self + .sessions + .get_mut(&id) + .filter(|s| !s.expired(now)) + .ok_or_else(NotFoundError::new)?; + + if !session.sequence_token().eq(&sequence_token) { + return Err(SynapseError::new( + StatusCode::CONFLICT, + "sequence_token does not match".to_owned(), + "IO_ELEMENT_MSC4388_CONCURRENT_WRITE", + None, + None, + )); + } + + session.update(data, now); + + Ok((200, session.put_response())) + } + + fn handle_delete(&mut self, id: &str) -> PyResult<(u8, ())> { + let id: Ulid = id.parse().map_err(|_| NotFoundError::new())?; + let _session = self.sessions.remove(&id).ok_or_else(NotFoundError::new)?; + + Ok((200, ())) + } +} + +pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + let child_module = PyModule::new(py, "msc4388_rendezvous")?; + + child_module.add_class::()?; + + m.add_submodule(&child_module)?; + + // We need to manually add the module to sys.modules to make `from + // synapse.synapse_rust import rendezvous` work. + py.import("sys")? + .getattr("modules")? + .set_item("synapse.synapse_rust.msc4388_rendezvous", child_module)?; + + Ok(()) +} diff --git a/rust/src/msc4388_rendezvous/session.rs b/rust/src/msc4388_rendezvous/session.rs new file mode 100644 index 00000000000..467d1b5baf5 --- /dev/null +++ b/rust/src/msc4388_rendezvous/session.rs @@ -0,0 +1,153 @@ +/* + * This file is licensed under the Affero General Public License (AGPL) version 3. + * + * Copyright (C) 2026 Element Creations Ltd + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * See the GNU Affero General Public License for more details: + * . + */ + +use std::time::{Duration, SystemTime}; + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use pyo3::{Bound, IntoPyObject, PyAny, Python}; +use pythonize::{pythonize, PythonizeError}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use ulid::Ulid; + +/// A single session, containing data, metadata, and expiry information. +pub struct Session { + id: Ulid, + hash: [u8; 32], + data: String, + last_modified: SystemTime, + expires: SystemTime, +} + +#[derive(Serialize)] +pub struct PostResponse { + id: String, + sequence_token: String, + expires_in_ms: u64, +} + +impl<'source> IntoPyObject<'source> for PostResponse { + type Target = PyAny; + type Output = Bound<'source, Self::Target>; + type Error = PythonizeError; + + fn into_pyobject(self, py: Python<'source>) -> Result { + pythonize(py, &self) + } +} + +#[derive(Serialize)] +pub struct GetResponse { + data: String, + sequence_token: String, + expires_in_ms: u64, +} + +impl<'source> IntoPyObject<'source> for GetResponse { + type Target = PyAny; + type Output = Bound<'source, Self::Target>; + type Error = PythonizeError; + + fn into_pyobject(self, py: Python<'source>) -> Result { + pythonize(py, &self) + } +} + +#[derive(Serialize)] +pub struct PutResponse { + sequence_token: String, +} + +impl<'source> IntoPyObject<'source> for PutResponse { + type Target = PyAny; + type Output = Bound<'source, Self::Target>; + type Error = PythonizeError; + + fn into_pyobject(self, py: Python<'source>) -> Result { + pythonize(py, &self) + } +} + +impl Session { + /// Create a new session with the given data and time-to-live. + pub fn new(id: Ulid, data: String, now: SystemTime, ttl: Duration) -> Self { + let hash = Self::compute_hash(&data, now); + Self { + id, + hash, + data, + expires: now + ttl, + last_modified: now, + } + } + + /// Returns true if the session has expired at the given time. + pub fn expired(&self, now: SystemTime) -> bool { + self.expires <= now + } + + /// Update the session with new data and last modified time. + pub fn update(&mut self, data: String, now: SystemTime) { + self.hash = Self::compute_hash(&data, now); + self.data = data; + self.last_modified = now; + } + + /// Compute the hash of the data and timestamp. + fn compute_hash(data: &str, now: SystemTime) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(data); + let now_millis = now + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(); + hasher.update(now_millis.to_be_bytes()); + hasher.finalize().into() + } + + /// The sequence token for the session. + pub fn sequence_token(&self) -> String { + URL_SAFE_NO_PAD.encode(self.hash) + } + + pub fn get_response(&self, now: SystemTime) -> GetResponse { + GetResponse { + data: self.data.clone(), + sequence_token: self.sequence_token(), + expires_in_ms: self + .expires + .duration_since(now) + .unwrap_or_default() + .as_millis() as u64, + } + } + + pub fn post_response(&self, now: SystemTime) -> PostResponse { + PostResponse { + id: self.id.to_string(), + sequence_token: self.sequence_token(), + expires_in_ms: self + .expires + .duration_since(now) + .unwrap_or_default() + .as_millis() as u64, + } + } + + pub fn put_response(&self) -> PutResponse { + PutResponse { + sequence_token: self.sequence_token(), + } + } +} diff --git a/synapse/config/experimental.py b/synapse/config/experimental.py index b6c8b8c0625..05091ca6eb6 100644 --- a/synapse/config/experimental.py +++ b/synapse/config/experimental.py @@ -509,7 +509,8 @@ def read_config( "msc4069_profile_inhibit_propagation", False ) - # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code + # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code - 2024 version: + # See: https://github.com/element-hq/synapse/issues/19434 self.msc4108_enabled = experimental.get("msc4108_enabled", False) self.msc4108_delegation_endpoint: str | None = experimental.get( @@ -534,6 +535,26 @@ def read_config( ("experimental", "msc4108_delegation_endpoint"), ) + # MSC4388: Secure out-of-band channel for sign in with QR: + # See: https://github.com/element-hq/synapse/issues/19433 + msc4388_mode = experimental.get("msc4388_mode", "off") + + if msc4388_mode not in ["off", "public", "authenticated"]: + raise ConfigError( + "msc4388_mode must be one of 'off', 'public' or 'authenticated'", + ("experimental", "msc4388_mode"), + ) + self.msc4388_enabled: bool = msc4388_mode != "off" + self.msc4388_requires_authentication: bool = msc4388_mode == "authenticated" + + if self.msc4388_enabled and not ( + config.get("matrix_authentication_service") or {} + ).get("enabled", False): + raise ConfigError( + "MSC4388 requires matrix_authentication_service to be enabled", + ("experimental", "msc4388_enabled"), + ) + # MSC4133: Custom profile fields self.msc4133_enabled: bool = experimental.get("msc4133_enabled", False) diff --git a/synapse/rest/client/rendezvous.py b/synapse/rest/client/rendezvous.py index 08a449eefc7..bd9205fc5f0 100644 --- a/synapse/rest/client/rendezvous.py +++ b/synapse/rest/client/rendezvous.py @@ -21,7 +21,7 @@ import logging from http.client import TEMPORARY_REDIRECT -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any from synapse.http.server import HttpServer, respond_with_redirect from synapse.http.servlet import RestServlet @@ -68,9 +68,57 @@ def on_POST(self, request: SynapseRequest) -> None: self._handler.handle_post(request) +class MSC4388CreateRendezvousServlet(RestServlet): + PATTERNS = client_patterns( + "/io.element.msc4388/rendezvous$", releases=[], v1=False, unstable=True + ) + + def __init__(self, hs: "HomeServer") -> None: + super().__init__() + self._handler = hs.get_msc4388_rendezvous_handler() + self.auth = hs.get_auth() + self.require_authentication = ( + hs.config.experimental.msc4388_requires_authentication + ) + + async def on_POST(self, request: SynapseRequest) -> tuple[int, Any]: + if self.require_authentication: + # This will raise if the user is not authenticated + await self.auth.get_user_by_req(request) + return self._handler.handle_post(request) + + +class MSC4388UpdateRendezvousServlet(RestServlet): + PATTERNS = client_patterns( + "/io.element.msc4388/rendezvous/(?P[^/]+)$", + releases=[], + v1=False, + unstable=True, + ) + + def __init__(self, hs: "HomeServer") -> None: + super().__init__() + self._handler = hs.get_msc4388_rendezvous_handler() + + def on_GET(self, request: SynapseRequest, rendezvous_id: str) -> tuple[int, Any]: + return self._handler.handle_get(rendezvous_id, request) + + def on_PUT(self, request: SynapseRequest, rendezvous_id: str) -> tuple[int, Any]: + return self._handler.handle_put(rendezvous_id, request) + + def on_DELETE( + self, _request: SynapseRequest, rendezvous_id: str + ) -> tuple[int, Any]: + return self._handler.handle_delete(rendezvous_id) + + def register_servlets(hs: "HomeServer", http_server: HttpServer) -> None: if hs.config.experimental.msc4108_enabled: MSC4108RendezvousServlet(hs).register(http_server) if hs.config.experimental.msc4108_delegation_endpoint is not None: MSC4108DelegationRendezvousServlet(hs).register(http_server) + + if hs.config.experimental.msc4388_enabled: + MSC4388CreateRendezvousServlet(hs).register(http_server) + MSC4388UpdateRendezvousServlet(hs).register(http_server) diff --git a/synapse/rest/client/versions.py b/synapse/rest/client/versions.py index 89458495311..e443629faba 100644 --- a/synapse/rest/client/versions.py +++ b/synapse/rest/client/versions.py @@ -161,7 +161,7 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: "org.matrix.msc4069": self.config.experimental.msc4069_profile_inhibit_propagation, # Allows clients to handle push for encrypted events. "org.matrix.msc4028": self.config.experimental.msc4028_push_encrypted_events, - # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code + # MSC4108: Mechanism to allow OIDC sign in and E2EE set up via QR code - 2024 version "org.matrix.msc4108": ( self.config.experimental.msc4108_enabled or ( @@ -169,6 +169,8 @@ async def on_GET(self, request: SynapseRequest) -> tuple[int, JsonDict]: is not None ) ), + # MSC4388: Secure out-of-band channel for sign in with QR + "io.element.msc4388": (self.config.experimental.msc4388_enabled), # MSC4140: Delayed events "org.matrix.msc4140": bool(self.config.server.max_event_delay_ms), # Simplified sliding sync diff --git a/synapse/server.py b/synapse/server.py index e6337c379b2..8bf19f11b5d 100644 --- a/synapse/server.py +++ b/synapse/server.py @@ -174,6 +174,7 @@ from synapse.storage import Databases from synapse.storage.controllers import StorageControllers from synapse.streams.events import EventSources +from synapse.synapse_rust.msc4388_rendezvous import MSC4388RendezvousHandler from synapse.synapse_rust.rendezvous import RendezvousHandler from synapse.types import DomainSpecificString, ISynapseReactor from synapse.util import SYNAPSE_VERSION @@ -1184,6 +1185,10 @@ def get_room_forgetter_handler(self) -> RoomForgetterHandler: def get_rendezvous_handler(self) -> RendezvousHandler: return RendezvousHandler(self) + @cache_in_self + def get_msc4388_rendezvous_handler(self) -> MSC4388RendezvousHandler: + return MSC4388RendezvousHandler(self) + @cache_in_self def get_outbound_redis_connection(self) -> "ConnectionHandler": """ diff --git a/synapse/synapse_rust/msc4388_rendezvous.pyi b/synapse/synapse_rust/msc4388_rendezvous.pyi new file mode 100644 index 00000000000..f8e064ef64f --- /dev/null +++ b/synapse/synapse_rust/msc4388_rendezvous.pyi @@ -0,0 +1,33 @@ +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + +from typing import Any + +from twisted.web.iweb import IRequest + +from synapse.server import HomeServer + +class MSC4388RendezvousHandler: + def __init__( + self, + homeserver: HomeServer, + /, + soft_limit: int = 100, # On each background eviction run sessions will be removed until we're under this limit + hard_limit: int = 200, # If this limit is reached an immediate eviction will be triggered + max_content_length: int = 4 * 1024, # MSC4388 specifies maximum of 4KB + eviction_interval: int = 60 * 1000, + ttl: int = 2 * 60 * 1000, # MSC4388 specifies minimum of 120 seconds + ) -> None: ... + def handle_post(self, request: IRequest) -> tuple[int, Any]: ... + def handle_get(self, session_id: str, request: IRequest) -> tuple[int, Any]: ... + def handle_put(self, session_id: str, request: IRequest) -> tuple[int, Any]: ... + def handle_delete(self, session_id: str) -> tuple[int, Any]: ... diff --git a/tests/rest/client/test_msc4388_rendezvous.py b/tests/rest/client/test_msc4388_rendezvous.py new file mode 100644 index 00000000000..f16bb3f344a --- /dev/null +++ b/tests/rest/client/test_msc4388_rendezvous.py @@ -0,0 +1,743 @@ +# +# This file is licensed under the Affero General Public License (AGPL) version 3. +# +# Copyright (C) 2026 Element Creations Ltd +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# See the GNU Affero General Public License for more details: +# . + + +import json +import urllib.parse +from typing import Any, Mapping +from unittest.mock import Mock + +from parameterized import parameterized + +from twisted.internet.testing import MemoryReactor + +from synapse.api.auth.mas import MasDelegatedAuth +from synapse.rest import admin +from synapse.rest.client import login, rendezvous +from synapse.server import HomeServer +from synapse.types import UserID +from synapse.util.clock import Clock + +from tests import unittest +from tests.unittest import checked_cast, override_config + +rz_endpoint = "/_matrix/client/unstable/io.element.msc4388/rendezvous" + + +class RendezvousServletTestCase(unittest.HomeserverTestCase): + """ + Test the experimental MSC4388 rendezvous endpoint. + """ + + servlets = [ + admin.register_servlets, + login.register_servlets, + rendezvous.register_servlets, + ] + + def make_homeserver(self, reactor: MemoryReactor, clock: Clock) -> HomeServer: + self.hs = self.setup_test_homeserver() + return self.hs + + def setup_mock_oauth(self) -> None: + """ + This isn't a very elegant way to mock the OAuth API, but it works for our purposes. + """ + + self.auth = checked_cast(MasDelegatedAuth, self.hs.get_auth()) + + self._rust_client = Mock(spec=["post"]) + self._rust_client.post = self._mock_oauth_response + self.auth._rust_http_client = self._rust_client + + async def _mock_oauth_response( + self, + url: str, + response_limit: int, + headers: Mapping[str, str], + request_body: str, + ) -> bytes: + # get the token from the request body which is form encoded + parsed_body = urllib.parse.parse_qs(request_body) + token = parsed_body.get("token", [""])[0] + + if not token.startswith("mock_token_"): + return bytes(json.dumps({"active": False}).encode("utf-8")) + token = token.replace("mock_token_", "") + + username, device_id = token.split("_", 1) + user_id = UserID(username, self.hs.hostname) + store = self.hs.get_datastores().main + + # Check th user exists in the store + user_info = await store.get_user_by_id(user_id=user_id.to_string()) + if user_info is None: + return bytes(json.dumps({"active": False}).encode("utf-8")) + + # Check the device exists in the store + device = await store.get_device( + user_id=user_id.to_string(), device_id=device_id + ) + if device is None: + return bytes(json.dumps({"active": False}).encode("utf-8")) + + return bytes( + json.dumps( + { + "active": True, + "scope": "urn:matrix:client:device:" + + device_id + + " urn:matrix:client:api:*", + "username": username, + } + ).encode("utf-8") + ) + + def register_oauth_user(self, username: str, device_id: str) -> str: + # Provision the user and the device + store = self.hs.get_datastores().main + user_id = UserID(username, self.hs.hostname) + + self.get_success(store.register_user(user_id=user_id.to_string())) + self.get_success( + store.store_device( + user_id=user_id.to_string(), + device_id=device_id, + initial_device_display_name=None, + ) + ) + # Generate an access token for the device + return "mock_token_" + username + "_" + device_id + + def test_disabled(self) -> None: + channel = self.make_request("POST", rz_endpoint, {}, access_token=None) + self.assertEqual(channel.code, 404) + + @override_config( + { + "experimental_features": { + "msc4388_mode": "off", + }, + } + ) + def test_off(self) -> None: + channel = self.make_request("POST", rz_endpoint, {}, access_token=None) + self.assertEqual(channel.code, 404) + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_rendezvous_public(self) -> None: + """ + Test the MSC4108 rendezvous endpoint, including: + - Creating a session + - Getting the data back + - Updating the data + - Deleting the data + - Sequence token handling + """ + # We can post arbitrary data to the endpoint + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + sequence_token = channel.json_body["sequence_token"] + expires_in_ms = channel.json_body["expires_in_ms"] + self.assertGreater(expires_in_ms, 0) + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can get the data back + # Advances clock by 100ms + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=bar") + self.assertEqual(channel.json_body["sequence_token"], sequence_token) + self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 100) + + # We can update the data + # Advances clock by 100ms + channel = self.make_request( + "PUT", + session_endpoint, + {"sequence_token": sequence_token, "data": "foo=baz"}, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + old_sequence_token = sequence_token + new_sequence_token = channel.json_body["sequence_token"] + + # If we try to update it again with the old etag, it should fail + # Advances clock by 100ms + channel = self.make_request( + "PUT", + session_endpoint, + {"sequence_token": old_sequence_token, "data": "bar=baz"}, + access_token=None, + ) + + self.assertEqual(channel.code, 409) + self.assertEqual( + channel.json_body["errcode"], "IO_ELEMENT_MSC4388_CONCURRENT_WRITE" + ) + + # We should get the updated data + # Advances clock by 100ms + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=baz") + self.assertEqual(channel.json_body["sequence_token"], new_sequence_token) + self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 400) + + # We can delete the data + channel = self.make_request( + "DELETE", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + + # If we try to get the data again, it should fail + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 404) + self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND") + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "authenticated", + }, + } + ) + def test_rendezvous_requires_authentication(self) -> None: + """ + Test the MSC4108 rendezvous endpoint when configured with the mode authenticated, including: + - Creating a session + - Getting the data back + - Updating the data + - Deleting the data + - Sequence token handling + """ + self.setup_mock_oauth() + alice_token = self.register_oauth_user("alice", "device1") + + # This should fail without authentication: + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 401) + + # This should work as we are now authenticated + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=alice_token, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + sequence_token = channel.json_body["sequence_token"] + expires_in_ms = channel.json_body["expires_in_ms"] + self.assertGreater(expires_in_ms, 0) + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can get the data back without authentication + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=bar") + self.assertEqual(channel.json_body["sequence_token"], sequence_token) + self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms) + + # We can update the data without authentication + channel = self.make_request( + "PUT", + session_endpoint, + {"sequence_token": sequence_token, "data": "foo=baz"}, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + new_sequence_token = channel.json_body["sequence_token"] + + # We should get the updated data without authentication + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=baz") + self.assertEqual(channel.json_body["sequence_token"], new_sequence_token) + self.assertEqual(channel.json_body["expires_in_ms"], expires_in_ms - 200) + + # We can delete the data without authentication + channel = self.make_request( + "DELETE", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + + # If we try to get the data again, it should fail + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 404) + self.assertEqual(channel.json_body["errcode"], "M_NOT_FOUND") + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_expiration(self) -> None: + """ + Test that entries are evicted after a TTL. + """ + # Start a new session + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + session_endpoint = rz_endpoint + "/" + channel.json_body["id"] + + # Sanity check that we can get the data back + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=bar") + + # Advance the clock, TTL of entries is 2 minutes + self.reactor.advance(120) + + # Get the data back, it should be gone + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + self.assertEqual(channel.code, 404) + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_capacity(self) -> None: + """ + Test that the soft capacity limit is enforced on the rendezvous sessions, as old + entries are evicted at an interval when the limit is reached. + """ + # Start a new session + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + session_endpoint = rz_endpoint + "/" + channel.json_body["id"] + + # Sanity check that we can get the data back + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=bar") + + # We advance the clock to make sure that this entry is the "lowest" in the session list + self.reactor.advance(1) + + # Start a lot of new sessions + for _ in range(100): + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + + # Get the data back, it should still be there, as the eviction hasn't run yet + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + + # Advance the clock, as it will trigger the eviction + self.reactor.advance(59) + + # Get the data back, it should be gone + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 404) + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_hard_capacity(self) -> None: + """ + Test that the hard capacity limit is enforced on the rendezvous sessions, as old + entries are evicted immediately when the limit is reached. + """ + # Start a new session + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + session_endpoint = rz_endpoint + "/" + channel.json_body["id"] + # We advance the clock to make sure that this entry is the "lowest" in the session list + self.reactor.advance(1) + + # Sanity check that we can get the data back + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + self.assertEqual(channel.code, 200) + self.assertEqual(channel.json_body["data"], "foo=bar") + + # Start a lot of new sessions + for _ in range(200): + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + + # Get the data back, it should already be gone as we hit the hard limit + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 404) + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_data_type(self) -> None: + """ + Test that the data field is restricted to string. + """ + invalid_datas: list[Any] = [123214, ["asd"], {"asd": "asdsad"}, None] + + # We cannot post invalid non-string data field values to the endpoint + for invalid_data in invalid_datas: + channel = self.make_request( + "POST", + rz_endpoint, + {"data": invalid_data}, + access_token=None, + ) + self.assertEqual(channel.code, 400) + self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM") + + # Make a valid request + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "test"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + sequence_token = channel.json_body["sequence_token"] + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can't update the data with invalid data + for invalid_data in invalid_datas: + channel = self.make_request( + "PUT", + session_endpoint, + {"sequence_token": sequence_token, "data": invalid_data}, + access_token=None, + ) + self.assertEqual(channel.code, 400) + self.assertEqual(channel.json_body["errcode"], "M_INVALID_PARAM") + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_max_length(self) -> None: + """ + Test that the data max length is restricted. + """ + too_long_data = "a" * 5000 # MSC4108 specifies 4KB max length + + channel = self.make_request( + "POST", + rz_endpoint, + {"data": too_long_data}, + access_token=None, + ) + self.assertEqual(channel.code, 413) + self.assertEqual(channel.json_body["errcode"], "M_TOO_LARGE") + + # Make a valid request + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "test"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + sequence_token = channel.json_body["sequence_token"] + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can't update the data with invalid data + channel = self.make_request( + "PUT", + session_endpoint, + {"sequence_token": sequence_token, "data": too_long_data}, + access_token=None, + ) + self.assertEqual(channel.code, 413) + self.assertEqual(channel.json_body["errcode"], "M_TOO_LARGE") + + @parameterized.expand( + [ + ("Sec-Fetch-Dest", "document"), + ("Sec-Fetch-Dest", "image"), + ("Sec-Fetch-Dest", "iframe"), + ("Sec-Fetch-Dest", "embed"), + ("Sec-Fetch-Dest", "video"), + ("Sec-Fetch-Mode", "navigate"), + ("Sec-Fetch-User", "?1"), + ("Sec-Fetch-Site", "none"), + ] + ) + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_rendezvous_rejects_unsafe_get_requests( + self, header_name: str, header_value: str + ) -> None: + """ + Tests that GET requests have the appropriate Sec-Fetch-* controls applied as per the MSC. + The mode is set to `public` but this doesn't actually matter. + """ + # We can post arbitrary data to the endpoint + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can get the data back + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + custom_headers=[(header_name, header_value)], + ) + self.assertEqual(channel.code, 403) + self.assertEqual(channel.json_body["errcode"], "M_FORBIDDEN") + + @override_config( + { + "disable_registration": True, + "matrix_authentication_service": { + "enabled": True, + "secret": "secret_value", + "endpoint": "https://issuer", + }, + "experimental_features": { + "msc4388_mode": "public", + }, + } + ) + def test_rendezvous_allows_from_browser_fetch(self) -> None: + """ + We check that the GET policy does allow for an expected browser fetch + The mode is set to `public` but this doesn't actually matter. + """ + # We can post arbitrary data to the endpoint + channel = self.make_request( + "POST", + rz_endpoint, + {"data": "foo=bar"}, + access_token=None, + ) + self.assertEqual(channel.code, 200) + rendezvous_id = channel.json_body["id"] + + session_endpoint = rz_endpoint + f"/{rendezvous_id}" + + # We can get the data back + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + ) + + self.assertEqual(channel.code, 200) + + # Test for a typical browser fetch from a client hosted on a different origin + channel = self.make_request( + "GET", + session_endpoint, + access_token=None, + custom_headers=[ + ("Sec-Fetch-Dest", "empty"), + ("Sec-Fetch-Mode", "cors"), + ("Sec-Fetch-Site", "cross-site"), + ], + ) + + self.assertEqual(channel.code, 200)