Skip to main content

max / makenotwork

19.8 KB · 610 lines History Blame Raw
1 //! HMAC-SHA256 authentication for internal API requests from MNW.
2 //!
3 //! The signed message binds method + path + nonce as well as timestamp + body,
4 //! `HMAC-SHA256(timestamp \n METHOD \n PATH \n NONCE \n body)`, sent in
5 //! `X-Internal-{Timestamp,Signature,Nonce}`. Binding method+path stops a
6 //! captured signature being replayed to a different endpoint; the nonce, checked
7 //! against a single-use cache, stops it being re-sent at all within the 60s
8 //! freshness window.
9 //!
10 //! A nonce is **mandatory**: there is exactly one verification path. A request
11 //! with no `X-Internal-Nonce` is rejected outright (401), never downgraded to an
12 //! unnonced format, which would reopen a replay window an attacker could select
13 //! by omitting the header.
14
15 use std::collections::HashMap;
16 use std::sync::{LazyLock, Mutex};
17
18 use axum::{
19 body::Bytes,
20 extract::{FromRequest, Request},
21 http::StatusCode,
22 response::{IntoResponse, Response},
23 };
24 use hmac::{Hmac, KeyInit, Mac};
25 use sha2::Sha256;
26
27 use crate::AppState;
28
29 /// Maximum age (in seconds) for an internal request timestamp before it's rejected.
30 const MAX_TIMESTAMP_AGE_SECS: i64 = 60;
31
32 /// Maximum tolerated clock skew into the future. Only a few seconds of skew are
33 /// legitimate, and a symmetric ±60s window would leave a captured signature
34 /// replayable across twice the band, so future timestamps are held tight.
35 const MAX_FUTURE_SKEW_SECS: i64 = 5;
36
37 /// Process-wide cache of recently-seen request nonces, for single-use
38 /// enforcement. MT runs as a single process (one `TcpListener`), so a local
39 /// cache is authoritative. Entries are evicted once older than the freshness
40 /// window, a request that old is already rejected by the timestamp check, so a
41 /// nonce can never be replayed after it ages out. Memory is therefore bounded
42 /// by (request rate × window), and the internal rate limiter caps that. Nonces
43 /// are inserted only AFTER the signature verifies, so unauthenticated traffic
44 /// can't poison or grow the cache.
45 struct NonceCache {
46 seen: HashMap<String, i64>,
47 /// Unix time of the last full sweep; the O(n) `retain` runs at most once per
48 /// window rather than on every insert.
49 last_sweep: i64,
50 }
51
52 static NONCE_CACHE: LazyLock<Mutex<NonceCache>> = LazyLock::new(|| {
53 Mutex::new(NonceCache {
54 seen: HashMap::new(),
55 last_sweep: 0,
56 })
57 });
58
59 /// Record a nonce as seen. Returns `false` if it was already present within the
60 /// window (a replay).
61 ///
62 /// Eviction is time-bucketed: the O(n) sweep of aged entries runs at most once
63 /// per freshness window, not on every call, so the hot internal path stays
64 /// effectively O(1) under the lock. Keeping an aged entry slightly longer is
65 /// harmless, a request old enough to evict is already rejected by the timestamp
66 /// freshness check before it ever reaches here, so it can't be the nonce we'd
67 /// have swept. Worst-case memory is ~2× the window's traffic instead of 1×.
68 fn record_nonce(nonce: &str, now_unix: i64) -> bool {
69 let mut cache = NONCE_CACHE
70 .lock()
71 .unwrap_or_else(std::sync::PoisonError::into_inner);
72 if now_unix - cache.last_sweep >= MAX_TIMESTAMP_AGE_SECS {
73 cache
74 .seen
75 .retain(|_, &mut ts| now_unix - ts <= MAX_TIMESTAMP_AGE_SECS);
76 cache.last_sweep = now_unix;
77 }
78 if cache.seen.contains_key(nonce) {
79 return false;
80 }
81 cache.seen.insert(nonce.to_string(), now_unix);
82 true
83 }
84
85 /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests.
86 /// Extracts the raw request body as `Bytes` after successful verification.
87 pub struct InternalAuth(pub Bytes);
88
89 impl FromRequest<AppState> for InternalAuth {
90 type Rejection = Response;
91
92 async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
93 let secret = state
94 .config
95 .internal_shared_secret
96 .as_deref()
97 .ok_or_else(|| {
98 tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured");
99 StatusCode::SERVICE_UNAVAILABLE.into_response()
100 })?;
101
102 let timestamp_header = req
103 .headers()
104 .get("X-Internal-Timestamp")
105 .and_then(|v| v.to_str().ok())
106 .map(str::to_string);
107 let signature_header = req
108 .headers()
109 .get("X-Internal-Signature")
110 .and_then(|v| v.to_str().ok())
111 .map(str::to_string);
112 let nonce_header = req
113 .headers()
114 .get("X-Internal-Nonce")
115 .and_then(|v| v.to_str().ok())
116 .map(str::to_string);
117 // Method + concrete request path (NOT the matched route template) must
118 // be captured before the body extractor consumes the request.
119 let method = req.method().as_str().to_string();
120 let path = req.uri().path().to_string();
121
122 let body = Bytes::from_request(req, state).await.map_err(|e| {
123 tracing::error!(error = %e, "failed to read request body");
124 StatusCode::BAD_REQUEST.into_response()
125 })?;
126
127 let now = chrono::Utc::now().timestamp();
128 verify_signed_request(
129 secret,
130 timestamp_header.as_deref(),
131 signature_header.as_deref(),
132 &method,
133 &path,
134 nonce_header.as_deref(),
135 &body,
136 now,
137 )
138 .map_err(|(status, msg)| (status, msg).into_response())?;
139
140 // Single-use: reject a replayed nonce. `verify_signed_request` has
141 // already guaranteed the nonce is present, so this always runs.
142 if let Some(nonce) = nonce_header.as_deref()
143 && !record_nonce(nonce, now)
144 {
145 return Err((StatusCode::UNAUTHORIZED, "Replayed nonce").into_response());
146 }
147
148 Ok(InternalAuth(body))
149 }
150 }
151
152 /// Compute the signature, which binds method + path + nonce in addition to
153 /// timestamp + body. The canonical message is newline-delimited with a fixed
154 /// field order, body last so an embedded newline in the body can never be
155 /// confused with a field separator:
156 /// `timestamp \n METHOD \n PATH \n NONCE \n <raw body bytes>`
157 /// METHOD is uppercase ASCII, PATH is the request path only (no query string).
158 /// Body is MAC'd as raw bytes, not a lossy UTF-8 string, closing the latent
159 /// hole where two distinct non-UTF-8 bodies both collapsed to "" and signed
160 /// identically.
161 pub(crate) fn compute_internal_signature_v2(
162 secret: &str,
163 timestamp_str: &str,
164 method: &str,
165 path: &str,
166 nonce: &str,
167 body: &[u8],
168 ) -> String {
169 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
170 .expect("HMAC-SHA256 accepts any key length");
171 mac.update(timestamp_str.as_bytes());
172 mac.update(b"\n");
173 mac.update(method.as_bytes());
174 mac.update(b"\n");
175 mac.update(path.as_bytes());
176 mac.update(b"\n");
177 mac.update(nonce.as_bytes());
178 mac.update(b"\n");
179 mac.update(body);
180 hex::encode(mac.finalize().into_bytes())
181 }
182
183 /// Validate timestamp freshness against `now_unix`. Returns the parsed timestamp.
184 fn check_freshness(timestamp_str: &str, now_unix: i64) -> Result<i64, (StatusCode, &'static str)> {
185 let timestamp: i64 = timestamp_str
186 .parse()
187 .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
188 if now_unix - timestamp > MAX_TIMESTAMP_AGE_SECS {
189 return Err((StatusCode::UNAUTHORIZED, "Timestamp too old"));
190 }
191 if timestamp - now_unix > MAX_FUTURE_SKEW_SECS {
192 return Err((StatusCode::UNAUTHORIZED, "Timestamp too far in the future"));
193 }
194 Ok(timestamp)
195 }
196
197 /// Verify a signed internal request, binding method + path + nonce. Freshness
198 /// is checked first. The module header carries why the nonce is mandatory and
199 /// what a missing one does.
200 ///
201 /// Headers are passed as `Option<&str>` so callers can extract them with any
202 /// strategy (axum `HeaderMap`, manual `Bytes`, tests).
203 ///
204 /// Nonce replay is NOT checked here (that is stateful); the caller records the
205 /// nonce via [`record_nonce`] after this returns Ok.
206 #[allow(clippy::too_many_arguments)]
207 pub(crate) fn verify_signed_request(
208 secret: &str,
209 timestamp_header: Option<&str>,
210 signature_header: Option<&str>,
211 method: &str,
212 path: &str,
213 nonce_header: Option<&str>,
214 body: &[u8],
215 now_unix: i64,
216 ) -> Result<(), (StatusCode, &'static str)> {
217 let nonce = nonce_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Nonce"))?;
218
219 let timestamp_str =
220 timestamp_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
221 let signature =
222 signature_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
223
224 check_freshness(timestamp_str, now_unix)?;
225
226 let expected = compute_internal_signature_v2(secret, timestamp_str, method, path, nonce, body);
227 if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
228 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
229 }
230 Ok(())
231 }
232
233 /// Constant-time byte comparison to prevent timing attacks.
234 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
235 if a.len() != b.len() {
236 return false;
237 }
238 a.iter()
239 .zip(b.iter())
240 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
241 == 0
242 }
243
244 #[cfg(test)]
245 mod tests {
246 use super::*;
247
248 #[test]
249 fn constant_time_eq_works() {
250 assert!(constant_time_eq(b"hello", b"hello"));
251 assert!(!constant_time_eq(b"hello", b"world"));
252 assert!(!constant_time_eq(b"hello", b"hell"));
253 }
254
255 // --- compute_internal_signature_v2 pins HMAC message construction
256
257 fn v2(secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &[u8]) -> String {
258 compute_internal_signature_v2(secret, ts, method, path, nonce, body)
259 }
260
261 #[test]
262 fn signature_is_64_hex_chars() {
263 let sig = v2("secret", "100", "POST", "/x", "n", b"body");
264 assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars");
265 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
266 }
267
268 #[test]
269 fn signature_matches_the_reference_hmac() {
270 // The signer lives in the MNW server and the verifier lives here, so
271 // the exact bytes are a cross-repo contract that no test in either
272 // repo can catch by agreeing with itself. Pinned against an
273 // independent HMAC-SHA256 over the documented message layout:
274 // key "secret", message "100\nPOST\n/x\nn\nbody".
275 assert_eq!(
276 v2("secret", "100", "POST", "/x", "n", b"body"),
277 "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098"
278 );
279 }
280
281 #[test]
282 fn signature_changes_with_secret() {
283 // Pins that the secret feeds into the MAC key.
284 assert_ne!(
285 v2("alpha", "100", "POST", "/x", "n", b"body"),
286 v2("beta", "100", "POST", "/x", "n", b"body"),
287 );
288 }
289
290 #[test]
291 fn signature_changes_with_each_bound_field() {
292 // Pins that timestamp, method, path, nonce, and body each feed the MAC,
293 // a mutation dropping any field would collide one of these pairs.
294 let base = v2("s", "100", "POST", "/x", "n", b"body");
295 assert_ne!(
296 base,
297 v2("s", "101", "POST", "/x", "n", b"body"),
298 "timestamp bound"
299 );
300 assert_ne!(
301 base,
302 v2("s", "100", "GET", "/x", "n", b"body"),
303 "method bound"
304 );
305 assert_ne!(
306 base,
307 v2("s", "100", "POST", "/y", "n", b"body"),
308 "path bound"
309 );
310 assert_ne!(
311 base,
312 v2("s", "100", "POST", "/x", "m", b"body"),
313 "nonce bound"
314 );
315 assert_ne!(
316 base,
317 v2("s", "100", "POST", "/x", "n", b"body!"),
318 "body bound"
319 );
320 }
321
322 #[test]
323 fn signature_separators_are_newlines_not_concat() {
324 // Without the `\n` delimiters, field-boundary ambiguity would let two
325 // distinct messages collide (e.g. ts "1"+"00body" vs "10"+"0body").
326 assert_ne!(
327 v2("s", "1", "POST", "/x", "n", b"00body"),
328 v2("s", "10", "POST", "/x", "n", b"0body"),
329 "missing separator allows length-ambiguity collision"
330 );
331 }
332
333 // --- verify_signed_request: signature + freshness + mandatory nonce
334
335 #[test]
336 fn verify_accepts_valid_signature_at_now() {
337 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
338 assert!(
339 verify_signed_request(
340 "s",
341 Some("1000"),
342 Some(&sig),
343 "POST",
344 "/internal/x",
345 Some("abc"),
346 b"body",
347 1000
348 )
349 .is_ok()
350 );
351 }
352
353 #[test]
354 fn verify_rejects_wrong_signature() {
355 let mut sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
356 let first = sig.remove(0);
357 sig.insert(0, if first == '0' { '1' } else { '0' });
358 let (status, _) = verify_signed_request(
359 "s",
360 Some("1000"),
361 Some(&sig),
362 "POST",
363 "/internal/x",
364 Some("abc"),
365 b"body",
366 1000,
367 )
368 .unwrap_err();
369 assert_eq!(status, StatusCode::UNAUTHORIZED);
370 }
371
372 #[test]
373 fn verify_rejects_wrong_secret() {
374 let sig = v2("real-secret", "1000", "POST", "/internal/x", "abc", b"body");
375 assert!(
376 verify_signed_request(
377 "wrong-secret",
378 Some("1000"),
379 Some(&sig),
380 "POST",
381 "/internal/x",
382 Some("abc"),
383 b"body",
384 1000
385 )
386 .is_err()
387 );
388 }
389
390 #[test]
391 fn verify_rejects_tampered_body() {
392 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"original");
393 assert!(
394 verify_signed_request(
395 "s",
396 Some("1000"),
397 Some(&sig),
398 "POST",
399 "/internal/x",
400 Some("abc"),
401 b"tampered",
402 1000
403 )
404 .is_err()
405 );
406 }
407
408 #[test]
409 fn verify_rejects_wrong_method() {
410 let sig = v2("s", "1000", "GET", "/internal/x", "abc", b"body");
411 assert!(
412 verify_signed_request(
413 "s",
414 Some("1000"),
415 Some(&sig),
416 "POST",
417 "/internal/x",
418 Some("abc"),
419 b"body",
420 1000
421 )
422 .is_err()
423 );
424 }
425
426 #[test]
427 fn verify_rejects_wrong_path() {
428 let sig = v2("s", "1000", "POST", "/internal/a", "abc", b"body");
429 assert!(
430 verify_signed_request(
431 "s",
432 Some("1000"),
433 Some(&sig),
434 "POST",
435 "/internal/b",
436 Some("abc"),
437 b"body",
438 1000
439 )
440 .is_err()
441 );
442 }
443
444 #[test]
445 fn verify_rejects_wrong_nonce() {
446 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
447 assert!(
448 verify_signed_request(
449 "s",
450 Some("1000"),
451 Some(&sig),
452 "POST",
453 "/internal/x",
454 Some("zzz"),
455 b"body",
456 1000
457 )
458 .is_err()
459 );
460 }
461
462 #[test]
463 fn verify_at_window_boundary_accepts_inside_rejects_outside() {
464 // Asymmetric window: up to MAX_TIMESTAMP_AGE_SECS (60s) old, but only
465 // MAX_FUTURE_SKEW_SECS (5s) into the future. `>` is strict, so exactly
466 // at each boundary is accepted.
467 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
468 let check = |now| {
469 verify_signed_request(
470 "s",
471 Some("1000"),
472 Some(&sig),
473 "POST",
474 "/x",
475 Some("abc"),
476 b"abc",
477 now,
478 )
479 };
480 assert!(check(1060).is_ok(), "now-ts=60 accepted (age boundary)");
481 assert!(check(1061).is_err(), "now-ts=61 rejected (too old)");
482 assert!(
483 check(995).is_ok(),
484 "ts-now=5 accepted (future-skew boundary)"
485 );
486 assert!(check(994).is_err(), "ts-now=6 rejected (too far future)");
487 }
488
489 #[test]
490 fn verify_rejects_missing_nonce() {
491 // A request with no nonce is rejected outright, no v1 downgrade exists.
492 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
493 let (status, msg) = verify_signed_request(
494 "s",
495 Some("1000"),
496 Some(&sig),
497 "POST",
498 "/internal/x",
499 None,
500 b"body",
501 1000,
502 )
503 .unwrap_err();
504 assert_eq!(status, StatusCode::UNAUTHORIZED);
505 assert!(msg.contains("Nonce"), "expected nonce msg, got: {msg}");
506 }
507
508 #[test]
509 fn verify_rejects_missing_timestamp_header() {
510 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
511 let (status, msg) = verify_signed_request(
512 "s",
513 None,
514 Some(&sig),
515 "POST",
516 "/x",
517 Some("abc"),
518 b"abc",
519 1000,
520 )
521 .unwrap_err();
522 assert_eq!(status, StatusCode::UNAUTHORIZED);
523 assert!(msg.contains("Timestamp"));
524 }
525
526 #[test]
527 fn verify_rejects_missing_signature_header() {
528 let (status, msg) = verify_signed_request(
529 "s",
530 Some("1000"),
531 None,
532 "POST",
533 "/x",
534 Some("abc"),
535 b"abc",
536 1000,
537 )
538 .unwrap_err();
539 assert_eq!(status, StatusCode::UNAUTHORIZED);
540 assert!(msg.contains("Signature"));
541 }
542
543 #[test]
544 fn verify_rejects_unparseable_timestamp() {
545 let (status, msg) = verify_signed_request(
546 "s",
547 Some("not-an-int"),
548 Some("zz"),
549 "POST",
550 "/x",
551 Some("abc"),
552 b"",
553 1000,
554 )
555 .unwrap_err();
556 assert_eq!(status, StatusCode::UNAUTHORIZED);
557 assert!(msg.contains("Invalid timestamp"));
558 }
559
560 #[test]
561 fn verify_check_order_nonce_before_timestamp() {
562 // Nonce mandatory: a missing nonce rejects even with all else missing.
563 let (_, msg) =
564 verify_signed_request("s", None, None, "POST", "/x", None, b"", 1000).unwrap_err();
565 assert!(
566 msg.contains("Nonce"),
567 "expected nonce msg first, got: {msg}"
568 );
569 }
570
571 #[test]
572 fn verify_check_order_freshness_before_signature() {
573 // A stale timestamp must reject even when the sig is otherwise valid,
574 // catches a mutation running the freshness check after signature verify.
575 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
576 let (_, msg) = verify_signed_request(
577 "s",
578 Some("1000"),
579 Some(&sig),
580 "POST",
581 "/x",
582 Some("abc"),
583 b"abc",
584 9999,
585 )
586 .unwrap_err();
587 assert!(
588 msg.contains("Timestamp"),
589 "expected freshness msg, got: {msg}"
590 );
591 }
592
593 #[test]
594 fn record_nonce_rejects_replay_and_evicts_aged() {
595 // Unique nonces so the shared cache can't collide with other tests.
596 let n1 = "nonce-test-unique-aaa";
597 assert!(record_nonce(n1, 1_000_000), "first use accepted");
598 assert!(
599 !record_nonce(n1, 1_000_000),
600 "replay within window rejected"
601 );
602 // Past the freshness window, the entry is swept and the nonce is free
603 // again (a request that old is already rejected by the timestamp check).
604 assert!(
605 record_nonce(n1, 1_000_000 + MAX_TIMESTAMP_AGE_SECS + 1),
606 "aged nonce reusable"
607 );
608 }
609 }
610