diff --git a/Cargo.lock b/Cargo.lock index 3c1e9b5cdf..a4263339cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -144,7 +144,7 @@ checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -236,6 +236,33 @@ dependencies = [ "cmov", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.104", +] + [[package]] name = "deadpool" version = "0.12.3" @@ -313,6 +340,30 @@ dependencies = [ "syn 2.0.104", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8", + "signature", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -331,6 +382,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "flagset" version = "0.4.7" @@ -479,7 +536,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "rand_core", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -1090,6 +1147,16 @@ version = "0.2.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der", + "spki", +] + [[package]] name = "portable-atomic" version = "1.11.1" @@ -1110,7 +1177,7 @@ dependencies = [ "md-5", "memchr", "rand", - "sha2", + "sha2 0.11.0", "stringprep", ] @@ -1304,7 +1371,16 @@ checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", "getrandom 0.4.3", - "rand_core", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.16", ] [[package]] @@ -1319,7 +1395,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core", + "rand_core 0.10.1", ] [[package]] @@ -1610,6 +1686,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1627,6 +1714,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "signature" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" +dependencies = [ + "rand_core 0.6.4", +] + [[package]] name = "siphasher" version = "1.0.4" @@ -1730,6 +1826,7 @@ dependencies = [ "blake2", "bytes", "deadpool", + "ed25519-dalek", "fallible-iterator", "futures", "headers", @@ -1758,7 +1855,7 @@ dependencies = [ "rustls-pemfile", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tokio", "tokio-postgres", "tokio-postgres-rustls", @@ -1916,7 +2013,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" dependencies = [ "rustls", - "sha2", + "sha2 0.11.0", "tokio", "tokio-postgres", "tokio-rustls", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index bf4f713956..93a1012b2c 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -102,6 +102,9 @@ tokio-postgres-rustls = { version = "0.14.0", default-features = false, features "ring", ] } lru = "0.16.4" +# Signing outbound federation requests. 2.x rather than 3.x, which needs a +# newer rustc than our MSRV. +ed25519-dalek = "2.2.0" [dev-dependencies] tokio = { version = "1.44.2", features = ["macros", "test-util"] } diff --git a/rust/src/federation/mod.rs b/rust/src/federation/mod.rs new file mode 100644 index 0000000000..2816829d19 --- /dev/null +++ b/rust/src/federation/mod.rs @@ -0,0 +1,90 @@ +/* + * 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: + * . + * + */ + +//! Outbound federation. +//! +//! This is being built up from the network end, one layer at a time. +//! +//! - [`signing`]: the `X-Matrix` request signature. +//! - [`transport`]: the seam between the request layer and the network, with +//! a Python-backed implementation for tests. + +use pyo3::prelude::*; + +pub mod signing; +pub mod transport; + +use signing::{make_auth_header, CanonicalBody, Destination, FederationSigningKey}; + +/// Build the `Authorization` header for a request, as +/// `MatrixFederationHttpClient.build_auth_headers` does. For tests, which +/// check the two produce the same header. +#[pyfunction] +#[pyo3(signature = (signing_key, origin, method, uri, destination, destination_is = None, content = None))] +fn build_auth_header_for_test( + signing_key: FederationSigningKey, + origin: &str, + method: &str, + uri: &str, + destination: Option<&str>, + destination_is: Option<&str>, + content: Option>, +) -> PyResult { + let destination = match (destination, destination_is) { + (Some(server), None) => Destination::Server(server), + (None, Some(identity_server)) => Destination::IdentityServer(identity_server), + (Some(server), Some(identity_server)) => Destination::Both { + server, + identity_server, + }, + (None, None) => { + return Err(pyo3::exceptions::PyValueError::new_err( + "one of destination and destination_is is required", + )) + } + }; + + let content = content + .map(|c| -> PyResult<_> { + let value: serde_json::Value = pythonize::depythonize(&c)?; + Ok(CanonicalBody::new(&value)?) + }) + .transpose()?; + + Ok(make_auth_header( + &signing_key, + origin, + method, + uri, + destination, + content.as_ref(), + )?) +} + +/// Called when registering modules with python. +pub fn register_module(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { + let child_module = PyModule::new(py, "federation")?; + child_module.add_function(wrap_pyfunction!(build_auth_header_for_test, &child_module)?)?; + + m.add_submodule(&child_module)?; + + // We need to manually add the module to sys.modules to make `from + // synapse.synapse_rust import federation` work. + py.import("sys")? + .getattr("modules")? + .set_item("synapse.synapse_rust.federation", child_module)?; + + Ok(()) +} diff --git a/rust/src/federation/signing.rs b/rust/src/federation/signing.rs new file mode 100644 index 0000000000..839ae8dedc --- /dev/null +++ b/rust/src/federation/signing.rs @@ -0,0 +1,342 @@ +/* + * 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: + * . + * + * Originally licensed under the Apache License, Version 2.0: + * . + * + * Derived from `src/client.rs` of + * . + * + * [This file includes modifications made by Element Creations Ltd] + */ + +//! Signing outbound federation requests with an `X-Matrix` `Authorization` +//! header. +//! +//! The signature covers the canonical JSON of +//! `{method, uri, origin, destination[, destination_is][, content]}`, as +//! `MatrixFederationHttpClient.build_auth_headers` builds it in Python. The +//! header uses the same layout and quoting as Python's. + +use anyhow::{bail, Context}; +use base64::{prelude::BASE64_STANDARD_NO_PAD, Engine}; +use ed25519_dalek::{Signer, SigningKey}; +use pyo3::{intern, prelude::*, types::PyBytes}; +use serde::Serialize; + +use crate::canonical_json::{to_string_canonical, to_vec_canonical, CanonicalizationOptions}; + +/// A homeserver's federation signing key: `hs.signing_key` in Python. +/// +/// Python holds this as a `signedjson` key, which is a `nacl.signing.SigningKey` +/// with `alg` and `version` attributes. We take its 32-byte seed, so Rust +/// never reads the key file. +#[derive(Clone)] +pub struct FederationSigningKey { + /// The key ID, e.g. `ed25519:a_AbCd`. + key_id: String, + key: SigningKey, +} + +impl std::fmt::Debug for FederationSigningKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Never print the key itself. + f.debug_struct("FederationSigningKey") + .field("key_id", &self.key_id) + .finish_non_exhaustive() + } +} + +impl FederationSigningKey { + /// Build a key from its algorithm, version and 32-byte seed. + pub fn new(alg: &str, version: &str, seed: &[u8]) -> anyhow::Result { + if alg != "ed25519" { + bail!("unsupported signing key algorithm {alg:?}"); + } + + let seed: &[u8; 32] = seed + .try_into() + .with_context(|| format!("ed25519 seed is {} bytes, not 32", seed.len()))?; + + Ok(FederationSigningKey { + key_id: format!("{alg}:{version}"), + key: SigningKey::from_bytes(seed), + }) + } + + pub fn key_id(&self) -> &str { + &self.key_id + } + + /// Sign `message`, returning the unpadded base64 signature. + pub fn sign_base64(&self, message: &[u8]) -> String { + BASE64_STANDARD_NO_PAD.encode(self.key.sign(message).to_bytes()) + } +} + +impl<'a, 'py> FromPyObject<'a, 'py> for FederationSigningKey { + type Error = PyErr; + + fn extract(obj: Borrowed<'a, 'py, PyAny>) -> PyResult { + let py = obj.py(); + let alg: String = obj.getattr(intern!(py, "alg"))?.extract()?; + let version: String = obj.getattr(intern!(py, "version"))?.extract()?; + // `nacl.signing.SigningKey.encode()` returns the seed. + let seed = obj.call_method0(intern!(py, "encode"))?; + let seed = seed.cast::()?; + + Ok(FederationSigningKey::new(&alg, &version, seed.as_bytes())?) + } +} + +/// Who a request is for. +#[derive(Clone, Copy, Debug)] +pub enum Destination<'a> { + /// Another homeserver. Signed as `destination`. + Server(&'a str), + /// An identity server. Signed as `destination_is`. + IdentityServer(&'a str), + /// Both, which Python allows although no caller does it. + Both { + server: &'a str, + identity_server: &'a str, + }, +} + +impl<'a> Destination<'a> { + fn server(&self) -> Option<&'a str> { + match *self { + Destination::Server(s) | Destination::Both { server: s, .. } => Some(s), + Destination::IdentityServer(_) => None, + } + } + + fn identity_server(&self) -> Option<&'a str> { + match *self { + Destination::IdentityServer(s) + | Destination::Both { + identity_server: s, .. + } => Some(s), + Destination::Server(_) => None, + } + } + + /// The value of the header's `destination` parameter: the server, else the + /// identity server. + fn header_value(&self) -> &'a str { + self.server() + .or_else(|| self.identity_server()) + .expect("a destination has one or the other") + } +} + +/// A request body, serialized as canonical JSON once and both signed and sent +/// in that form. +#[derive(Clone, Debug)] +pub struct CanonicalBody(String); + +impl CanonicalBody { + pub fn new(content: &T) -> anyhow::Result { + Ok(CanonicalBody( + to_string_canonical(content, CanonicalizationOptions::relaxed()) + .context("serializing request body")?, + )) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn into_bytes(self) -> bytes::Bytes { + self.0.into() + } +} + +/// The `Authorization` header value for one federation request. +/// +/// `uri` is the path and query string, as sent on the request line. +pub fn make_auth_header( + key: &FederationSigningKey, + origin: &str, + method: &str, + uri: &str, + destination: Destination<'_>, + content: Option<&CanonicalBody>, +) -> anyhow::Result { + let message = signing_message(origin, method, uri, destination, content)?; + let sig = key.sign_base64(&message); + + Ok(format!( + r#"X-Matrix origin="{origin}",key="{key_id}",sig="{sig}",destination="{destination}""#, + key_id = key.key_id(), + destination = destination.header_value(), + )) +} + +/// The canonical JSON that the request signature covers. +/// +/// Canonical JSON sorts keys, so this writes them in sorted order around the +/// already canonical body, rather than parsing the body back to embed it. +fn signing_message( + origin: &str, + method: &str, + uri: &str, + destination: Destination<'_>, + content: Option<&CanonicalBody>, +) -> anyhow::Result> { + let string = |s: &str| to_vec_canonical(s, CanonicalizationOptions::relaxed()); + + let mut fields: Vec<(&str, Vec)> = Vec::with_capacity(6); + if let Some(content) = content { + fields.push(("content", content.as_str().as_bytes().to_vec())); + } + if let Some(server) = destination.server() { + fields.push(("destination", string(server)?)); + } + if let Some(identity_server) = destination.identity_server() { + fields.push(("destination_is", string(identity_server)?)); + } + fields.push(("method", string(method)?)); + fields.push(("origin", string(origin)?)); + fields.push(("uri", string(uri)?)); + + debug_assert!(fields.windows(2).all(|w| w[0].0 < w[1].0)); + + let mut out = Vec::with_capacity(256); + out.push(b'{'); + for (i, (name, value)) in fields.iter().enumerate() { + if i > 0 { + out.push(b','); + } + out.push(b'"'); + out.extend_from_slice(name.as_bytes()); + out.extend_from_slice(b"\":"); + out.extend_from_slice(value); + } + out.push(b'}'); + + Ok(out) +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use serde_json::json; + + use super::*; + + fn zero_key() -> FederationSigningKey { + FederationSigningKey::new("ed25519", "test", &[0u8; 32]).unwrap() + } + + /// The message equals what a generic canonical JSON serializer produces + /// for the same object. + #[test] + fn signing_message_is_canonical_json() { + let body = CanonicalBody::new(&json!({"b": [1, 2], "a": "é"})).unwrap(); + let message = signing_message( + "origin.example", + "PUT", + "/_matrix/federation/v1/send/1?x=y", + Destination::Both { + server: "dest.example", + identity_server: "is.example", + }, + Some(&body), + ) + .unwrap(); + + let expected = to_vec_canonical( + &json!({ + "method": "PUT", + "uri": "/_matrix/federation/v1/send/1?x=y", + "origin": "origin.example", + "destination": "dest.example", + "destination_is": "is.example", + "content": {"a": "é", "b": [1, 2]}, + }), + CanonicalizationOptions::relaxed(), + ) + .unwrap(); + + assert_eq!( + String::from_utf8(message).unwrap(), + String::from_utf8(expected).unwrap() + ); + } + + // The signatures in the next two tests are the vectors from + // matrix-hyper-federation-client, which signs the same objects. Only the + // header layout differs, to match Python's. + + #[test] + fn auth_header_no_content() { + let header = make_auth_header( + &zero_key(), + "localhost", + "GET", + "/test", + Destination::Server("matrix.org"), + None, + ) + .unwrap(); + + assert_eq!( + header, + r#"X-Matrix origin="localhost",key="ed25519:test",sig="aemgn56SKst12mSbh2X0l3pBuzyWmAkURVknrTqz/ev2p8KDnKHXnFw/UsLOfwbD6V/om4Lh+DzeyE0MlJ1GBA",destination="matrix.org""# + ); + } + + #[test] + fn auth_header_content() { + let mut map = BTreeMap::new(); + map.insert("foo", "bar"); + + let header = make_auth_header( + &zero_key(), + "localhost", + "GET", + "/test", + Destination::Server("matrix.org"), + Some(&CanonicalBody::new(&map).unwrap()), + ) + .unwrap(); + + assert_eq!( + header, + r#"X-Matrix origin="localhost",key="ed25519:test",sig="JwOvw9q9rGU1FOX+nVqZkXL9P6WhsKE3aNV2Q+Ftj0urJHv8olv7r7gOMZM3nITm0gVwYBN8s0FBGJymeQt9DA",destination="matrix.org""# + ); + } + + #[test] + fn identity_server_destination() { + let header = make_auth_header( + &zero_key(), + "localhost", + "POST", + "/_matrix/identity/v2/account/register", + Destination::IdentityServer("is.example"), + None, + ) + .unwrap(); + + assert!(header.ends_with(r#",destination="is.example""#)); + } + + #[test] + fn rejects_bad_keys() { + assert!(FederationSigningKey::new("ed25519", "a", &[0u8; 31]).is_err()); + assert!(FederationSigningKey::new("rsa", "a", &[0u8; 32]).is_err()); + } +} diff --git a/rust/src/federation/transport.rs b/rust/src/federation/transport.rs new file mode 100644 index 0000000000..a38c8d33a8 --- /dev/null +++ b/rust/src/federation/transport.rs @@ -0,0 +1,252 @@ +/* + * 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: + * . + * + */ + +//! The seam between the federation request layer and the network. +//! +//! The request layer (signing, retries, backoff) sends through a +//! [`FederationTransport`]. In production that is the +//! [`Agent`](super::agent::Agent), which resolves Matrix server names and +//! pools connections. Tests install a [`PyTransport`] instead, which hands +//! each request to a Python callable. That is the Rust-side replacement for +//! the `FakeTransport` and `reactor.tcpClients` plumbing the Python tests use. + +use std::fmt; + +use async_trait::async_trait; +use bytes::Bytes; +use http::{HeaderName, HeaderValue, Request, Response, StatusCode}; +use http_body_util::{combinators::UnsyncBoxBody, BodyExt, Full}; +use pyo3::{ + prelude::*, + types::{PyBytes, PyList, PyTuple}, +}; + +/// A boxed error, as body streams produce. +pub type BoxError = Box; + +/// A streaming response body. +pub type ResponseBody = UnsyncBoxBody; + +/// Sends one federation request and returns the response head with a +/// streaming body. +/// +/// The request URI is `matrix-federation:///`, or an +/// `https://` URI for anything that is not server-to-server federation. +#[async_trait] +pub trait FederationTransport: Send + Sync + 'static { + async fn send(&self, request: Request) + -> Result, TransportError>; +} + +/// Why a request got no response. +/// +/// The request layer needs to tell these apart: a DNS failure is not +/// retried when the caller asked for `retry_on_dns_fail=False`, and a +/// blocked IP address is reported as such. +#[derive(Debug)] +pub enum TransportError { + /// Looking up the server's addresses (SRV or A/AAAA) failed, or found + /// nothing we may connect to. + Dns(anyhow::Error), + /// The server publishes a single SRV record with target `.`, which means + /// it offers no federation service. + ServiceUnavailable(String), + /// The destination is an IP address that the IP range blocklist covers. + IpBlocked(std::net::IpAddr), + /// Every TCP connection attempt failed, or the proxy refused the tunnel. + Connect(anyhow::Error), + /// The TLS handshake or certificate verification failed. + Tls(anyhow::Error), + /// The request could not be built. + InvalidRequest(anyhow::Error), + /// Sending the request or reading the response head failed. + Request(anyhow::Error), + /// The request took longer than the transport's timeout. + Timeout, + /// A Python test transport raised. + Python(PyErr), +} + +impl TransportError { + /// Whether this is a name resolution failure, which Python reports as a + /// `DNSLookupError`. + pub fn is_dns(&self) -> bool { + matches!(self, TransportError::Dns(_)) + } +} + +impl fmt::Display for TransportError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + TransportError::Dns(e) => write!(f, "DNS lookup failed: {e:#}"), + TransportError::ServiceUnavailable(name) => write!(f, "service {name} unavailable"), + TransportError::IpBlocked(ip) => write!(f, "IP address {ip} blocked"), + TransportError::Connect(e) => write!(f, "connection failed: {e:#}"), + TransportError::Tls(e) => write!(f, "TLS failed: {e:#}"), + TransportError::InvalidRequest(e) => write!(f, "invalid request: {e:#}"), + TransportError::Request(e) => write!(f, "request failed: {e:#}"), + TransportError::Timeout => write!(f, "request timed out"), + TransportError::Python(e) => write!(f, "test transport raised: {e}"), + } + } +} + +impl std::error::Error for TransportError {} + +/// What a [`PyTransport`] callable returns: status, headers and body. +type PyResponse = (u16, Vec<(Vec, Vec)>, Vec); + +/// A [`FederationTransport`] that calls a Python callable, for tests. +/// +/// The callable is called with the GIL held, on a tokio worker thread, as +/// `callable(destination: str, method: str, path: str, +/// headers: list[tuple[bytes, bytes]], body: bytes)` and returns +/// `(status: int, headers: list[tuple[bytes, bytes]], body: bytes)`. +/// `path` includes the query string. If it raises, the request fails with +/// [`TransportError::Python`]. +pub struct PyTransport { + callable: Py, +} + +impl PyTransport { + pub fn new(callable: Py) -> Self { + PyTransport { callable } + } + + fn call(&self, py: Python<'_>, request: &Request) -> PyResult> { + let uri = request.uri(); + let destination = uri.authority().map(|a| a.as_str()).unwrap_or(""); + let path = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/"); + + let headers = PyList::empty(py); + for (name, value) in request.headers() { + headers.append(( + PyBytes::new(py, name.as_str().as_bytes()), + PyBytes::new(py, value.as_bytes()), + ))?; + } + + let result = self.callable.bind(py).call1(( + destination, + request.method().as_str(), + path, + headers, + PyBytes::new(py, request.body()), + ))?; + + let result = result.cast::()?; + let (status, headers, body): PyResponse = result.extract()?; + + let mut response = Response::new(Bytes::from(body)); + *response.status_mut() = StatusCode::from_u16(status) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + for (name, value) in headers { + let name = HeaderName::from_bytes(&name) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + let value = HeaderValue::from_bytes(&value) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + response.headers_mut().append(name, value); + } + + Ok(response) + } +} + +#[async_trait] +impl FederationTransport for PyTransport { + async fn send( + &self, + request: Request, + ) -> Result, TransportError> { + let response = + Python::attach(|py| self.call(py, &request)).map_err(TransportError::Python)?; + + Ok(response.map(|body| { + Full::new(body) + .map_err(|never| match never {}) + .boxed_unsync() + })) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The test transport passes the request through to Python and builds the + /// response from what it returns. + #[tokio::test] + async fn py_transport_round_trip() { + Python::initialize(); + + let callable = Python::attach(|py| -> PyResult> { + let locals = pyo3::types::PyDict::new(py); + py.run( + c" +def handler(destination, method, path, headers, body): + assert destination == 'remote.example', destination + assert method == 'PUT', method + assert path == '/_matrix/federation/v1/send/1?a=b', path + assert (b'content-type', b'application/json') in headers, headers + return (201, [(b'x-seen', body)], b'{\"ok\":true}') +", + None, + Some(&locals), + )?; + Ok(locals.get_item("handler")?.unwrap().unbind()) + }) + .unwrap(); + + let transport = PyTransport::new(callable); + let request = + Request::put("matrix-federation://remote.example/_matrix/federation/v1/send/1?a=b") + .header("content-type", "application/json") + .body(Bytes::from_static(b"{}")) + .unwrap(); + + let response = transport.send(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["x-seen"], "{}"); + let body = response.into_body().collect().await.unwrap().to_bytes(); + assert_eq!(&body[..], b"{\"ok\":true}"); + } + + #[tokio::test] + async fn py_transport_raises() { + Python::initialize(); + + let callable = Python::attach(|py| -> PyResult> { + let locals = pyo3::types::PyDict::new(py); + py.run( + c" +def handler(*args): + raise ConnectionError('boom') +", + None, + Some(&locals), + )?; + Ok(locals.get_item("handler")?.unwrap().unbind()) + }) + .unwrap(); + + let transport = PyTransport::new(callable); + let request = Request::get("matrix-federation://remote.example/x") + .body(Bytes::new()) + .unwrap(); + + let err = transport.send(request).await.unwrap_err(); + assert!(matches!(err, TransportError::Python(_)), "{err}"); + } +} diff --git a/rust/src/homeserver.rs b/rust/src/homeserver.rs index 956455e8d4..bc6cbb3818 100644 --- a/rust/src/homeserver.rs +++ b/rust/src/homeserver.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use pyo3::{intern, prelude::*, types::PyDict}; use crate::config::SynapseHomeServerConfig; +use crate::federation::signing::FederationSigningKey; use crate::reactor::Reactor; use crate::runtime::RustRuntime; use crate::storage::{python::RustStore, store::Store}; @@ -84,6 +85,20 @@ impl HomeServer { self.0.bind(py).getattr(intern!(py, "config"))?.extract() } + /// This homeserver's name (`hs.hostname`). + pub fn hostname(&self, py: Python<'_>) -> PyResult { + self.0.bind(py).getattr(intern!(py, "hostname"))?.extract() + } + + /// The key this homeserver signs federation requests with + /// (`hs.signing_key`). + pub fn signing_key(&self, py: Python<'_>) -> PyResult { + self.0 + .bind(py) + .getattr(intern!(py, "signing_key"))? + .extract() + } + /// The Synapse `Clock` (`hs.get_clock()`). // TODO: give the clock a typed wrapper of its own. pub fn get_clock(&self, py: Python<'_>) -> PyResult> { diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 4e1eb8b91f..f65453640c 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -18,6 +18,7 @@ pub mod duration; pub mod errors; pub mod event_auth; pub mod events; +pub mod federation; pub mod handlers; pub mod homeserver; pub mod http; @@ -99,6 +100,7 @@ fn synapse_rust(py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { database::register_module(py, m)?; push::register_module(py, m)?; events::register_module(py, m)?; + federation::register_module(py, m)?; event_auth::register_module(py, m)?; handlers::register_module(py, m)?; http_client::register_module(py, m)?; diff --git a/synapse/synapse_rust/federation.pyi b/synapse/synapse_rust/federation.pyi new file mode 100644 index 0000000000..4c865daef8 --- /dev/null +++ b/synapse/synapse_rust/federation.pyi @@ -0,0 +1,28 @@ +# 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 signedjson.types import SigningKey + +from synapse.types import JsonDict + +def build_auth_header_for_test( + signing_key: SigningKey, + origin: str, + method: str, + uri: str, + destination: str | None, + destination_is: str | None = None, + content: JsonDict | None = None, +) -> str: + """Build the `X-Matrix` `Authorization` header the Rust request layer + sends. Tests compare it with `MatrixFederationHttpClient.build_auth_headers`. + """ diff --git a/tests/synapse_rust/test_federation_signing.py b/tests/synapse_rust/test_federation_signing.py new file mode 100644 index 0000000000..314383054c --- /dev/null +++ b/tests/synapse_rust/test_federation_signing.py @@ -0,0 +1,104 @@ +# +# 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 signedjson.key import get_verify_key +from signedjson.sign import verify_signed_json + +from twisted.internet.testing import MemoryReactor + +from synapse.server import HomeServer +from synapse.synapse_rust.federation import build_auth_header_for_test +from synapse.types import JsonDict +from synapse.util.clock import Clock + +from tests import unittest + + +class FederationSigningTestCase(unittest.HomeserverTestCase): + """The Rust `X-Matrix` header is byte-identical to Python's.""" + + def prepare(self, reactor: MemoryReactor, clock: Clock, hs: HomeServer) -> None: + self.client = hs.get_federation_http_client() + + def _check( + self, + method: str, + uri: str, + destination: str | None, + destination_is: str | None = None, + content: JsonDict | None = None, + ) -> None: + (python_header,) = self.client.build_auth_headers( + destination.encode() if destination is not None else None, + method.encode(), + uri.encode(), + content, + destination_is.encode() if destination_is is not None else None, + ) + + rust_header = build_auth_header_for_test( + self.hs.signing_key, + self.hs.hostname, + method, + uri, + destination, + destination_is, + content, + ) + + self.assertEqual(rust_header, python_header.decode("ascii")) + + def test_no_content(self) -> None: + self._check("GET", "/_matrix/federation/v1/version", "remote.example") + + def test_content(self) -> None: + self._check( + "PUT", + "/_matrix/federation/v1/send/123?x=%2Fy", + "remote.example:8448", + content={"pdus": [{"z": 1, "a": "é", "n": None}], "edus": []}, + ) + + def test_empty_content(self) -> None: + # An empty body is still signed, unlike a missing one. + self._check("POST", "/path", "remote.example", content={}) + + def test_identity_server(self) -> None: + self._check("POST", "/_matrix/identity/v2/x", None, destination_is="is.example") + + def test_signature_verifies(self) -> None: + content = {"key": "value"} + header = build_auth_header_for_test( + self.hs.signing_key, + self.hs.hostname, + "PUT", + "/p", + "remote.example", + None, + content, + ) + params = dict( + part.split("=", 1) for part in header.removeprefix("X-Matrix ").split(",") + ) + params = {k: v.strip('"') for k, v in params.items()} + + signed = { + "method": "PUT", + "uri": "/p", + "origin": self.hs.hostname, + "destination": "remote.example", + "content": content, + "signatures": {self.hs.hostname: {params["key"]: params["sig"]}}, + } + verify_key = get_verify_key(self.hs.signing_key) + verify_signed_json(signed, self.hs.hostname, verify_key)