diff --git a/crates/scrybe-cache/src/client.rs b/crates/scrybe-cache/src/client.rs index c0106ef..b09a8cd 100644 --- a/crates/scrybe-cache/src/client.rs +++ b/crates/scrybe-cache/src/client.rs @@ -5,6 +5,7 @@ use scrybe_core::ScrybeError; /// Redis client with connection pool. /// /// Uses `deadpool-redis` for connection pooling with configurable pool size. +#[derive(Clone)] pub struct RedisClient { pool: Pool, } diff --git a/crates/scrybe-gateway/Cargo.toml b/crates/scrybe-gateway/Cargo.toml index 6b90400..964cd0c 100644 --- a/crates/scrybe-gateway/Cargo.toml +++ b/crates/scrybe-gateway/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] scrybe-core = { path = "../scrybe-core" } +scrybe-cache = { path = "../scrybe-cache" } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/scrybe-gateway/src/main.rs b/crates/scrybe-gateway/src/main.rs index 78a558c..cc0b6a6 100644 --- a/crates/scrybe-gateway/src/main.rs +++ b/crates/scrybe-gateway/src/main.rs @@ -24,6 +24,7 @@ mod health; mod middleware; mod routes; mod shutdown; +mod state; use axum::{routing::get, Router}; use routes::ingest::AppState; diff --git a/crates/scrybe-gateway/src/middleware/auth.rs b/crates/scrybe-gateway/src/middleware/auth.rs index 77fc874..6f632d9 100644 --- a/crates/scrybe-gateway/src/middleware/auth.rs +++ b/crates/scrybe-gateway/src/middleware/auth.rs @@ -4,13 +4,14 @@ use axum::{ body::Body, - extract::Request, + extract::{Request, State}, http::{HeaderMap, StatusCode}, middleware::Next, response::{IntoResponse, Response}, }; use hmac::{Hmac, Mac}; use sha2::Sha256; +use std::sync::Arc; use subtle::ConstantTimeEq; use tracing::{debug, warn}; @@ -25,8 +26,12 @@ type HmacSha256 = Hmac; /// - `X-Scrybe-Signature`: HMAC-SHA256 hex string /// /// The signature is computed over: `{timestamp}:{nonce}:{body}` -#[allow(dead_code)] // Ready for use, pending integration testing +/// HMAC authentication middleware with nonce validation. +/// +/// Validates HMAC signatures and checks nonce uniqueness via Redis. +#[allow(dead_code)] // Ready to wire into routes pub async fn hmac_auth( + State(state): State>, headers: HeaderMap, request: Request, next: Next, @@ -41,7 +46,17 @@ pub async fn hmac_auth( // Validate timestamp (must be within 5 minutes) validate_timestamp(×tamp)?; - // TODO: Validate nonce for replay protection (requires Redis) + // Validate nonce for replay protection + let nonce_valid = state + .nonce_validator + .validate_nonce(&nonce) + .await + .map_err(|_| AuthError::InvalidNonce)?; + + if !nonce_valid { + warn!("Replay attack detected: nonce already used"); + return Err(AuthError::ReplayAttack); + } // Read body for signature verification let (parts, body) = request.into_parts(); @@ -125,37 +140,53 @@ fn get_hmac_key() -> Vec { /// Authentication errors. #[derive(Debug)] -#[allow(dead_code)] // Ready for use, pending integration pub enum AuthError { - /// Missing required header + /// Missing or invalid header MissingHeader(String), - /// Invalid header value - InvalidHeader(String), - /// Invalid timestamp format + /// Invalid timestamp (too old or future) InvalidTimestamp, - /// Timestamp expired (> 5 minutes) - TimestampExpired, /// Invalid HMAC signature InvalidSignature, + /// Invalid nonce (cache error) + InvalidNonce, + /// Replay attack detected (nonce reused) + ReplayAttack, + /// Timestamp expired (> 5 minutes) + TimestampExpired, /// Invalid HMAC key InvalidKey, + /// Invalid header + InvalidHeader(String), } impl IntoResponse for AuthError { fn into_response(self) -> Response { let (status, message) = match self { - AuthError::MissingHeader(h) => { - (StatusCode::UNAUTHORIZED, format!("Missing header: {}", h)) + AuthError::MissingHeader(header) => ( + StatusCode::BAD_REQUEST, + format!("Missing header: {}", header), + ), + AuthError::InvalidTimestamp => { + (StatusCode::UNAUTHORIZED, "Invalid timestamp".to_string()) } - AuthError::InvalidHeader(h) => { - (StatusCode::UNAUTHORIZED, format!("Invalid header: {}", h)) + AuthError::InvalidSignature => { + (StatusCode::UNAUTHORIZED, "Invalid signature".to_string()) + } + AuthError::InvalidNonce => ( + StatusCode::INTERNAL_SERVER_ERROR, + "Nonce validation failed".to_string(), + ), + AuthError::ReplayAttack => (StatusCode::CONFLICT, "Replay attack detected".to_string()), + AuthError::TimestampExpired => { + (StatusCode::UNAUTHORIZED, "Timestamp expired".to_string()) } - AuthError::InvalidTimestamp => (StatusCode::UNAUTHORIZED, "Invalid timestamp".into()), - AuthError::TimestampExpired => (StatusCode::UNAUTHORIZED, "Timestamp expired".into()), - AuthError::InvalidSignature => (StatusCode::UNAUTHORIZED, "Invalid signature".into()), AuthError::InvalidKey => ( StatusCode::INTERNAL_SERVER_ERROR, - "Configuration error".into(), + "Configuration error".to_string(), + ), + AuthError::InvalidHeader(header) => ( + StatusCode::BAD_REQUEST, + format!("Invalid header: {}", header), ), }; diff --git a/crates/scrybe-gateway/src/routes/ingest.rs b/crates/scrybe-gateway/src/routes/ingest.rs index 6ce90c0..407df7b 100644 --- a/crates/scrybe-gateway/src/routes/ingest.rs +++ b/crates/scrybe-gateway/src/routes/ingest.rs @@ -167,8 +167,9 @@ pub fn ingest_route() -> axum::Router> { #[cfg(test)] mod tests { use super::*; + use crate::state::AppState as GatewayAppState; use axum::http::StatusCode; - use scrybe_core::types::{Header, HttpVersion, ScreenInfo, TimingMetrics}; + use scrybe_core::{types::*, ScrybeError}; use std::net::Ipv4Addr; fn create_test_request() -> IngestRequest { diff --git a/crates/scrybe-gateway/src/state.rs b/crates/scrybe-gateway/src/state.rs new file mode 100644 index 0000000..4446884 --- /dev/null +++ b/crates/scrybe-gateway/src/state.rs @@ -0,0 +1,50 @@ +//! Application state shared across handlers. + +use scrybe_cache::{NonceValidator, RedisClient}; +use std::sync::Arc; + +/// Shared application state. +/// +/// Contains Redis client and nonce validator for authentication. +#[derive(Clone)] +#[allow(dead_code)] // Ready to use in routes +pub struct AppState { + /// Redis client for caching + pub redis_client: Arc, + /// Nonce validator for replay attack prevention + pub nonce_validator: Arc, +} + +impl AppState { + /// Create new application state. + /// + /// # Arguments + /// + /// * `redis_url` - Redis connection URL + /// * `pool_size` - Redis connection pool size + /// * `nonce_ttl` - Nonce TTL in seconds (default: 300 = 5 minutes) + /// + /// # Errors + /// + /// Returns error if Redis connection fails. + #[allow(dead_code)] // Ready for use + pub async fn new( + redis_url: &str, + pool_size: usize, + nonce_ttl: Option, + ) -> Result { + let redis_client = RedisClient::new(redis_url, pool_size).await?; + let nonce_validator = NonceValidator::new(redis_client.clone(), nonce_ttl); + + Ok(Self { + redis_client: Arc::new(redis_client), + nonce_validator: Arc::new(nonce_validator), + }) + } + + /// Check if Redis is healthy. + #[allow(dead_code)] // Ready for use + pub async fn health_check(&self) -> Result<(), scrybe_core::ScrybeError> { + self.redis_client.health_check().await + } +}