Skip to main content

max / makenotwork

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