From ee371f45ff04128959db9acd71eb87b0e6b51567 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Mon, 15 Jun 2026 14:43:37 -0700 Subject: [PATCH 1/4] ingest-router: Add relay signing and verification logic `RelaySigner` loads synapse's own ed25519 credentials.json and re-signs rewritten bodies with a fresh signature, so the upstream accepts them as synapse's own trusted traffic. Accepts both the 32-byte seed and 64-byte keypair secret-key encodings that relay's SecretKey supports. `RelayVerifier` authenticates inbound requests against a statically configured set of trusted downstream relays (new `relay_keys` config). This functionality is needed by the project-configs endpoint which rewrites the request body to fan requests across cells, invalidating the inbound relay signature. This change adds the new auth module helpers. Actually wiring it into the ingest-router, and loading the keys from config will be added as a follow up. --- Cargo.lock | 5 + Cargo.toml | 1 + example_config_ingest_router.yaml | 12 + ingest-router/Cargo.toml | 3 + ingest-router/src/auth.rs | 544 ++++++++++++++++++++++++++++++ ingest-router/src/lib.rs | 1 + locator/Cargo.toml | 2 +- 7 files changed, 567 insertions(+), 1 deletion(-) create mode 100644 ingest-router/src/auth.rs diff --git a/Cargo.lock b/Cargo.lock index ff8a9c3..79ecce4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -487,8 +487,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2" dependencies = [ "iana-time-zone", + "js-sys", "num-traits", "serde", + "wasm-bindgen", "windows-link 0.2.1", ] @@ -1696,6 +1698,9 @@ name = "ingest-router" version = "0.1.0" dependencies = [ "async-trait", + "base64", + "chrono", + "ed25519-dalek", "http 1.3.1", "http-body-util", "hyper", diff --git a/Cargo.toml b/Cargo.toml index ee270f2..0133aa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,6 +5,7 @@ default-members = ["synapse"] [workspace.dependencies] async-trait = "0.1.89" +base64 = "0.22.1" http = "1.3.1" http-body-util = "0.1.3" hyper = { version = "1.7.0", features = ["full"] } diff --git a/example_config_ingest_router.yaml b/example_config_ingest_router.yaml index 6d6c14c..15591e8 100644 --- a/example_config_ingest_router.yaml +++ b/example_config_ingest_router.yaml @@ -6,6 +6,18 @@ ingest_router: host: "0.0.0.0" port: 3001 + + # relay_keys: + # Verified downstream Relays (POPs) can be configured here, as a map of relay id to relay + # info. + # The relay id (the map key) must be a valid UUIDv4 string. This is the same ID that the + # Relay uses in its `X-Sentry-Relay-Id` header, and is used by Sentry to identify the relay. + # `public_key` must be the base64url-nopad encoding of the relay's 32 byte ed25519 public + # key (the `public_key` field of its credentials.json). This is the same value configured + # for the relay in the upstream's `static_relays`. + # "00000000-0000-0000-0000-000000000000": + # public_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" + # Locator service configuration for routing public keys to cells locator: type: in_process diff --git a/ingest-router/Cargo.toml b/ingest-router/Cargo.toml index 0bd4abd..4e3b98c 100644 --- a/ingest-router/Cargo.toml +++ b/ingest-router/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" [dependencies] async-trait = { workspace = true } +base64 = { workspace = true } +chrono = { version = "0.4", features = ["clock", "serde"] } +ed25519-dalek = "2" http = { workspace = true } http-body-util = { workspace = true } hyper = { workspace = true } diff --git a/ingest-router/src/auth.rs b/ingest-router/src/auth.rs new file mode 100644 index 0000000..fb25aee --- /dev/null +++ b/ingest-router/src/auth.rs @@ -0,0 +1,544 @@ +//! The ingest-router authenticates as an internal Relay: it owns an ed25519 keypair and a +//! relay id (from a `credentials.json`). Most forwarded requests are a transparent pass-through — +//! synapse leaves the inbound `X-Sentry-Relay-Id` / `X-Sentry-Relay-Signature` untouched and +//! the upstream verifies the originating relay directly. +//! +//! The exception is the project-configs endpoint: Synapse rewrites the body to fan keys out +//! across cells, which invalidates the inbound signature. In this scenario it re-signs each +//! rewritten body with its own credentials. This module exists to support this use case. +//! +//! ([`RelaySigner`]) is responsible for re-signing requests with Synapse's credentials. +//! Once synapse re-signs, the upstream accepts the request as Synapse's own trusted traffic. +//! [`RelayVerifier`] checks the inbound signature against a configured set of trusted downstream relays. +//! +//! Signing and verification follow relay-auth's scheme: +//! - `X-Sentry-Relay-Id` contains the Relay ID +//! - `X-Sentry-Relay-Signature` is an ed25519 signature over the request body (plus an embedded timestamp) +//! +//! On verify the timestamp is required: a signature is rejected if its timestamp is +//! missing, stale (older than 5 minutes), or future-dated. This matches relay-auth's +//! scheme after https://github.com/getsentry/relay/pull/6069. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use hyper::header::{HeaderMap, HeaderName, HeaderValue}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; + +/// Signature freshness window, matching Sentry +/// https://github.com/getsentry/sentry/blob/c9138b328e9aad58f95f087c0f8a8843a06dbbe9/src/sentry/api/authentication.py#L260 +const SIGNATURE_MAX_AGE_SECS: i64 = 300; + +/// The `relay-auth` signature header, carried (base64url-encoded) inside the signature value. +#[derive(Debug, Serialize, Deserialize)] +struct SignatureHeader { + /// When the payload was signed. + #[serde(rename = "t")] + timestamp: chrono::DateTime, + /// relay-auth's signature algorithm (`a`), captured only so we can reject anything other + /// than the default `Regular` (`v0`) scheme — which is the only algorithm synapse verifies. + /// Synapse never emits it (an absent `a` means `Regular`), so it's skipped on serialize. + #[serde(rename = "a", default, skip_serializing_if = "Option::is_none")] + signature_algorithm: Option, +} + +/// Header carrying the relay id (a UUID) identifying the signing relay. +pub static RELAY_ID_HEADER: HeaderName = HeaderName::from_static("x-sentry-relay-id"); +/// Header carrying the request body signature. +pub static RELAY_SIGNATURE_HEADER: HeaderName = HeaderName::from_static("x-sentry-relay-signature"); + +#[derive(thiserror::Error, Debug)] +pub enum SigningError { + #[error("could not read credentials file: {0}")] + Io(#[from] std::io::Error), + #[error("could not parse credentials file: {0}")] + Parse(#[from] serde_json::Error), + #[error("invalid secret_key encoding")] + BadKeyEncoding, + #[error("invalid secret_key length: expected 32 or 64 bytes, got {0}")] + BadKeyLength(usize), +} + +/// Relay credentials, matching the `credentials.json` produced by `relay credentials generate`. +#[derive(Debug, Deserialize)] +struct Credentials { + secret_key: String, + // Retained for completeness/diagnostics; the public key lives in the upstream's + // `static_relays` config and is not needed to sign. + #[allow(dead_code)] + public_key: String, + id: String, +} + +/// Signs outgoing requests with synapse's relay credentials. +#[derive(Clone)] +pub struct RelaySigner { + signing_key: SigningKey, + relay_id: HeaderValue, +} + +impl RelaySigner { + /// Loads relay credentials from a `relay credentials generate`-style `credentials.json`. + pub fn from_file(path: &Path) -> Result { + let contents = std::fs::read(path)?; + let credentials: Credentials = serde_json::from_slice(&contents)?; + Self::from_credentials(credentials) + } + + fn from_credentials(credentials: Credentials) -> Result { + let bytes = URL_SAFE_NO_PAD + .decode(credentials.secret_key.as_bytes()) + .map_err(|_| SigningError::BadKeyEncoding)?; + + // Relay's SecretKey accepts either a 64-byte keypair or a 32-byte seed so we support both too + // https://github.com/getsentry/relay/blame/9bfa40d9ea1d5a9225a7332e19d81f4a9b096a21/relay-auth/src/lib.rs#L298-L305 + let signing_key = if let Ok(keypair) = <[u8; 64]>::try_from(bytes.as_slice()) { + SigningKey::from_keypair_bytes(&keypair).map_err(|_| SigningError::BadKeyEncoding)? + } else if let Ok(seed) = <[u8; 32]>::try_from(bytes.as_slice()) { + SigningKey::from_bytes(&seed) + } else { + return Err(SigningError::BadKeyLength(bytes.len())); + }; + + let relay_id = HeaderValue::from_str(&credentials.id) + .map_err(|_| SigningError::BadKeyEncoding) + .map(|mut v| { + v.set_sensitive(false); + v + })?; + + Ok(Self { + signing_key, + relay_id, + }) + } + + /// Computes the `X-Sentry-Relay-Signature` value for `body`. + /// + /// The signature is stamped with the current time, matching relay-auth: each hop re-signs + /// with its own fresh timestamp rather than carrying the inbound request's age forward. + fn sign_body(&self, body: &[u8]) -> String { + let header = SignatureHeader { + timestamp: chrono::Utc::now(), + signature_algorithm: None, + }; + let header_json = serde_json::to_vec(&header).expect("SignatureHeader serializes"); + + let mut message = header_json.clone(); + message.push(b'\x00'); + message.extend_from_slice(body); + let signature = self.signing_key.sign(&message); + + let mut value = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + value.push('.'); + value.push_str(&URL_SAFE_NO_PAD.encode(&header_json)); + value + } + + /// Replaces any inbound relay-auth headers with synapse's relay id and a fresh + /// signature over `body`. + pub fn sign_request(&self, headers: &mut HeaderMap, body: &[u8]) { + let signature = HeaderValue::from_str(&self.sign_body(body)) + .expect("base64 signature is always a valid header value"); + + headers.insert(RELAY_ID_HEADER.clone(), self.relay_id.clone()); + headers.insert(RELAY_SIGNATURE_HEADER.clone(), signature); + } +} + +#[derive(thiserror::Error, Debug, PartialEq, Eq)] +pub enum VerifyError { + #[error("invalid trusted relay public key for {0}")] + InvalidPublicKey(String), + #[error("missing {} header", RELAY_ID_HEADER.as_str())] + MissingRelayId, + #[error("missing {} header", RELAY_SIGNATURE_HEADER.as_str())] + MissingSignature, + #[error("relay {0} is not a trusted relay")] + UntrustedRelay(String), + #[error("signature verification failed")] + BadSignature, + #[error("signature has expired")] + Expired, + #[error("unsupported signature algorithm: {0}")] + UnsupportedAlgorithm(String), +} + +/// Configuration for a single trusted downstream relay, matching the upstream's +/// `static_relays` entry shape. +#[derive(Debug, Clone, Deserialize)] +pub struct RelayInfo { + /// base64url-nopad encoding of the relay's 32-byte ed25519 public key. + pub public_key: String, +} + +/// Verifies inbound requests against a configured set of trusted downstream relays. +/// +/// Synapse re-signs forwarded requests with its own (upstream-trusted) credentials, so it +/// must authenticate the caller first. Only relays whose public key is configured here are +/// allowed; anyone else is rejected before their request is re-signed. +/// +/// The trusted set is fixed and small, so keys are configured statically rather than resolved +/// at runtime via Sentry's `publickeys` endpoint (the mechanism relay-to-relay verification uses +/// for a dynamic relay set). +#[derive(Clone, Default)] +pub struct RelayVerifier { + /// Trusted downstream relays, keyed by relay id (a UUID). + trusted_relays: HashMap, +} + +impl RelayVerifier { + /// Builds a verifier from a `relay_id -> RelayInfo` map (the upstream's `static_relays` + /// equivalent). + pub fn from_relays(relays: HashMap) -> Result { + let trusted_relays = relays + .into_iter() + .map(|(id, info)| Ok((id.clone(), parse_public_key(&info.public_key, &id)?))) + .collect::>()?; + Ok(Self { trusted_relays }) + } + + /// Verifies the `X-Sentry-Relay-Id` / `X-Sentry-Relay-Signature` headers against `body`. + /// + /// Mirrors `relay-auth`'s `unpack`: the signature is checked against the relay's public + /// key and the embedded timestamp must lie within the freshness window (neither older + /// than `SIGNATURE_MAX_AGE_SECS` nor in the future). + pub fn verify_request(&self, headers: &HeaderMap, body: &[u8]) -> Result<(), VerifyError> { + let relay_id = headers + .get(&RELAY_ID_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or(VerifyError::MissingRelayId)?; + let signature = headers + .get(&RELAY_SIGNATURE_HEADER) + .and_then(|v| v.to_str().ok()) + .ok_or(VerifyError::MissingSignature)?; + + let key = self + .trusted_relays + .get(relay_id) + .ok_or_else(|| VerifyError::UntrustedRelay(relay_id.to_string()))?; + + // `relay-auth` signature value is `base64url(sig).base64url(header_json)`. + let (sig_b64, header_b64) = signature.split_once('.').ok_or(VerifyError::BadSignature)?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(sig_b64) + .map_err(|_| VerifyError::BadSignature)?; + let signature = Signature::from_slice(&sig_bytes).map_err(|_| VerifyError::BadSignature)?; + let header_json = URL_SAFE_NO_PAD + .decode(header_b64) + .map_err(|_| VerifyError::BadSignature)?; + + // Parse the header before verifying so an unsupported algorithm produces a clear error. + // A header without a timestamp fails to parse and is rejected as a bad signature. + let header: SignatureHeader = + serde_json::from_slice(&header_json).map_err(|_| VerifyError::BadSignature)?; + + // Synapse only produces and verifies the default `Regular` (`v0`) algorithm. Reject any + // other algorithm up front: `key.verify` below only checks `Regular` signatures, so a + // prehashed (`v1`) or future signature would otherwise fail as an opaque mismatch. + if let Some(algo) = header.signature_algorithm.as_deref() { + if algo != "v0" { + return Err(VerifyError::UnsupportedAlgorithm(algo.to_string())); + } + } + + let mut message = header_json.clone(); + message.push(b'\x00'); + message.extend_from_slice(body); + key.verify(&message, &signature) + .map_err(|_| VerifyError::BadSignature)?; + + // Reject stale and future-dated signatures (replay protection), matching relay-auth's + // `is_valid_time`: the timestamp must lie within [now - max_age, now]. + let age = chrono::Utc::now() - header.timestamp; + if age < chrono::Duration::zero() + || age > chrono::Duration::seconds(SIGNATURE_MAX_AGE_SECS) + { + return Err(VerifyError::Expired); + } + + Ok(()) + } +} + +/// Parses a base64url-nopad ed25519 public key, as found in `static_relays` config. +fn parse_public_key(key: &str, relay_id: &str) -> Result { + let err = || VerifyError::InvalidPublicKey(relay_id.to_string()); + let bytes = URL_SAFE_NO_PAD.decode(key).map_err(|_| err())?; + let array: [u8; 32] = bytes.as_slice().try_into().map_err(|_| err())?; + VerifyingKey::from_bytes(&array).map_err(|_| err()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Verifier, VerifyingKey}; + + // A credentials.json as produced by `relay credentials generate`: the `secret_key` is the + // 32-byte seed, which is what relay's `SecretKey` serializes by default. (The 64-byte keypair + // form is also accepted on load; see `accepts_64_byte_keypair_form`.) + fn test_credentials() -> (Credentials, VerifyingKey) { + let signing_key = SigningKey::from_bytes(&[7u8; 32]); + let verifying_key = signing_key.verifying_key(); + let credentials = Credentials { + secret_key: URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), + public_key: URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()), + id: "00000000-0000-0000-0000-000000000000".to_string(), + }; + (credentials, verifying_key) + } + + /// Reconstructs the signed message from a signature value and verifies it, mirroring + /// what Sentry's `unpack` does on the receiving end. + fn verify(verifying_key: &VerifyingKey, body: &[u8], value: &str) -> bool { + let (sig_b64, header_b64) = value.split_once('.').expect("signature has header part"); + let sig_bytes = URL_SAFE_NO_PAD.decode(sig_b64).unwrap(); + let header_json = URL_SAFE_NO_PAD.decode(header_b64).unwrap(); + + let mut message = header_json; + message.push(b'\x00'); + message.extend_from_slice(body); + + let signature = ed25519_dalek::Signature::from_slice(&sig_bytes).unwrap(); + verifying_key.verify(&message, &signature).is_ok() + } + + #[test] + fn signs_and_verifies() { + let (credentials, verifying_key) = test_credentials(); + let signer = RelaySigner::from_credentials(credentials).unwrap(); + + let body = br#"{"publicKeys":["abc"]}"#; + let value = signer.sign_body(body); + + assert!(verify(&verifying_key, body, &value)); + // A different body must not verify against the same signature. + assert!(!verify(&verifying_key, b"tampered", &value)); + } + + #[test] + fn sign_request_replaces_inbound_headers() { + let (credentials, verifying_key) = test_credentials(); + let signer = RelaySigner::from_credentials(credentials).unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static("inbound-relay")); + headers.insert( + RELAY_SIGNATURE_HEADER.clone(), + HeaderValue::from_static("stale-signature"), + ); + + let body = br#"{"publicKeys":["key1"]}"#; + signer.sign_request(&mut headers, body); + + assert_eq!( + headers.get(&RELAY_ID_HEADER).unwrap(), + "00000000-0000-0000-0000-000000000000" + ); + let value = headers.get(&RELAY_SIGNATURE_HEADER).unwrap().to_str().unwrap(); + assert!(verify(&verifying_key, body, value)); + } + + #[test] + fn accepts_64_byte_keypair_form() { + // relay also accepts the expanded 64-byte keypair encoding (`SecretKey`'s alternate + // `{:#}` form), so `from_credentials` must load it too. + let signing_key = SigningKey::from_bytes(&[3u8; 32]); + let credentials = Credentials { + secret_key: URL_SAFE_NO_PAD.encode(signing_key.to_keypair_bytes()), + public_key: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), + id: "11111111-1111-1111-1111-111111111111".to_string(), + }; + + let signer = RelaySigner::from_credentials(credentials).unwrap(); + let body = b"body"; + assert!(verify( + &signing_key.verifying_key(), + body, + &signer.sign_body(body) + )); + } + + #[test] + fn rejects_bad_key_length() { + let credentials = Credentials { + secret_key: URL_SAFE_NO_PAD.encode([0u8; 16]), + public_key: String::new(), + id: "id".to_string(), + }; + assert!(matches!( + RelaySigner::from_credentials(credentials), + Err(SigningError::BadKeyLength(16)) + )); + } + + const DOWNSTREAM_ID: &str = "00000000-0000-0000-0000-000000000000"; + + /// Builds a signer plus a verifier that trusts that signer's relay id + public key. + fn signer_and_verifier() -> (RelaySigner, RelayVerifier) { + let (credentials, verifying_key) = test_credentials(); + let signer = RelaySigner::from_credentials(credentials).unwrap(); + let verifier = RelayVerifier::from_relays(HashMap::from([( + DOWNSTREAM_ID.to_string(), + RelayInfo { + public_key: URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()), + }, + )])) + .unwrap(); + (signer, verifier) + } + + fn signed_headers(signer: &RelaySigner, body: &[u8]) -> HeaderMap { + let mut headers = HeaderMap::new(); + signer.sign_request(&mut headers, body); + headers + } + + #[test] + fn verifies_a_signed_request() { + let (signer, verifier) = signer_and_verifier(); + let body = br#"{"publicKeys":["key1"]}"#; + let headers = signed_headers(&signer, body); + assert_eq!(verifier.verify_request(&headers, body), Ok(())); + } + + #[test] + fn rejects_tampered_body() { + let (signer, verifier) = signer_and_verifier(); + let headers = signed_headers(&signer, br#"{"publicKeys":["key1"]}"#); + assert_eq!( + verifier.verify_request(&headers, b"tampered"), + Err(VerifyError::BadSignature) + ); + } + + #[test] + fn rejects_untrusted_relay() { + let (signer, _) = signer_and_verifier(); + let verifier = RelayVerifier::default(); // trusts nobody + let body = b"body"; + let headers = signed_headers(&signer, body); + assert_eq!( + verifier.verify_request(&headers, body), + Err(VerifyError::UntrustedRelay(DOWNSTREAM_ID.to_string())) + ); + } + + #[test] + fn rejects_missing_headers() { + let (_, verifier) = signer_and_verifier(); + assert_eq!( + verifier.verify_request(&HeaderMap::new(), b"body"), + Err(VerifyError::MissingRelayId) + ); + + let mut only_id = HeaderMap::new(); + only_id.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static(DOWNSTREAM_ID)); + assert_eq!( + verifier.verify_request(&only_id, b"body"), + Err(VerifyError::MissingSignature) + ); + } + + /// Produces a genuinely-signed request over caller-supplied header JSON, bypassing + /// `sign_body` (which always stamps a fresh timestamp and never sets an algorithm). + /// The signature is real and verifies fine; only the header content is chosen by the + /// caller, so tests can drive the timestamp/algorithm guards (which run *after* + /// signature verification) in isolation. + fn sign_raw_header(signer: &RelaySigner, header_json: &[u8], body: &[u8]) -> HeaderMap { + let mut message = header_json.to_vec(); + message.push(b'\x00'); + message.extend_from_slice(body); + let sig = signer.signing_key.sign(&message); + let value = format!( + "{}.{}", + URL_SAFE_NO_PAD.encode(sig.to_bytes()), + URL_SAFE_NO_PAD.encode(header_json) + ); + + let mut headers = HeaderMap::new(); + headers.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static(DOWNSTREAM_ID)); + headers.insert(RELAY_SIGNATURE_HEADER.clone(), HeaderValue::from_str(&value).unwrap()); + headers + } + + /// Serializes a `SignatureHeader` with the given timestamp (no algorithm). + fn header_with_timestamp(timestamp: chrono::DateTime) -> Vec { + serde_json::to_vec(&SignatureHeader { + timestamp, + signature_algorithm: None, + }) + .unwrap() + } + + #[test] + fn rejects_expired_signature() { + let (signer, verifier) = signer_and_verifier(); + let body = b"body"; + let stale = chrono::Utc::now() - chrono::Duration::seconds(SIGNATURE_MAX_AGE_SECS + 60); + let headers = sign_raw_header(&signer, &header_with_timestamp(stale), body); + + assert_eq!( + verifier.verify_request(&headers, body), + Err(VerifyError::Expired) + ); + } + + #[test] + fn rejects_future_signature() { + let (signer, verifier) = signer_and_verifier(); + let body = b"body"; + let future = chrono::Utc::now() + chrono::Duration::seconds(60); + let headers = sign_raw_header(&signer, &header_with_timestamp(future), body); + + assert_eq!( + verifier.verify_request(&headers, body), + Err(VerifyError::Expired) + ); + } + + #[test] + fn rejects_missing_timestamp() { + let (signer, verifier) = signer_and_verifier(); + let body = b"body"; + // A validly-signed header with no `t` field must not bypass the freshness check. + let headers = sign_raw_header(&signer, b"{}", body); + + assert_eq!( + verifier.verify_request(&headers, body), + Err(VerifyError::BadSignature) + ); + } + + #[test] + fn rejects_unsupported_algorithm() { + let (signer, verifier) = signer_and_verifier(); + let body = b"body"; + // A validly-signed header requesting the prehashed (`v1`) algorithm, which synapse does + // not implement, must be rejected with a clear error rather than an opaque mismatch. + let header_json = serde_json::to_vec(&serde_json::json!({ + "t": chrono::Utc::now(), + "a": "v1", + })) + .unwrap(); + let headers = sign_raw_header(&signer, &header_json, body); + + assert_eq!( + verifier.verify_request(&headers, body), + Err(VerifyError::UnsupportedAlgorithm("v1".to_string())) + ); + } + + #[test] + fn from_relays_rejects_bad_key() { + let result = RelayVerifier::from_relays(HashMap::from([( + "relay-x".to_string(), + RelayInfo { + public_key: "not-valid-base64-key!!".to_string(), + }, + )])); + assert_eq!(result.err(), Some(VerifyError::InvalidPublicKey("relay-x".to_string()))); + } +} diff --git a/ingest-router/src/lib.rs b/ingest-router/src/lib.rs index 69a110e..10a24bc 100644 --- a/ingest-router/src/lib.rs +++ b/ingest-router/src/lib.rs @@ -1,4 +1,5 @@ pub mod api; +pub mod auth; pub mod config; pub mod errors; mod executor; diff --git a/locator/Cargo.toml b/locator/Cargo.toml index d4242b4..5f70dc1 100644 --- a/locator/Cargo.toml +++ b/locator/Cargo.toml @@ -6,7 +6,7 @@ edition = "2024" [dependencies] async-trait = { workspace = true } axum = "0.8.4" -base64 = "0.22.1" +base64 = { workspace = true } bincode = { version = "2.0.1", features = ["std", "serde"] } bytes = "1.9.0" flate2 = "1.1.5" From dd2449a121d1df8edb13ae114bad0da256d1b3e2 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Mon, 15 Jun 2026 14:51:19 -0700 Subject: [PATCH 2/4] format and lint --- ingest-router/src/auth.rs | 42 ++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 12 deletions(-) diff --git a/ingest-router/src/auth.rs b/ingest-router/src/auth.rs index fb25aee..220432e 100644 --- a/ingest-router/src/auth.rs +++ b/ingest-router/src/auth.rs @@ -238,10 +238,10 @@ impl RelayVerifier { // Synapse only produces and verifies the default `Regular` (`v0`) algorithm. Reject any // other algorithm up front: `key.verify` below only checks `Regular` signatures, so a // prehashed (`v1`) or future signature would otherwise fail as an opaque mismatch. - if let Some(algo) = header.signature_algorithm.as_deref() { - if algo != "v0" { - return Err(VerifyError::UnsupportedAlgorithm(algo.to_string())); - } + if let Some(algo) = header.signature_algorithm.as_deref() + && algo != "v0" + { + return Err(VerifyError::UnsupportedAlgorithm(algo.to_string())); } let mut message = header_json.clone(); @@ -253,8 +253,7 @@ impl RelayVerifier { // Reject stale and future-dated signatures (replay protection), matching relay-auth's // `is_valid_time`: the timestamp must lie within [now - max_age, now]. let age = chrono::Utc::now() - header.timestamp; - if age < chrono::Duration::zero() - || age > chrono::Duration::seconds(SIGNATURE_MAX_AGE_SECS) + if age < chrono::Duration::zero() || age > chrono::Duration::seconds(SIGNATURE_MAX_AGE_SECS) { return Err(VerifyError::Expired); } @@ -324,7 +323,10 @@ mod tests { let signer = RelaySigner::from_credentials(credentials).unwrap(); let mut headers = HeaderMap::new(); - headers.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static("inbound-relay")); + headers.insert( + RELAY_ID_HEADER.clone(), + HeaderValue::from_static("inbound-relay"), + ); headers.insert( RELAY_SIGNATURE_HEADER.clone(), HeaderValue::from_static("stale-signature"), @@ -337,7 +339,11 @@ mod tests { headers.get(&RELAY_ID_HEADER).unwrap(), "00000000-0000-0000-0000-000000000000" ); - let value = headers.get(&RELAY_SIGNATURE_HEADER).unwrap().to_str().unwrap(); + let value = headers + .get(&RELAY_SIGNATURE_HEADER) + .unwrap() + .to_str() + .unwrap(); assert!(verify(&verifying_key, body, value)); } @@ -435,7 +441,10 @@ mod tests { ); let mut only_id = HeaderMap::new(); - only_id.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static(DOWNSTREAM_ID)); + only_id.insert( + RELAY_ID_HEADER.clone(), + HeaderValue::from_static(DOWNSTREAM_ID), + ); assert_eq!( verifier.verify_request(&only_id, b"body"), Err(VerifyError::MissingSignature) @@ -459,8 +468,14 @@ mod tests { ); let mut headers = HeaderMap::new(); - headers.insert(RELAY_ID_HEADER.clone(), HeaderValue::from_static(DOWNSTREAM_ID)); - headers.insert(RELAY_SIGNATURE_HEADER.clone(), HeaderValue::from_str(&value).unwrap()); + headers.insert( + RELAY_ID_HEADER.clone(), + HeaderValue::from_static(DOWNSTREAM_ID), + ); + headers.insert( + RELAY_SIGNATURE_HEADER.clone(), + HeaderValue::from_str(&value).unwrap(), + ); headers } @@ -539,6 +554,9 @@ mod tests { public_key: "not-valid-base64-key!!".to_string(), }, )])); - assert_eq!(result.err(), Some(VerifyError::InvalidPublicKey("relay-x".to_string()))); + assert_eq!( + result.err(), + Some(VerifyError::InvalidPublicKey("relay-x".to_string())) + ); } } From fa6e6b22cb14c3353a34d400e9b193449d1e508e Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Mon, 15 Jun 2026 17:12:41 -0700 Subject: [PATCH 3/4] fix link to relay blob --- ingest-router/src/auth.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ingest-router/src/auth.rs b/ingest-router/src/auth.rs index 220432e..65d626e 100644 --- a/ingest-router/src/auth.rs +++ b/ingest-router/src/auth.rs @@ -93,7 +93,7 @@ impl RelaySigner { .map_err(|_| SigningError::BadKeyEncoding)?; // Relay's SecretKey accepts either a 64-byte keypair or a 32-byte seed so we support both too - // https://github.com/getsentry/relay/blame/9bfa40d9ea1d5a9225a7332e19d81f4a9b096a21/relay-auth/src/lib.rs#L298-L305 + // https://github.com/getsentry/relay/blob/0aac0fc04f8b2e1c834385bb4765380cdf63e138/relay-auth/src/lib.rs#L298-L303 let signing_key = if let Ok(keypair) = <[u8; 64]>::try_from(bytes.as_slice()) { SigningKey::from_keypair_bytes(&keypair).map_err(|_| SigningError::BadKeyEncoding)? } else if let Ok(seed) = <[u8; 32]>::try_from(bytes.as_slice()) { From a18229c641a7eef8be2d576d9f0eef31b1154858 Mon Sep 17 00:00:00 2001 From: Lyn Nagara Date: Mon, 15 Jun 2026 17:32:31 -0700 Subject: [PATCH 4/4] remove public key --- ingest-router/src/auth.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/ingest-router/src/auth.rs b/ingest-router/src/auth.rs index 65d626e..1fbf3cb 100644 --- a/ingest-router/src/auth.rs +++ b/ingest-router/src/auth.rs @@ -65,10 +65,6 @@ pub enum SigningError { #[derive(Debug, Deserialize)] struct Credentials { secret_key: String, - // Retained for completeness/diagnostics; the public key lives in the upstream's - // `static_relays` config and is not needed to sign. - #[allow(dead_code)] - public_key: String, id: String, } @@ -283,7 +279,6 @@ mod tests { let verifying_key = signing_key.verifying_key(); let credentials = Credentials { secret_key: URL_SAFE_NO_PAD.encode(signing_key.to_bytes()), - public_key: URL_SAFE_NO_PAD.encode(verifying_key.to_bytes()), id: "00000000-0000-0000-0000-000000000000".to_string(), }; (credentials, verifying_key) @@ -354,7 +349,6 @@ mod tests { let signing_key = SigningKey::from_bytes(&[3u8; 32]); let credentials = Credentials { secret_key: URL_SAFE_NO_PAD.encode(signing_key.to_keypair_bytes()), - public_key: URL_SAFE_NO_PAD.encode(signing_key.verifying_key().to_bytes()), id: "11111111-1111-1111-1111-111111111111".to_string(), }; @@ -371,7 +365,6 @@ mod tests { fn rejects_bad_key_length() { let credentials = Credentials { secret_key: URL_SAFE_NO_PAD.encode([0u8; 16]), - public_key: String::new(), id: "id".to_string(), }; assert!(matches!(