Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/scrybe-cache/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
Expand Down
1 change: 1 addition & 0 deletions crates/scrybe-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
1 change: 1 addition & 0 deletions crates/scrybe-gateway/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ mod health;
mod middleware;
mod routes;
mod shutdown;
mod state;

use axum::{routing::get, Router};
use routes::ingest::AppState;
Expand Down
67 changes: 49 additions & 18 deletions crates/scrybe-gateway/src/middleware/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand All @@ -25,8 +26,12 @@ type HmacSha256 = Hmac<Sha256>;
/// - `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<Arc<crate::state::AppState>>,
headers: HeaderMap,
request: Request,
next: Next,
Expand All @@ -41,7 +46,17 @@ pub async fn hmac_auth(
// Validate timestamp (must be within 5 minutes)
validate_timestamp(&timestamp)?;

// 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();
Expand Down Expand Up @@ -125,37 +140,53 @@ fn get_hmac_key() -> Vec<u8> {

/// 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),
),
};

Expand Down
3 changes: 2 additions & 1 deletion crates/scrybe-gateway/src/routes/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,8 +167,9 @@ pub fn ingest_route() -> axum::Router<Arc<AppState>> {
#[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 {
Expand Down
50 changes: 50 additions & 0 deletions crates/scrybe-gateway/src/state.rs
Original file line number Diff line number Diff line change
@@ -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<RedisClient>,
/// Nonce validator for replay attack prevention
pub nonce_validator: Arc<NonceValidator>,
}

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<usize>,
) -> Result<Self, scrybe_core::ScrybeError> {
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
}
}