//! HMAC-SHA256 authentication for internal API requests from MNW. //! //! MNW signs requests with `HMAC-SHA256(timestamp + "\n" + body, secret)`. //! The signature and timestamp are sent in `X-Internal-Signature` and //! `X-Internal-Timestamp` headers. Requests older than 60 seconds are rejected. use axum::{ body::Bytes, extract::{FromRequest, Request}, http::StatusCode, response::{IntoResponse, Response}, }; use hmac::{Hmac, Mac}; use sha2::Sha256; use crate::AppState; /// Maximum age (in seconds) for an internal request timestamp before it's rejected. const MAX_TIMESTAMP_AGE_SECS: i64 = 60; /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests. /// Extracts the raw request body as `Bytes` after successful verification. pub struct InternalAuth(pub Bytes); impl FromRequest for InternalAuth { type Rejection = Response; async fn from_request(req: Request, state: &AppState) -> Result { let secret = state .config .internal_shared_secret .as_deref() .ok_or_else(|| { tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured"); StatusCode::SERVICE_UNAVAILABLE.into_response() })?; let timestamp_str = req .headers() .get("X-Internal-Timestamp") .and_then(|v| v.to_str().ok()) .ok_or_else(|| { (StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp").into_response() })? .to_string(); let signature = req .headers() .get("X-Internal-Signature") .and_then(|v| v.to_str().ok()) .ok_or_else(|| { (StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature").into_response() })? .to_string(); // Verify timestamp freshness let timestamp: i64 = timestamp_str.parse().map_err(|_| { (StatusCode::UNAUTHORIZED, "Invalid timestamp").into_response() })?; let now = chrono::Utc::now().timestamp(); if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS { return Err( (StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future") .into_response(), ); } // Read body let body = Bytes::from_request(req, state).await.map_err(|e| { tracing::error!(error = %e, "failed to read request body"); StatusCode::BAD_REQUEST.into_response() })?; // Verify HMAC let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(&body).unwrap_or("")); let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(message.as_bytes()); let expected = hex::encode(mac.finalize().into_bytes()); if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); } Ok(InternalAuth(body)) } } /// Constant-time byte comparison to prevent timing attacks. fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { if a.len() != b.len() { return false; } a.iter() .zip(b.iter()) .fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 } #[cfg(test)] mod tests { use super::*; #[test] fn constant_time_eq_works() { assert!(constant_time_eq(b"hello", b"hello")); assert!(!constant_time_eq(b"hello", b"world")); assert!(!constant_time_eq(b"hello", b"hell")); } #[test] fn hmac_signature_roundtrip() { let secret = "test-secret"; let timestamp = "1234567890"; let body = r#"{"name":"test"}"#; let message = format!("{}\n{}", timestamp, body); let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac.update(message.as_bytes()); let sig = hex::encode(mac.finalize().into_bytes()); // Verify the same computation matches let mut mac2 = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); mac2.update(message.as_bytes()); let expected = hex::encode(mac2.finalize().into_bytes()); assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes())); } }