Skip to main content

max / makenotwork

19.9 KB · 612 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. Freshness
200 /// is checked first. The module header carries why the nonce is mandatory and
201 /// what a missing one does.
202 ///
203 /// Headers are passed as `Option<&str>` so callers can extract them with any
204 /// strategy (axum `HeaderMap`, manual `Bytes`, tests).
205 ///
206 /// Nonce replay is NOT checked here (that is stateful); the caller records the
207 /// nonce via [`record_nonce`] after this returns Ok.
208 #[allow(clippy::too_many_arguments)]
209 pub(crate) fn verify_signed_request(
210 secret: &str,
211 timestamp_header: Option<&str>,
212 signature_header: Option<&str>,
213 method: &str,
214 path: &str,
215 nonce_header: Option<&str>,
216 body: &[u8],
217 now_unix: i64,
218 ) -> Result<(), (StatusCode, &'static str)> {
219 let nonce = nonce_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Nonce"))?;
220
221 let timestamp_str =
222 timestamp_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
223 let signature =
224 signature_header.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
225
226 check_freshness(timestamp_str, now_unix)?;
227
228 let expected = compute_internal_signature_v2(secret, timestamp_str, method, path, nonce, body);
229 if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
230 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
231 }
232 Ok(())
233 }
234
235 /// Constant-time byte comparison to prevent timing attacks.
236 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
237 if a.len() != b.len() {
238 return false;
239 }
240 a.iter()
241 .zip(b.iter())
242 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
243 == 0
244 }
245
246 #[cfg(test)]
247 mod tests {
248 use super::*;
249
250 #[test]
251 fn constant_time_eq_works() {
252 assert!(constant_time_eq(b"hello", b"hello"));
253 assert!(!constant_time_eq(b"hello", b"world"));
254 assert!(!constant_time_eq(b"hello", b"hell"));
255 }
256
257 // --- compute_internal_signature_v2 pins HMAC message construction
258
259 fn v2(secret: &str, ts: &str, method: &str, path: &str, nonce: &str, body: &[u8]) -> String {
260 compute_internal_signature_v2(secret, ts, method, path, nonce, body)
261 }
262
263 #[test]
264 fn signature_is_64_hex_chars() {
265 let sig = v2("secret", "100", "POST", "/x", "n", b"body");
266 assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars");
267 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
268 }
269
270 #[test]
271 fn signature_matches_the_reference_hmac() {
272 // The signer lives in the MNW server and the verifier lives here, so
273 // the exact bytes are a cross-repo contract that no test in either
274 // repo can catch by agreeing with itself. Pinned against an
275 // independent HMAC-SHA256 over the documented message layout:
276 // key "secret", message "100\nPOST\n/x\nn\nbody".
277 assert_eq!(
278 v2("secret", "100", "POST", "/x", "n", b"body"),
279 "0e97f90cb4e4ca7aaa5499e67a22fb5b7ad45ad3cc966f37d225f39da2728098"
280 );
281 }
282
283 #[test]
284 fn signature_changes_with_secret() {
285 // Pins that the secret feeds into the MAC key.
286 assert_ne!(
287 v2("alpha", "100", "POST", "/x", "n", b"body"),
288 v2("beta", "100", "POST", "/x", "n", b"body"),
289 );
290 }
291
292 #[test]
293 fn signature_changes_with_each_bound_field() {
294 // Pins that timestamp, method, path, nonce, and body each feed the MAC,
295 // a mutation dropping any field would collide one of these pairs.
296 let base = v2("s", "100", "POST", "/x", "n", b"body");
297 assert_ne!(
298 base,
299 v2("s", "101", "POST", "/x", "n", b"body"),
300 "timestamp bound"
301 );
302 assert_ne!(
303 base,
304 v2("s", "100", "GET", "/x", "n", b"body"),
305 "method bound"
306 );
307 assert_ne!(
308 base,
309 v2("s", "100", "POST", "/y", "n", b"body"),
310 "path bound"
311 );
312 assert_ne!(
313 base,
314 v2("s", "100", "POST", "/x", "m", b"body"),
315 "nonce bound"
316 );
317 assert_ne!(
318 base,
319 v2("s", "100", "POST", "/x", "n", b"body!"),
320 "body bound"
321 );
322 }
323
324 #[test]
325 fn signature_separators_are_newlines_not_concat() {
326 // Without the `\n` delimiters, field-boundary ambiguity would let two
327 // distinct messages collide (e.g. ts "1"+"00body" vs "10"+"0body").
328 assert_ne!(
329 v2("s", "1", "POST", "/x", "n", b"00body"),
330 v2("s", "10", "POST", "/x", "n", b"0body"),
331 "missing separator allows length-ambiguity collision"
332 );
333 }
334
335 // --- verify_signed_request: signature + freshness + mandatory nonce
336
337 #[test]
338 fn verify_accepts_valid_signature_at_now() {
339 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
340 assert!(
341 verify_signed_request(
342 "s",
343 Some("1000"),
344 Some(&sig),
345 "POST",
346 "/internal/x",
347 Some("abc"),
348 b"body",
349 1000
350 )
351 .is_ok()
352 );
353 }
354
355 #[test]
356 fn verify_rejects_wrong_signature() {
357 let mut sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
358 let first = sig.remove(0);
359 sig.insert(0, if first == '0' { '1' } else { '0' });
360 let (status, _) = verify_signed_request(
361 "s",
362 Some("1000"),
363 Some(&sig),
364 "POST",
365 "/internal/x",
366 Some("abc"),
367 b"body",
368 1000,
369 )
370 .unwrap_err();
371 assert_eq!(status, StatusCode::UNAUTHORIZED);
372 }
373
374 #[test]
375 fn verify_rejects_wrong_secret() {
376 let sig = v2("real-secret", "1000", "POST", "/internal/x", "abc", b"body");
377 assert!(
378 verify_signed_request(
379 "wrong-secret",
380 Some("1000"),
381 Some(&sig),
382 "POST",
383 "/internal/x",
384 Some("abc"),
385 b"body",
386 1000
387 )
388 .is_err()
389 );
390 }
391
392 #[test]
393 fn verify_rejects_tampered_body() {
394 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"original");
395 assert!(
396 verify_signed_request(
397 "s",
398 Some("1000"),
399 Some(&sig),
400 "POST",
401 "/internal/x",
402 Some("abc"),
403 b"tampered",
404 1000
405 )
406 .is_err()
407 );
408 }
409
410 #[test]
411 fn verify_rejects_wrong_method() {
412 let sig = v2("s", "1000", "GET", "/internal/x", "abc", b"body");
413 assert!(
414 verify_signed_request(
415 "s",
416 Some("1000"),
417 Some(&sig),
418 "POST",
419 "/internal/x",
420 Some("abc"),
421 b"body",
422 1000
423 )
424 .is_err()
425 );
426 }
427
428 #[test]
429 fn verify_rejects_wrong_path() {
430 let sig = v2("s", "1000", "POST", "/internal/a", "abc", b"body");
431 assert!(
432 verify_signed_request(
433 "s",
434 Some("1000"),
435 Some(&sig),
436 "POST",
437 "/internal/b",
438 Some("abc"),
439 b"body",
440 1000
441 )
442 .is_err()
443 );
444 }
445
446 #[test]
447 fn verify_rejects_wrong_nonce() {
448 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
449 assert!(
450 verify_signed_request(
451 "s",
452 Some("1000"),
453 Some(&sig),
454 "POST",
455 "/internal/x",
456 Some("zzz"),
457 b"body",
458 1000
459 )
460 .is_err()
461 );
462 }
463
464 #[test]
465 fn verify_at_window_boundary_accepts_inside_rejects_outside() {
466 // Asymmetric window: up to MAX_TIMESTAMP_AGE_SECS (60s) old, but only
467 // MAX_FUTURE_SKEW_SECS (5s) into the future. `>` is strict, so exactly
468 // at each boundary is accepted.
469 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
470 let check = |now| {
471 verify_signed_request(
472 "s",
473 Some("1000"),
474 Some(&sig),
475 "POST",
476 "/x",
477 Some("abc"),
478 b"abc",
479 now,
480 )
481 };
482 assert!(check(1060).is_ok(), "now-ts=60 accepted (age boundary)");
483 assert!(check(1061).is_err(), "now-ts=61 rejected (too old)");
484 assert!(
485 check(995).is_ok(),
486 "ts-now=5 accepted (future-skew boundary)"
487 );
488 assert!(check(994).is_err(), "ts-now=6 rejected (too far future)");
489 }
490
491 #[test]
492 fn verify_rejects_missing_nonce() {
493 // A request with no nonce is rejected outright, no v1 downgrade exists.
494 let sig = v2("s", "1000", "POST", "/internal/x", "abc", b"body");
495 let (status, msg) = verify_signed_request(
496 "s",
497 Some("1000"),
498 Some(&sig),
499 "POST",
500 "/internal/x",
501 None,
502 b"body",
503 1000,
504 )
505 .unwrap_err();
506 assert_eq!(status, StatusCode::UNAUTHORIZED);
507 assert!(msg.contains("Nonce"), "expected nonce msg, got: {msg}");
508 }
509
510 #[test]
511 fn verify_rejects_missing_timestamp_header() {
512 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
513 let (status, msg) = verify_signed_request(
514 "s",
515 None,
516 Some(&sig),
517 "POST",
518 "/x",
519 Some("abc"),
520 b"abc",
521 1000,
522 )
523 .unwrap_err();
524 assert_eq!(status, StatusCode::UNAUTHORIZED);
525 assert!(msg.contains("Timestamp"));
526 }
527
528 #[test]
529 fn verify_rejects_missing_signature_header() {
530 let (status, msg) = verify_signed_request(
531 "s",
532 Some("1000"),
533 None,
534 "POST",
535 "/x",
536 Some("abc"),
537 b"abc",
538 1000,
539 )
540 .unwrap_err();
541 assert_eq!(status, StatusCode::UNAUTHORIZED);
542 assert!(msg.contains("Signature"));
543 }
544
545 #[test]
546 fn verify_rejects_unparseable_timestamp() {
547 let (status, msg) = verify_signed_request(
548 "s",
549 Some("not-an-int"),
550 Some("zz"),
551 "POST",
552 "/x",
553 Some("abc"),
554 b"",
555 1000,
556 )
557 .unwrap_err();
558 assert_eq!(status, StatusCode::UNAUTHORIZED);
559 assert!(msg.contains("Invalid timestamp"));
560 }
561
562 #[test]
563 fn verify_check_order_nonce_before_timestamp() {
564 // Nonce mandatory: a missing nonce rejects even with all else missing.
565 let (_, msg) =
566 verify_signed_request("s", None, None, "POST", "/x", None, b"", 1000).unwrap_err();
567 assert!(
568 msg.contains("Nonce"),
569 "expected nonce msg first, got: {msg}"
570 );
571 }
572
573 #[test]
574 fn verify_check_order_freshness_before_signature() {
575 // A stale timestamp must reject even when the sig is otherwise valid,
576 // catches a mutation running the freshness check after signature verify.
577 let sig = v2("s", "1000", "POST", "/x", "abc", b"abc");
578 let (_, msg) = verify_signed_request(
579 "s",
580 Some("1000"),
581 Some(&sig),
582 "POST",
583 "/x",
584 Some("abc"),
585 b"abc",
586 9999,
587 )
588 .unwrap_err();
589 assert!(
590 msg.contains("Timestamp"),
591 "expected freshness msg, got: {msg}"
592 );
593 }
594
595 #[test]
596 fn record_nonce_rejects_replay_and_evicts_aged() {
597 // Unique nonces so the shared cache can't collide with other tests.
598 let n1 = "nonce-test-unique-aaa";
599 assert!(record_nonce(n1, 1_000_000), "first use accepted");
600 assert!(
601 !record_nonce(n1, 1_000_000),
602 "replay within window rejected"
603 );
604 // Past the freshness window, the entry is swept and the nonce is free
605 // again (a request that old is already rejected by the timestamp check).
606 assert!(
607 record_nonce(n1, 1_000_000 + MAX_TIMESTAMP_AGE_SECS + 1),
608 "aged nonce reusable"
609 );
610 }
611 }
612