Skip to main content

max / makenotwork

12.0 KB · 343 lines History Blame Raw
1 //! HMAC-SHA256 authentication for internal API requests from MNW.
2 //!
3 //! MNW signs requests with `HMAC-SHA256(timestamp + "\n" + body, secret)`.
4 //! The signature and timestamp are sent in `X-Internal-Signature` and
5 //! `X-Internal-Timestamp` headers. Requests older than 60 seconds are rejected.
6
7 use axum::{
8 body::Bytes,
9 extract::{FromRequest, Request},
10 http::StatusCode,
11 response::{IntoResponse, Response},
12 };
13 use hmac::{Hmac, Mac};
14 use sha2::Sha256;
15
16 use crate::AppState;
17
18 /// Maximum age (in seconds) for an internal request timestamp before it's rejected.
19 const MAX_TIMESTAMP_AGE_SECS: i64 = 60;
20
21 /// Axum extractor that validates HMAC-SHA256 signatures on internal API requests.
22 /// Extracts the raw request body as `Bytes` after successful verification.
23 pub struct InternalAuth(pub Bytes);
24
25 impl FromRequest<AppState> for InternalAuth {
26 type Rejection = Response;
27
28 async fn from_request(req: Request, state: &AppState) -> Result<Self, Self::Rejection> {
29 let secret = state
30 .config
31 .internal_shared_secret
32 .as_deref()
33 .ok_or_else(|| {
34 tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured");
35 StatusCode::SERVICE_UNAVAILABLE.into_response()
36 })?;
37
38 let timestamp_header = req
39 .headers()
40 .get("X-Internal-Timestamp")
41 .and_then(|v| v.to_str().ok())
42 .map(str::to_string);
43 let signature_header = req
44 .headers()
45 .get("X-Internal-Signature")
46 .and_then(|v| v.to_str().ok())
47 .map(str::to_string);
48
49 let body = Bytes::from_request(req, state).await.map_err(|e| {
50 tracing::error!(error = %e, "failed to read request body");
51 StatusCode::BAD_REQUEST.into_response()
52 })?;
53
54 verify_internal_signature(
55 secret,
56 timestamp_header.as_deref(),
57 signature_header.as_deref(),
58 &body,
59 chrono::Utc::now().timestamp(),
60 )
61 .map_err(|(status, msg)| (status, msg).into_response())?;
62
63 Ok(InternalAuth(body))
64 }
65 }
66
67 /// Compute the hex-encoded HMAC-SHA256 signature for an internal request.
68 /// `secret` may be any length (HMAC-SHA256 accepts any key length).
69 pub(crate) fn compute_internal_signature(secret: &str, timestamp_str: &str, body: &[u8]) -> String {
70 let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
71 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
72 .expect("HMAC-SHA256 accepts any key length");
73 mac.update(message.as_bytes());
74 hex::encode(mac.finalize().into_bytes())
75 }
76
77 /// Pure verification: validate timestamp freshness against `now_unix`, then
78 /// recompute the signature and constant-time compare.
79 ///
80 /// Headers are passed as `Option<&str>` so callers can extract them with any
81 /// strategy (axum `HeaderMap`, manual `Bytes`, tests).
82 pub(crate) fn verify_internal_signature(
83 secret: &str,
84 timestamp_header: Option<&str>,
85 signature_header: Option<&str>,
86 body: &[u8],
87 now_unix: i64,
88 ) -> Result<(), (StatusCode, &'static str)> {
89 let timestamp_str = timestamp_header
90 .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
91 let signature = signature_header
92 .ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
93
94 let timestamp: i64 = timestamp_str
95 .parse()
96 .map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
97
98 if (now_unix - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
99 return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
100 }
101
102 let expected = compute_internal_signature(secret, timestamp_str, body);
103
104 if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
105 return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
106 }
107 Ok(())
108 }
109
110 /// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor).
111 pub fn verify_hmac_headers(
112 state: &AppState,
113 headers: &axum::http::HeaderMap,
114 body: &[u8],
115 ) -> Result<(), (StatusCode, &'static str)> {
116 let secret = state
117 .config
118 .internal_shared_secret
119 .as_deref()
120 .ok_or_else(|| {
121 tracing::warn!("internal API called but INTERNAL_SHARED_SECRET not configured");
122 (StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
123 })?;
124
125 let timestamp_header = headers
126 .get("X-Internal-Timestamp")
127 .and_then(|v| v.to_str().ok());
128 let signature_header = headers
129 .get("X-Internal-Signature")
130 .and_then(|v| v.to_str().ok());
131
132 verify_internal_signature(
133 secret,
134 timestamp_header,
135 signature_header,
136 body,
137 chrono::Utc::now().timestamp(),
138 )
139 }
140
141 /// Constant-time byte comparison to prevent timing attacks.
142 fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
143 if a.len() != b.len() {
144 return false;
145 }
146 a.iter()
147 .zip(b.iter())
148 .fold(0u8, |acc, (x, y)| acc | (x ^ y))
149 == 0
150 }
151
152 #[cfg(test)]
153 mod tests {
154 use super::*;
155
156 #[test]
157 fn constant_time_eq_works() {
158 assert!(constant_time_eq(b"hello", b"hello"));
159 assert!(!constant_time_eq(b"hello", b"world"));
160 assert!(!constant_time_eq(b"hello", b"hell"));
161 }
162
163 #[test]
164 fn hmac_signature_roundtrip() {
165 let secret = "test-secret";
166 let timestamp = "1234567890";
167 let body = r#"{"name":"test"}"#;
168 let message = format!("{}\n{}", timestamp, body);
169
170 let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
171 mac.update(message.as_bytes());
172 let sig = hex::encode(mac.finalize().into_bytes());
173
174 // Verify the same computation matches
175 let mut mac2 = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
176 mac2.update(message.as_bytes());
177 let expected = hex::encode(mac2.finalize().into_bytes());
178
179 assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes()));
180 }
181
182 // ── compute_internal_signature pins HMAC message construction ──
183
184 #[test]
185 fn signature_is_64_hex_chars() {
186 let sig = compute_internal_signature("secret", "100", b"body");
187 assert_eq!(sig.len(), 64, "SHA-256 hex is 64 chars");
188 assert!(sig.chars().all(|c| c.is_ascii_hexdigit()));
189 }
190
191 #[test]
192 fn signature_changes_with_secret() {
193 // Pins that the secret feeds into the MAC key.
194 let s1 = compute_internal_signature("alpha", "100", b"body");
195 let s2 = compute_internal_signature("beta", "100", b"body");
196 assert_ne!(s1, s2);
197 }
198
199 #[test]
200 fn signature_changes_with_timestamp() {
201 // Pins the `format!("{}\n{}", timestamp_str, body)` ordering — a
202 // mutation that drops the timestamp or swaps the order would make
203 // these two signatures match.
204 let s1 = compute_internal_signature("secret", "100", b"body");
205 let s2 = compute_internal_signature("secret", "101", b"body");
206 assert_ne!(s1, s2);
207 }
208
209 #[test]
210 fn signature_changes_with_body() {
211 let s1 = compute_internal_signature("secret", "100", b"hello");
212 let s2 = compute_internal_signature("secret", "100", b"hello!");
213 assert_ne!(s1, s2);
214 }
215
216 #[test]
217 fn signature_separator_is_newline_not_concat() {
218 // Pins `format!("{}\n{}", ...)` — without the `\n`, "1" + "00body"
219 // would collide with "10" + "0body".
220 let collision_a = compute_internal_signature("secret", "1", b"00body");
221 let collision_b = compute_internal_signature("secret", "10", b"0body");
222 assert_ne!(
223 collision_a, collision_b,
224 "missing newline separator allows length-ambiguity collision"
225 );
226 }
227
228 // ── verify_internal_signature freshness + signature check ──
229
230 fn valid(secret: &str, ts: &str, body: &[u8]) -> String {
231 compute_internal_signature(secret, ts, body)
232 }
233
234 #[test]
235 fn verify_accepts_valid_signature_at_now() {
236 let secret = "s";
237 let body = b"abc";
238 let ts = "1000";
239 let sig = valid(secret, ts, body);
240 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).is_ok());
241 }
242
243 #[test]
244 fn verify_rejects_wrong_signature() {
245 let secret = "s";
246 let body = b"abc";
247 let ts = "1000";
248 // Tamper with one hex char.
249 let mut sig = valid(secret, ts, body);
250 let first = sig.remove(0);
251 sig.insert(0, if first == '0' { '1' } else { '0' });
252 let (status, _) =
253 verify_internal_signature(secret, Some(ts), Some(&sig), body, 1000).unwrap_err();
254 assert_eq!(status, StatusCode::UNAUTHORIZED);
255 }
256
257 #[test]
258 fn verify_rejects_wrong_secret() {
259 let body = b"abc";
260 let ts = "1000";
261 let sig = valid("real-secret", ts, body);
262 assert!(
263 verify_internal_signature("wrong-secret", Some(ts), Some(&sig), body, 1000).is_err()
264 );
265 }
266
267 #[test]
268 fn verify_rejects_tampered_body() {
269 let secret = "s";
270 let ts = "1000";
271 let sig = valid(secret, ts, b"original");
272 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), b"tampered", 1000).is_err());
273 }
274
275 #[test]
276 fn verify_at_window_boundary_accepts_inside_rejects_outside() {
277 // Pins `(now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS` (60s).
278 // Exactly at the boundary (abs diff == 60) must be accepted (since `>`
279 // is strict). One second past must be rejected.
280 let secret = "s";
281 let body = b"abc";
282 let ts = "1000";
283 let sig = valid(secret, ts, body);
284
285 // diff = 60 → accepted
286 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1060).is_ok());
287 // diff = 61 → rejected (too old)
288 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 1061).is_err());
289 // diff = -60 → accepted
290 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 940).is_ok());
291 // diff = -61 → rejected (too far in future)
292 assert!(verify_internal_signature(secret, Some(ts), Some(&sig), body, 939).is_err());
293 }
294
295 #[test]
296 fn verify_rejects_missing_timestamp_header() {
297 let secret = "s";
298 let body = b"abc";
299 let sig = valid(secret, "1000", body);
300 let (status, msg) =
301 verify_internal_signature(secret, None, Some(&sig), body, 1000).unwrap_err();
302 assert_eq!(status, StatusCode::UNAUTHORIZED);
303 assert!(msg.contains("Timestamp"));
304 }
305
306 #[test]
307 fn verify_rejects_missing_signature_header() {
308 let (status, msg) =
309 verify_internal_signature("s", Some("1000"), None, b"abc", 1000).unwrap_err();
310 assert_eq!(status, StatusCode::UNAUTHORIZED);
311 assert!(msg.contains("Signature"));
312 }
313
314 #[test]
315 fn verify_rejects_unparseable_timestamp() {
316 let (status, msg) =
317 verify_internal_signature("s", Some("not-an-int"), Some("zz"), b"", 1000).unwrap_err();
318 assert_eq!(status, StatusCode::UNAUTHORIZED);
319 assert!(msg.contains("Invalid timestamp"));
320 }
321
322 #[test]
323 fn verify_check_order_missing_timestamp_first() {
324 // Both headers missing: timestamp check fires first.
325 let (_, msg) = verify_internal_signature("s", None, None, b"", 1000).unwrap_err();
326 assert!(msg.contains("Timestamp"), "expected timestamp msg first, got: {msg}");
327 }
328
329 #[test]
330 fn verify_check_order_freshness_before_signature() {
331 // A stale timestamp must reject even when the (otherwise valid) sig
332 // matches. Catches a mutation that runs the freshness check after
333 // signature verification.
334 let secret = "s";
335 let body = b"abc";
336 let ts = "1000";
337 let sig = valid(secret, ts, body);
338 let (_, msg) =
339 verify_internal_signature(secret, Some(ts), Some(&sig), body, 9999).unwrap_err();
340 assert!(msg.contains("Timestamp"), "expected freshness msg, got: {msg}");
341 }
342 }
343