//! HMAC-SHA256 authentication for internal API requests from MNW. //! //! The signed message binds method + path + nonce as well as timestamp + body, //! `HMAC-SHA256(timestamp \n METHOD \n PATH \n NONCE \n body)`, sent in //! `X-Internal-{Timestamp,Signature,Nonce}`. Binding method+path stops a //! captured signature being replayed to a different endpoint; the nonce, checked //! against a single-use cache, stops it being re-sent at all within the 60s //! freshness window. //! //! A nonce is **mandatory**: there is exactly one verification path. A request //! with no `X-Internal-Nonce` is rejected outright (401), never downgraded to an //! unnonced format, which would reopen a replay window an attacker could select //! by omitting the header. use std::collections::HashMap; use std::sync::{LazyLock, Mutex}; use axum::{ body::Bytes, extract::{FromRequest, Request}, http::StatusCode, response::{IntoResponse, Response}, }; use hmac::{Hmac, KeyInit, 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; /// Maximum tolerated clock skew into the future. Only a few seconds of skew are /// legitimate, and a symmetric ±60s window would leave a captured signature /// replayable across twice the band, so future timestamps are held tight. const MAX_FUTURE_SKEW_SECS: i64 = 5; /// Process-wide cache of recently-seen request nonces, for single-use /// enforcement. MT runs as a single process (one `TcpListener`), so a local /// cache is authoritative. Entries are evicted once older than the freshness /// window, a request that old is already rejected by the timestamp check, so a /// nonce can never be replayed after it ages out. Memory is therefore bounded /// by (request rate × window), and the internal rate limiter caps that. Nonces /// are inserted only AFTER the signature verifies, so unauthenticated traffic /// can't poison or grow the cache. struct NonceCache { seen: HashMap, /// Unix time of the last full sweep; the O(n) `retain` runs at most once per /// window rather than on every insert. last_sweep: i64, } static NONCE_CACHE: LazyLock> = LazyLock::new(|| { Mutex::new(NonceCache { seen: HashMap::new(), last_sweep: 0, }) }); /// Record a nonce as seen. Returns `false` if it was already present within the /// window (a replay). /// /// Eviction is time-bucketed: the O(n) sweep of aged entries runs at most once /// per freshness window, not on every call, so the hot internal path stays /// effectively O(1) under the lock. Keeping an aged entry slightly longer is /// harmless, a request old enough to evict is already rejected by the timestamp /// freshness check before it ever reaches here, so it can't be the nonce we'd /// have swept. Worst-case memory is ~2× the window's traffic instead of 1×. fn record_nonce(nonce: &str, now_unix: i64) -> bool { let mut cache = NONCE_CACHE .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); if now_unix - cache.last_sweep >= MAX_TIMESTAMP_AGE_SECS { cache .seen .retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS); cache.last_sweep = now_unix; } if cache.seen.contains_key(nonce) { return false; } cache.seen.insert(nonce.to_string(), now_unix); true } /// 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 nonce_header = req .headers() .get("X-Internal-Nonce") .and_then(|v| v.to_str().ok()) .map(str::to_string); // Method + concrete request path (NOT the matched route template) must // be captured before the body extractor consumes the request. let method = req.method().as_str().to_string(); let path = req.uri().path().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() })?; let now = chrono::Utc::now().timestamp(); verify_signed_request( secret, timestamp_header.as_deref(), signature_header.as_deref(), &method, &path, nonce_header.as_deref(), &body, now, ) .map_err(|(status, msg)| (status, msg).into_response())?; // Single-use: reject a replayed nonce. `verify_signed_request` has // already guaranteed the nonce is present, so this always runs. if let Some(nonce) = nonce_header.as_deref() && !record_nonce(nonce, now) { return Err((StatusCode::UNAUTHORIZED, "Replayed nonce").into_response()); } Ok(InternalAuth(body)) } } /// Compute the signature, which binds method + path + nonce in addition to /// timestamp + body. The canonical message is newline-delimited with a fixed /// field order, body last so an embedded newline in the body can never be /// confused with a field separator: /// `timestamp \n METHOD \n PATH \n NONCE \n ` /// METHOD is uppercase ASCII, PATH is the request path only (no query string). /// Body is MAC'd as raw bytes, not a lossy UTF-8 string, closing the latent /// hole where two distinct non-UTF-8 bodies both collapsed to "" and signed /// identically. pub(crate) fn compute_internal_signature_v2( secret: &str, timestamp_str: &str, method: &str, path: &str, nonce: &str, body: &[u8], ) -> String { let mut mac = Hmac::::new_from_slice(secret.as_bytes()) .expect("HMAC-SHA256 accepts any key length"); mac.update(timestamp_str.as_bytes()); mac.update(b"\n"); mac.update(method.as_bytes()); mac.update(b"\n"); mac.update(path.as_bytes()); mac.update(b"\n"); mac.update(nonce.as_bytes()); mac.update(b"\n"); mac.update(body); hex::encode(mac.finalize().into_bytes()) } /// Validate timestamp freshness against `now_unix`. Returns the parsed timestamp. fn check_freshness(timestamp_str: &str, now_unix: i64) -> Result { let timestamp: i64 = timestamp_str .parse() .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?; if now_unix - timestamp > MAX_TIMESTAMP_AGE_SECS { return Err((StatusCode::UNAUTHORIZED, "Timestamp too old")); } if timestamp - now_unix > MAX_FUTURE_SKEW_SECS { return Err((StatusCode::UNAUTHORIZED, "Timestamp too far in the future")); } Ok(timestamp) } /// Verify a signed internal request, binding method + path + nonce. Freshness /// is checked first. The module header carries why the nonce is mandatory and /// what a missing one does. /// /// Headers are passed as `Option<&str>` so callers can extract them with any /// strategy (axum `HeaderMap`, manual `Bytes`, tests). /// /// Nonce replay is NOT checked here (that is stateful); the caller records the /// nonce via [`record_nonce`] after this returns Ok. #[allow(clippy::too_many_arguments)] pub(crate) fn verify_signed_request( secret: &str, timestamp_header: Option<&str>, signature_header: Option<&str>, method: &str, path: &str, nonce_header: Option<&str>, body: &[u8], now_unix: i64, ) -> Result<(), (StatusCode, &'static str)> { let nonce = nonce_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Nonce"))?; 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"))?; check_freshness(timestamp_str, now_unix)?; let expected = compute_internal_signature_v2(secret, timestamp_str, method, path, nonce, body); if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) { return Err((StatusCode::UNAUTHORIZED, "Invalid signature")); } Ok(()) } /// 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")); } // --- compute_internal_signature_v2 pins HMAC message construction fn v2(secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &[u8]) -> String { compute_internal_signature_v2(secret, ts, method, path, nonce, body) } #[test] fn signature_is_64_hex_chars() { let sig = v2("secret", "100", "POST", "/x", "n", 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_matches_the_reference_hmac() { // The signer lives in the MNW server and the verifier lives here, so // the exact bytes are a cross-repo contract that no test in either // repo can catch by agreeing with itself. Pinned against an // independent HMAC-SHA256 over the documented message layout: // key "secret", message "100\nPOST\n/x\nn\nbody". assert_eq!( v2("secret", "100", "POST", "/x", "n", b"body"), "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098" ); } #[test] fn signature_changes_with_secret() { // Pins that the secret feeds into the MAC key. assert_ne!( v2("alpha", "100", "POST", "/x", "n", b"body"), v2("beta", "100", "POST", "/x", "n", b"body"), ); } #[test] fn signature_changes_with_each_bound_field() { // Pins that timestamp, method, path, nonce, and body each feed the MAC, // a mutation dropping any field would collide one of these pairs. let base = v2("s", "100", "POST", "/x", "n", b"body"); assert_ne!( base, v2("s", "101", "POST", "/x", "n", b"body"), "timestamp bound" ); assert_ne!( base, v2("s", "100", "GET", "/x", "n", b"body"), "method bound" ); assert_ne!( base, v2("s", "100", "POST", "/y", "n", b"body"), "path bound" ); assert_ne!( base, v2("s", "100", "POST", "/x", "m", b"body"), "nonce bound" ); assert_ne!( base, v2("s", "100", "POST", "/x", "n", b"body!"), "body bound" ); } #[test] fn signature_separators_are_newlines_not_concat() { // Without the `\n` delimiters, field-boundary ambiguity would let two // distinct messages collide (e.g. ts "1"+"00body" vs "10"+"0body"). assert_ne!( v2("s", "1", "POST", "/x", "n", b"00body"), v2("s", "10", "POST", "/x", "n", b"0body"), "missing separator allows length-ambiguity collision" ); } // --- verify_signed_request: signature + freshness + mandatory nonce #[test] fn verify_accepts_valid_signature_at_now() { let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); assert!( verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000 ) .is_ok() ); } #[test] fn verify_rejects_wrong_signature() { let mut sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); let first = sig.remove(0); sig.insert(0, if first == '0' { '1' } else { '0' }); let (status, _) = verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000, ) .unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); } #[test] fn verify_rejects_wrong_secret() { let sig = v2("real-secret", "1000", "POST", "/internal/x", "abc", b"body"); assert!( verify_signed_request( "wrong-secret", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000 ) .is_err() ); } #[test] fn verify_rejects_tampered_body() { let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"original"); assert!( verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"tampered", 1000 ) .is_err() ); } #[test] fn verify_rejects_wrong_method() { let sig = v2("s", "1000", "GET", "/internal/x", "abc", b"body"); assert!( verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("abc"), b"body", 1000 ) .is_err() ); } #[test] fn verify_rejects_wrong_path() { let sig = v2("s", "1000", "POST", "/internal/a", "abc", b"body"); assert!( verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/b", Some("abc"), b"body", 1000 ) .is_err() ); } #[test] fn verify_rejects_wrong_nonce() { let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); assert!( verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", Some("zzz"), b"body", 1000 ) .is_err() ); } #[test] fn verify_at_window_boundary_accepts_inside_rejects_outside() { // Asymmetric window: up to MAX_TIMESTAMP_AGE_SECS (60s) old, but only // MAX_FUTURE_SKEW_SECS (5s) into the future. `>` is strict, so exactly // at each boundary is accepted. let sig = v2("s", "1000", "POST", "/x", "abc", b"abc"); let check = |now| { verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/x", Some("abc"), b"abc", now, ) }; assert!(check(1060).is_ok(), "now-ts=60 accepted (age boundary)"); assert!(check(1061).is_err(), "now-ts=61 rejected (too old)"); assert!( check(995).is_ok(), "ts-now=5 accepted (future-skew boundary)" ); assert!(check(994).is_err(), "ts-now=6 rejected (too far future)"); } #[test] fn verify_rejects_missing_nonce() { // A request with no nonce is rejected outright, no v1 downgrade exists. let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body"); let (status, msg) = verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/internal/x", None, b"body", 1000, ) .unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Nonce"), "expected nonce msg, got: {msg}"); } #[test] fn verify_rejects_missing_timestamp_header() { let sig = v2("s", "1000", "POST", "/x", "abc", b"abc"); let (status, msg) = verify_signed_request( "s", None, Some(&sig), "POST", "/x", Some("abc"), b"abc", 1000, ) .unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Timestamp")); } #[test] fn verify_rejects_missing_signature_header() { let (status, msg) = verify_signed_request( "s", Some("1000"), None, "POST", "/x", Some("abc"), b"abc", 1000, ) .unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Signature")); } #[test] fn verify_rejects_unparseable_timestamp() { let (status, msg) = verify_signed_request( "s", Some("not-an-int"), Some("zz"), "POST", "/x", Some("abc"), b"", 1000, ) .unwrap_err(); assert_eq!(status, StatusCode::UNAUTHORIZED); assert!(msg.contains("Invalid timestamp")); } #[test] fn verify_check_order_nonce_before_timestamp() { // Nonce mandatory: a missing nonce rejects even with all else missing. let (_, msg) = verify_signed_request("s", None, None, "POST", "/x", None, b"", 1000).unwrap_err(); assert!( msg.contains("Nonce"), "expected nonce msg first, got: {msg}" ); } #[test] fn verify_check_order_freshness_before_signature() { // A stale timestamp must reject even when the sig is otherwise valid, // catches a mutation running the freshness check after signature verify. let sig = v2("s", "1000", "POST", "/x", "abc", b"abc"); let (_, msg) = verify_signed_request( "s", Some("1000"), Some(&sig), "POST", "/x", Some("abc"), b"abc", 9999, ) .unwrap_err(); assert!( msg.contains("Timestamp"), "expected freshness msg, got: {msg}" ); } #[test] fn record_nonce_rejects_replay_and_evicts_aged() { // Unique nonces so the shared cache can't collide with other tests. let n1 = "nonce-test-unique-aaa"; assert!(record_nonce(n1, 1_000_000), "first use accepted"); assert!( !record_nonce(n1, 1_000_000), "replay within window rejected" ); // Past the freshness window, the entry is swept and the nonce is free // again (a request that old is already rejected by the timestamp check). assert!( record_nonce(n1, 1_000_000 + MAX_TIMESTAMP_AGE_SECS + 1), "aged nonce reusable" ); } }