| 35 |
35 |
|
StatusCode::SERVICE_UNAVAILABLE.into_response()
|
| 36 |
36 |
|
})?;
|
| 37 |
37 |
|
|
| 38 |
|
- |
let timestamp_str = req
|
|
38 |
+ |
let timestamp_header = req
|
| 39 |
39 |
|
.headers()
|
| 40 |
40 |
|
.get("X-Internal-Timestamp")
|
| 41 |
41 |
|
.and_then(|v| v.to_str().ok())
|
| 42 |
|
- |
.ok_or_else(|| {
|
| 43 |
|
- |
(StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp").into_response()
|
| 44 |
|
- |
})?
|
| 45 |
|
- |
.to_string();
|
| 46 |
|
- |
|
| 47 |
|
- |
let signature = req
|
|
42 |
+ |
.map(str::to_string);
|
|
43 |
+ |
let signature_header = req
|
| 48 |
44 |
|
.headers()
|
| 49 |
45 |
|
.get("X-Internal-Signature")
|
| 50 |
46 |
|
.and_then(|v| v.to_str().ok())
|
| 51 |
|
- |
.ok_or_else(|| {
|
| 52 |
|
- |
(StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature").into_response()
|
| 53 |
|
- |
})?
|
| 54 |
|
- |
.to_string();
|
|
47 |
+ |
.map(str::to_string);
|
| 55 |
48 |
|
|
| 56 |
|
- |
// Verify timestamp freshness
|
| 57 |
|
- |
let timestamp: i64 = timestamp_str.parse().map_err(|_| {
|
| 58 |
|
- |
(StatusCode::UNAUTHORIZED, "Invalid timestamp").into_response()
|
| 59 |
|
- |
})?;
|
| 60 |
|
- |
|
| 61 |
|
- |
let now = chrono::Utc::now().timestamp();
|
| 62 |
|
- |
if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
|
| 63 |
|
- |
return Err(
|
| 64 |
|
- |
(StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future")
|
| 65 |
|
- |
.into_response(),
|
| 66 |
|
- |
);
|
| 67 |
|
- |
}
|
| 68 |
|
- |
|
| 69 |
|
- |
// Read body
|
| 70 |
49 |
|
let body = Bytes::from_request(req, state).await.map_err(|e| {
|
| 71 |
50 |
|
tracing::error!(error = %e, "failed to read request body");
|
| 72 |
51 |
|
StatusCode::BAD_REQUEST.into_response()
|
| 73 |
52 |
|
})?;
|
| 74 |
53 |
|
|
| 75 |
|
- |
// Verify HMAC
|
| 76 |
|
- |
let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(&body).unwrap_or(""));
|
| 77 |
|
- |
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
| 78 |
|
- |
.expect("HMAC-SHA256 accepts any key length");
|
| 79 |
|
- |
mac.update(message.as_bytes());
|
| 80 |
|
- |
let expected = hex::encode(mac.finalize().into_bytes());
|
| 81 |
|
- |
|
| 82 |
|
- |
if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
|
| 83 |
|
- |
return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response());
|
| 84 |
|
- |
}
|
|
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())?;
|
| 85 |
62 |
|
|
| 86 |
63 |
|
Ok(InternalAuth(body))
|
| 87 |
64 |
|
}
|
| 88 |
65 |
|
}
|
| 89 |
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 |
+ |
|
| 90 |
110 |
|
/// Verify HMAC-SHA256 headers on an internal request (for GET endpoints without a body extractor).
|
| 91 |
111 |
|
pub fn verify_hmac_headers(
|
| 92 |
112 |
|
state: &AppState,
|
| 102 |
122 |
|
(StatusCode::SERVICE_UNAVAILABLE, "Service unavailable")
|
| 103 |
123 |
|
})?;
|
| 104 |
124 |
|
|
| 105 |
|
- |
let timestamp_str = headers
|
|
125 |
+ |
let timestamp_header = headers
|
| 106 |
126 |
|
.get("X-Internal-Timestamp")
|
| 107 |
|
- |
.and_then(|v| v.to_str().ok())
|
| 108 |
|
- |
.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Timestamp"))?;
|
| 109 |
|
- |
|
| 110 |
|
- |
let signature = headers
|
|
127 |
+ |
.and_then(|v| v.to_str().ok());
|
|
128 |
+ |
let signature_header = headers
|
| 111 |
129 |
|
.get("X-Internal-Signature")
|
| 112 |
|
- |
.and_then(|v| v.to_str().ok())
|
| 113 |
|
- |
.ok_or((StatusCode::UNAUTHORIZED, "Missing X-Internal-Signature"))?;
|
|
130 |
+ |
.and_then(|v| v.to_str().ok());
|
| 114 |
131 |
|
|
| 115 |
|
- |
let timestamp: i64 = timestamp_str
|
| 116 |
|
- |
.parse()
|
| 117 |
|
- |
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid timestamp"))?;
|
| 118 |
|
- |
|
| 119 |
|
- |
let now = chrono::Utc::now().timestamp();
|
| 120 |
|
- |
if (now - timestamp).abs() > MAX_TIMESTAMP_AGE_SECS {
|
| 121 |
|
- |
return Err((StatusCode::UNAUTHORIZED, "Timestamp too old or too far in the future"));
|
| 122 |
|
- |
}
|
| 123 |
|
- |
|
| 124 |
|
- |
let message = format!("{}\n{}", timestamp_str, std::str::from_utf8(body).unwrap_or(""));
|
| 125 |
|
- |
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
|
| 126 |
|
- |
.expect("HMAC-SHA256 accepts any key length");
|
| 127 |
|
- |
mac.update(message.as_bytes());
|
| 128 |
|
- |
let expected = hex::encode(mac.finalize().into_bytes());
|
| 129 |
|
- |
|
| 130 |
|
- |
if !constant_time_eq(expected.as_bytes(), signature.as_bytes()) {
|
| 131 |
|
- |
return Err((StatusCode::UNAUTHORIZED, "Invalid signature"));
|
| 132 |
|
- |
}
|
| 133 |
|
- |
|
| 134 |
|
- |
Ok(())
|
|
132 |
+ |
verify_internal_signature(
|
|
133 |
+ |
secret,
|
|
134 |
+ |
timestamp_header,
|
|
135 |
+ |
signature_header,
|
|
136 |
+ |
body,
|
|
137 |
+ |
chrono::Utc::now().timestamp(),
|
|
138 |
+ |
)
|
| 135 |
139 |
|
}
|
| 136 |
140 |
|
|
| 137 |
141 |
|
/// Constant-time byte comparison to prevent timing attacks.
|
| 174 |
178 |
|
|
| 175 |
179 |
|
assert!(constant_time_eq(sig.as_bytes(), expected.as_bytes()));
|
| 176 |
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 |
+ |
}
|
| 177 |
342 |
|
}
|