//! 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_header = req .headers() .get("X-Internal-Timestamp") .and_then(|v| v.to_str().ok()) .map(str::to_string); let signature_header = req .headers() .get("X-Internal-Signature") .and_then(|v| v.to_str().ok()) .map(str::to_string); 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_internal_signature( secret, timestamp_header.as_deref(), signature_header.as_deref(), &body, chrono::Utc::now().timestamp(), ) .map_err(|(status, msg)| (status, msg).into_response())?; Ok(InternalAuth(body)) } } /// Compute the hex-encoded HMAC-SHA256 signature for an internal request. /// `secret` may be any length (HMAC-SHA256 accepts any key length). pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body: &[u8]) -> String { 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()); hex::encode(mac.finalize().into_bytes()) } /// Pure verification: validate timestamp freshness against `now_unix`, then /// recompute the signature and constant-time compare. /// /// Headers are passed as `Option<&str>` so callers can extract them with any /// strategy (axum `HeaderMap`, manual `Bytes`, tests). pub(crate) fn verify_internal_signature( secret: &str, timestamp_header: Option<&str>, signature_header: Option<&str>, body: &[u8], now_unix: i64, ) -> Result<(), (StatusCode, &'static str)> { let timestamp_str = timestamp_header .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?; let signature = signature_header .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?; let timestamp: i64 = timestamp_str .parse() .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?; if (now_unix - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS { return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future")); } let expected = compute_internal_signature(secret, timestamp_str, body); if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); } Ok(()) } /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor). pub fn verify_hmac_headers( state: &AppState, headers: &axum::http::HeaderMap, body: &[u8], ) -> Result<(), (StatusCode, &'static str)> { 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, "Service unavailable") })?; let timestamp_header = headers .get("X-Internal-Timestamp") .and_then(|v| v.to_str().ok()); let signature_header = headers .get("X-Internal-Signature") .and_then(|v| v.to_str().ok()); verify_internal_signature( secret, timestamp_header, signature_header, body, chrono::Utc::now().timestamp(), ) } /// 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())); } // ── compute_internal_signature pins HMAC message construction ── #[test] fn signature_is_64_hex_chars() { let sig = compute_internal_signature("secret", "100", b"body"); assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars"); assert!(sig.chars().all(|c| c.is_ascii_hexdigit())); } #[test] fn signature_changes_with_secret() { // Pins that the secret feeds into the MAC key. let s1 = compute_internal_signature("alpha", "100", b"body"); let s2 = compute_internal_signature("beta", "100", b"body"); assert_ne!(s1, s2); } #[test] fn signature_changes_with_timestamp() { // Pins the `format!("{}\n{}", timestamp_str, body)` ordering — a // mutation that drops the timestamp or swaps the order would make // these two signatures match. let s1 = compute_internal_signature("secret", "100", b"body"); let s2 = compute_internal_signature("secret", "101", b"body"); assert_ne!(s1, s2); } #[test] fn signature_changes_with_body() { let s1 = compute_internal_signature("secret", "100", b"hello"); let s2 = compute_internal_signature("secret", "100", b"hello!"); assert_ne!(s1, s2); } #[test] fn signature_separator_is_newline_not_concat() { // Pins `format!("{}\n{}", ...)` — without the `\n`, "1" + "00body" // would collide with "10" + "0body". let collision_a = compute_internal_signature("secret", "1", b"00body"); let collision_b = compute_internal_signature("secret", "10", b"0body"); assert_ne!( collision_a, collision_b, "missing newline separator allows length-ambiguity collision" ); } // ── verify_internal_signature freshness + signature check ── fn valid(secret: &str, ts: &str, body: &[u8]) -> String { compute_internal_signature(secret, ts, body) } #[test] fn verify_accepts_valid_signature_at_now() { let secret = "s"; let body = b"abc"; let ts = "1000"; let sig = valid(secret, ts, body); assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).is_ok()); } #[test] fn verify_rejects_wrong_signature() { let secret = "s"; let body = b"abc"; let ts = "1000"; // Tamper with one hex char. let mut sig = valid(secret, ts, body); let first = sig.remove(0); sig.insert(0, if first == '0' { '1' } else { '0' }); let (status, _) = verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); } #[test] fn verify_rejects_wrong_secret() { let body = b"abc"; let ts = "1000"; let sig = valid("real-secret", ts, body); assert!( verify_internal_signature("wrong-secret", Some(ts), Some(&sig), body, 1000).is_err() ); } #[test] fn verify_rejects_tampered_body() { let secret = "s"; let ts = "1000"; let sig = valid(secret, ts, b"original"); assert!(verify_internal_signature(secret, Some(ts), Some(&sig), b"tampered", 1000).is_err()); } #[test] fn verify_at_window_boundary_accepts_inside_rejects_outside() { // Pins `(now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS` (60s). // Exactly at the boundary (abs diff == 60) must be accepted (since `>` // is strict). One second past must be rejected. let secret = "s"; let body = b"abc"; let ts = "1000"; let sig = valid(secret, ts, body); // diff = 60 → accepted assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1060).is_ok()); // diff = 61 → rejected (too old) assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1061).is_err()); // diff = -60 → accepted assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 940).is_ok()); // diff = -61 → rejected (too far in future) assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 939).is_err()); } #[test] fn verify_rejects_missing_timestamp_header() { let secret = "s"; let body = b"abc"; let sig = valid(secret, "1000", body); let (status, msg) = verify_internal_signature(secret, None, Some(&sig), body, 1000).unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Timestamp")); } #[test] fn verify_rejects_missing_signature_header() { let (status, msg) = verify_internal_signature("s", Some("1000"), None, b"abc", 1000).unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Signature")); } #[test] fn verify_rejects_unparseable_timestamp() { let (status, msg) = verify_internal_signature("s", Some("not-an-int"), Some("zz"), b"", 1000).unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Invalid timestamp")); } #[test] fn verify_check_order_missing_timestamp_first() { // Both headers missing: timestamp check fires first. let (_, msg) = verify_internal_signature("s", None, None, b"", 1000).unwrap_err(); assert!(msg.contains("Timestamp"), "expected timestamp msg first, got: {msg}"); } #[test] fn verify_check_order_freshness_before_signature() { // A stale timestamp must reject even when the (otherwise valid) sig // matches. Catches a mutation that runs the freshness check after // signature verification. let secret = "s"; let body = b"abc"; let ts = "1000"; let sig = valid(secret, ts, body); let (_, msg) = verify_internal_signature(secret, Some(ts), Some(&sig), body, 9999).unwrap_err(); assert!(msg.contains("Timestamp"), "expected freshness msg, got: {msg}"); } }