From 489c09175db258ccaa3cb3a3e2b8edc79ab02940 Mon Sep 17 00:00:00 2001 From: Erik Johnston Date: Thu, 15 Oct 2026 11:20:41 +0000 Subject: [PATCH] Sign federation requests in Rust Add `rust/src/federation`, starting with the `X-Matrix` request signature and the transport seam the request layer will send through. The signing code comes from matrix-hyper-federation-client (Apache-2.0), changed to use Synapse's canonical JSON serializer, Python's header layout and quoting, and `destination_is` for identity servers. It keeps that crate's signature test vectors. The body is serialized to canonical JSON once, then both signed and sent in that form. The homeserver's signing key crosses to Rust as its algorithm, version and seed, taken from `hs.signing_key`. Rust never reads the key file. `FederationTransport` is what the request layer will send through. `PyTransport` implements it with a Python callable, which is how Rust level tests will inject responses in place of `FakeTransport`. A trial test checks the Rust header is byte-identical to the one `MatrixFederationHttpClient.build_auth_headers` builds. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R5b7Ruy4cgt27Mjq6Ptxbb --- Cargo.lock | 111 +++++- rust/Cargo.toml | 3 + rust/src/federation/mod.rs | 90 +++++ rust/src/federation/signing.rs | 342 ++++++++++++++++++ rust/src/federation/transport.rs | 252 +++++++++++++ rust/src/homeserver.rs | 15 + rust/src/lib.rs | 2 + synapse/synapse_rust/federation.pyi | 28 ++ tests/synapse_rust/test_federation_signing.py | 104 ++++++ 9 files changed, 940 insertions(+), 7 deletions(-) create mode 100644 rust/src/federation/mod.rs create mode 100644 rust/src/federation/signing.rs create mode 100644 rust/src/federation/transport.rs create mode 100644 synapse/synapse_rust/federation.pyi create mode 100644 tests/synapse_rust/test_federation_signing.py 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)