diff --git a/example_config_ingest_router.yaml b/example_config_ingest_router.yaml index 15591e8..7b93ea2 100644 --- a/example_config_ingest_router.yaml +++ b/example_config_ingest_router.yaml @@ -7,7 +7,7 @@ ingest_router: port: 3001 - # relay_keys: + 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 diff --git a/ingest-router/src/api/project_config.rs b/ingest-router/src/api/project_config.rs index 4826174..79e7eb6 100644 --- a/ingest-router/src/api/project_config.rs +++ b/ingest-router/src/api/project_config.rs @@ -402,6 +402,10 @@ impl Handler for ProjectConfigsHandler { fn execution_mode(&self) -> ExecutionMode { ExecutionMode::Parallel } + + fn requires_relay_auth(&self) -> bool { + true + } async fn split_request( &self, request: Request, diff --git a/ingest-router/src/auth.rs b/ingest-router/src/auth.rs index 38d6238..cd5edcf 100644 --- a/ingest-router/src/auth.rs +++ b/ingest-router/src/auth.rs @@ -26,6 +26,7 @@ use hyper::header::{HeaderMap, HeaderName, HeaderValue}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; +use std::sync::Arc; /// Signature freshness window, matching Sentry /// https://github.com/getsentry/sentry/blob/c9138b328e9aad58f95f087c0f8a8843a06dbbe9/src/sentry/api/authentication.py#L260 @@ -184,7 +185,7 @@ pub enum VerifyError { /// Configuration for a single trusted downstream relay, matching the upstream's /// `static_relays` entry shape. -#[derive(Debug, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, PartialEq)] pub struct RelayInfo { /// base64url-nopad encoding of the relay's 32-byte ed25519 public key. pub public_key: String, @@ -202,7 +203,7 @@ pub struct RelayInfo { #[derive(Clone, Default)] pub struct RelayVerifier { /// Trusted downstream relays, keyed by relay id (a UUID). - trusted_relays: HashMap, + trusted_relays: Arc>, } impl RelayVerifier { @@ -212,8 +213,10 @@ impl RelayVerifier { let trusted_relays = relays .into_iter() .map(|(id, info)| Ok((id.clone(), parse_public_key(&info.public_key, &id)?))) - .collect::>()?; - Ok(Self { trusted_relays }) + .collect::, VerifyError>>()?; + Ok(Self { + trusted_relays: Arc::new(trusted_relays), + }) } /// Verifies the `X-Sentry-Relay-Id` / `X-Sentry-Relay-Signature` headers against `body`. diff --git a/ingest-router/src/config.rs b/ingest-router/src/config.rs index b912279..cff2c2d 100644 --- a/ingest-router/src/config.rs +++ b/ingest-router/src/config.rs @@ -1,3 +1,4 @@ +use crate::auth::RelayInfo; use locator::client::{LocatorConfig as ClientLocatorConfig, LocatorType as ClientLocatorType}; use locator::config::{BackupRouteStore, ControlPlane, LocatorDataType}; use serde::Deserialize; @@ -187,6 +188,8 @@ pub struct Config { /// Timeout configuration for relay handlers #[serde(default)] pub relay_timeouts: RelayTimeouts, + /// Trusted downstream relay public keys, keyed by relay id + pub relay_keys: HashMap, } impl Config { @@ -349,6 +352,7 @@ routes: action: handler: health locality: us +relay_keys: "#; let config: Config = serde_yaml::from_str(yaml).unwrap(); @@ -386,6 +390,7 @@ routes: }], )]), relay_timeouts: RelayTimeouts::default(), + relay_keys: HashMap::new(), routes: vec![Route { r#match: Match { path: Some("/api/".to_string()), diff --git a/ingest-router/src/errors.rs b/ingest-router/src/errors.rs index 5bc4078..09145dd 100644 --- a/ingest-router/src/errors.rs +++ b/ingest-router/src/errors.rs @@ -51,4 +51,7 @@ pub enum IngestRouterError { #[error("Serde error: {0}")] SerdeError(#[from] serde_json::Error), + + #[error("Relay verifier configuration error: {0}")] + RelayVerifierError(#[from] crate::auth::VerifyError), } diff --git a/ingest-router/src/handler.rs b/ingest-router/src/handler.rs index c4bd9c7..5856ae1 100644 --- a/ingest-router/src/handler.rs +++ b/ingest-router/src/handler.rs @@ -27,6 +27,12 @@ pub trait Handler: Send + Sync { fn execution_mode(&self) -> ExecutionMode; + /// Whether this handler participates in synapse's relay auth: verifying the inbound + /// signature and re-signing the outbound request with synapse's own credentials. + fn requires_relay_auth(&self) -> bool { + false + } + /// Split one request into multiple per-cell requests /// /// This method routes the request data to appropriate cells and builds diff --git a/ingest-router/src/ingest_router_service.rs b/ingest-router/src/ingest_router_service.rs index d371e95..d791e14 100644 --- a/ingest-router/src/ingest_router_service.rs +++ b/ingest-router/src/ingest_router_service.rs @@ -1,3 +1,4 @@ +use crate::auth; use crate::config; use crate::errors::IngestRouterError; use crate::executor; @@ -22,12 +23,21 @@ static INFLIGHT: AtomicU64 = AtomicU64::new(0); pub struct IngestRouterService { router: router::Router, executor: executor::Executor, + verifier: auth::RelayVerifier, } impl IngestRouterService { - pub fn new(router: router::Router, timeouts: config::RelayTimeouts) -> Self { + pub fn new( + router: router::Router, + timeouts: config::RelayTimeouts, + verifier: auth::RelayVerifier, + ) -> Self { let executor = executor::Executor::new(timeouts); - Self { router, executor } + Self { + router, + executor, + verifier, + } } } @@ -49,6 +59,11 @@ where let resolved = self.router.resolve(&req); let (parts, body) = req.into_parts(); let executor = self.executor.clone(); + // Clone verifier only for requests that require it + let maybe_verifier = match &resolved { + Some((handler, _)) if handler.requires_relay_auth() => Some(self.verifier.clone()), + _ => None, + }; Box::pin(async move { let (response, handler_name): (Response>, &str) = match resolved { @@ -56,9 +71,20 @@ where let handler_name = handler.name(); match body.collect().await { Ok(c) => { - let request = Request::from_parts(parts, c.to_bytes()); - let response = executor.execute(handler, request, cells).await; - (response.map(Full::new), handler_name) + let body_bytes = c.to_bytes(); + if let Some(verifier) = &maybe_verifier + && let Err(err) = + verifier.verify_request(&parts.headers, &body_bytes) + { + tracing::warn!(error = %err, handler = handler_name, "relay signature verification failed"); + let response = + make_error_response(StatusCode::UNAUTHORIZED).map(Full::new); + (response, handler_name) + } else { + let request = Request::from_parts(parts, body_bytes); + let response = executor.execute(handler, request, cells).await; + (response.map(Full::new), handler_name) + } } Err(_) => { let response = @@ -111,6 +137,8 @@ mod tests { use std::time::Duration; use url::Url; + use crate::testutils::make_signing_keypair; + struct TestServer { child: Child, } @@ -181,6 +209,8 @@ mod tests { )])) .await; + let (signer, verifier) = make_signing_keypair(); + let service = IngestRouterService::new( router::Router::new(routes_config, localities, locator), config::RelayTimeouts { @@ -188,17 +218,18 @@ mod tests { task_initial_timeout_secs: 10000, task_subsequent_timeout_secs: 10000, }, + verifier, ); - // Project configs request - let request = Request::builder() + // Project configs request — must be signed by a trusted relay + let body = r#"{"publicKeys": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], "global": 1}"#; + let mut request = Request::builder() .method(Method::POST) .uri("/api/0/relays/projectconfigs/") .header(HOST, "us.sentry.io") - .body(Full::new(Bytes::from( - r#"{"publicKeys": ["aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"], "global": 1}"#, - ))) + .body(Full::new(Bytes::from(body))) .unwrap(); + signer.sign_request(request.headers_mut(), body.as_bytes()); let response = service.call(request).await.unwrap(); diff --git a/ingest-router/src/lib.rs b/ingest-router/src/lib.rs index 10a24bc..5d20f22 100644 --- a/ingest-router/src/lib.rs +++ b/ingest-router/src/lib.rs @@ -14,6 +14,7 @@ pub mod router; mod testutils; use crate::errors::IngestRouterError; +use auth::RelayVerifier; use locator::client::Locator; use shared::http::run_http_service; @@ -22,9 +23,12 @@ use shared::admin_service::AdminService; pub async fn run(config: config::Config) -> Result<(), IngestRouterError> { let locator = Locator::new(config.locator.to_client_config()).await?; + let verifier = RelayVerifier::from_relays(config.relay_keys)?; + let ingest_router_service = ingest_router_service::IngestRouterService::new( router::Router::new(config.routes, config.localities, locator.clone()), config.relay_timeouts, + verifier, ); let admin_service = AdminService::new({ let locator = locator.clone(); diff --git a/ingest-router/src/testutils.rs b/ingest-router/src/testutils.rs index bfb1a05..0a452bd 100644 --- a/ingest-router/src/testutils.rs +++ b/ingest-router/src/testutils.rs @@ -1,8 +1,10 @@ +use crate::auth::{RelayInfo, RelaySigner, RelayVerifier, generate_credentials_json}; use locator::backup_routes::{BackupRouteProvider, FilesystemRouteProvider}; use locator::client::Locator; use locator::config::Compression; use locator::types::RouteData; use std::collections::HashMap; +use std::io::Write; use std::sync::Arc; pub async fn get_mock_provider() -> (tempfile::TempDir, FilesystemRouteProvider) { @@ -26,6 +28,24 @@ pub async fn get_mock_provider() -> (tempfile::TempDir, FilesystemRouteProvider) (dir, provider) } +/// A signer plus a verifier that trusts that signer's freshly generated credentials, so signed +/// requests verify end-to-end. +pub fn make_signing_keypair() -> (RelaySigner, RelayVerifier) { + let json = generate_credentials_json(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + let id = parsed["id"].as_str().unwrap().to_string(); + let public_key = parsed["public_key"].as_str().unwrap().to_string(); + + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + tmp.write_all(json.as_bytes()).unwrap(); + let signer = RelaySigner::from_file(tmp.path()).unwrap(); + + let verifier = + RelayVerifier::from_relays(HashMap::from([(id, RelayInfo { public_key })])).unwrap(); + + (signer, verifier) +} + // Mock backup provider for testing struct MockBackupProvider { data: RouteData,