1use crate::compare::ash_timing_safe_equal;
37use crate::errors::{AshError, AshErrorCode, InternalReason};
38use crate::headers::{HeaderMapView, HDR_BODY_HASH, HDR_PROOF, HDR_TIMESTAMP};
39use crate::proof::{ash_build_proof, ash_derive_client_secret, ash_validate_timestamp_format};
40use crate::validate::ash_validate_nonce;
41
42pub struct VerifyRequestInput<'a, H: HeaderMapView> {
54 pub headers: &'a H,
56
57 pub method: &'a str,
59
60 pub path: &'a str,
62
63 pub raw_query: &'a str,
65
66 pub canonical_body: &'a str,
68
69 pub nonce: &'a str,
71
72 pub context_id: &'a str,
74
75 pub max_age_seconds: u64,
77
78 pub clock_skew_seconds: u64,
80}
81
82pub struct VerifyResult {
86 pub ok: bool,
88
89 pub error: Option<AshError>,
91
92 pub meta: Option<VerifyMeta>,
94}
95
96pub struct VerifyMeta {
98 pub canonical_query: String,
100
101 pub computed_body_hash: String,
103
104 pub binding: String,
106}
107
108impl VerifyResult {
109 fn fail(error: AshError) -> Self {
110 Self {
111 ok: false,
112 error: Some(error),
113 meta: None,
114 }
115 }
116
117 fn success(meta: Option<VerifyMeta>) -> Self {
118 Self {
119 ok: true,
120 error: None,
121 meta,
122 }
123 }
124}
125
126pub fn verify_incoming_request<H: HeaderMapView>(input: &VerifyRequestInput<'_, H>) -> VerifyResult {
186 let ts = match extract_single_header(input.headers, HDR_TIMESTAMP) {
188 Ok(v) => v,
189 Err(e) => return VerifyResult::fail(e),
190 };
191
192 let header_body_hash = match extract_single_header(input.headers, HDR_BODY_HASH) {
193 Ok(v) => v,
194 Err(e) => return VerifyResult::fail(e),
195 };
196
197 let proof = match extract_single_header(input.headers, HDR_PROOF) {
198 Ok(v) => v,
199 Err(e) => return VerifyResult::fail(e),
200 };
201
202 if let Err(e) = ash_validate_timestamp_format(&ts) {
204 return VerifyResult::fail(e);
205 }
206
207 if let Err(e) = validate_timestamp_with_reference(
209 &ts,
210 input.max_age_seconds,
211 input.clock_skew_seconds,
212 ) {
213 return VerifyResult::fail(e);
214 }
215
216 if let Err(e) = ash_validate_nonce(input.nonce) {
218 return VerifyResult::fail(e);
219 }
220
221 let binding = match crate::ash_normalize_binding(input.method, input.path, input.raw_query) {
223 Ok(b) => b,
224 Err(e) => return VerifyResult::fail(e),
225 };
226
227 let computed_body_hash = crate::proof::ash_hash_body(input.canonical_body);
229
230 if !ash_timing_safe_equal(computed_body_hash.as_bytes(), header_body_hash.as_bytes()) {
232 return VerifyResult::fail(AshError::with_reason(
233 AshErrorCode::ValidationError,
234 InternalReason::General,
235 "Body hash mismatch",
236 ));
237 }
238
239 let client_secret = match ash_derive_client_secret(input.nonce, input.context_id, &binding) {
241 Ok(s) => s,
242 Err(e) => return VerifyResult::fail(e),
243 };
244
245 let expected_proof = match ash_build_proof(&client_secret, &ts, &binding, &computed_body_hash) {
246 Ok(p) => p,
247 Err(e) => return VerifyResult::fail(e),
248 };
249
250 if !ash_timing_safe_equal(expected_proof.as_bytes(), proof.as_bytes()) {
251 return VerifyResult::fail(AshError::new(
252 AshErrorCode::ProofInvalid,
253 "Proof verification failed",
254 ));
255 }
256
257 let meta = if cfg!(debug_assertions) {
259 Some(VerifyMeta {
260 canonical_query: input.raw_query.to_string(),
261 computed_body_hash,
262 binding,
263 })
264 } else {
265 None
266 };
267
268 VerifyResult::success(meta)
269}
270
271fn extract_single_header(h: &impl HeaderMapView, name: &'static str) -> Result<String, AshError> {
276 let vals = h.get_all_ci(name);
277
278 if vals.is_empty() {
279 return Err(
280 AshError::with_reason(
281 AshErrorCode::ValidationError,
282 InternalReason::HdrMissing,
283 format!("Required header '{}' is missing", name),
284 )
285 .with_detail("header", name),
286 );
287 }
288 if vals.len() > 1 {
289 return Err(
290 AshError::with_reason(
291 AshErrorCode::ValidationError,
292 InternalReason::HdrMultiValue,
293 format!("Header '{}' must have exactly one value, got {}", name, vals.len()),
294 )
295 .with_detail("header", name)
296 .with_detail("count", vals.len().to_string()),
297 );
298 }
299
300 let v = vals[0].trim();
301 if v.chars().any(|c| c == '\r' || c == '\n' || c.is_control()) {
302 return Err(
303 AshError::with_reason(
304 AshErrorCode::ValidationError,
305 InternalReason::HdrInvalidChars,
306 format!("Header '{}' contains invalid characters", name),
307 )
308 .with_detail("header", name),
309 );
310 }
311
312 Ok(v.to_string())
313}
314
315fn validate_timestamp_with_reference(
318 timestamp: &str,
319 max_age_seconds: u64,
320 clock_skew_seconds: u64,
321) -> Result<(), AshError> {
322 crate::proof::ash_validate_timestamp(timestamp, max_age_seconds, clock_skew_seconds)
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328
329 struct TestHeaders(Vec<(String, String)>);
330
331 impl HeaderMapView for TestHeaders {
332 fn get_all_ci(&self, name: &str) -> Vec<&str> {
333 let n = name.to_ascii_lowercase();
334 self.0
335 .iter()
336 .filter(|(k, _)| k.to_ascii_lowercase() == n)
337 .map(|(_, v)| v.as_str())
338 .collect()
339 }
340 }
341
342 fn now_ts() -> String {
343 use std::time::{SystemTime, UNIX_EPOCH};
344 SystemTime::now()
345 .duration_since(UNIX_EPOCH)
346 .unwrap()
347 .as_secs()
348 .to_string()
349 }
350
351 fn make_valid_request() -> (TestHeaders, String, String) {
352 let nonce = "0123456789abcdef0123456789abcdef";
353 let context_id = "ctx_test123";
354 let binding = "POST|/api/transfer|";
355 let timestamp = now_ts();
356 let canonical_body = r#"{"amount":100}"#;
357 let body_hash = crate::proof::ash_hash_body(canonical_body);
358
359 let client_secret =
360 ash_derive_client_secret(nonce, context_id, binding).unwrap();
361 let proof =
362 ash_build_proof(&client_secret, ×tamp, binding, &body_hash).unwrap();
363
364 let headers = TestHeaders(vec![
365 ("x-ash-ts".into(), timestamp),
366 ("x-ash-body-hash".into(), body_hash),
367 ("x-ash-proof".into(), proof),
368 ]);
369
370 (headers, canonical_body.to_string(), nonce.to_string())
371 }
372
373 #[test]
374 fn test_valid_request_passes() {
375 let (headers, canonical_body, nonce) = make_valid_request();
376
377 let input = VerifyRequestInput {
378 headers: &headers,
379 method: "POST",
380 path: "/api/transfer",
381 raw_query: "",
382 canonical_body: &canonical_body,
383 nonce: &nonce,
384 context_id: "ctx_test123",
385 max_age_seconds: 300,
386 clock_skew_seconds: 60,
387 };
388
389 let result = verify_incoming_request(&input);
390 assert!(result.ok, "Expected ok, got error: {:?}", result.error);
391 }
392
393 #[test]
394 fn test_missing_timestamp_fails() {
395 let headers = TestHeaders(vec![
396 ("x-ash-body-hash".into(), "a".repeat(64)),
397 ("x-ash-proof".into(), "b".repeat(64)),
398 ]);
399
400 let input = VerifyRequestInput {
401 headers: &headers,
402 method: "POST",
403 path: "/api/test",
404 raw_query: "",
405 canonical_body: "{}",
406 nonce: "0123456789abcdef0123456789abcdef",
407 context_id: "ctx_test",
408 max_age_seconds: 300,
409 clock_skew_seconds: 60,
410 };
411
412 let result = verify_incoming_request(&input);
413 assert!(!result.ok);
414 let err = result.error.unwrap();
415 assert_eq!(err.code(), AshErrorCode::ValidationError);
416 assert_eq!(err.reason(), InternalReason::HdrMissing);
417 }
418
419 #[test]
420 fn test_invalid_timestamp_format_fails() {
421 let headers = TestHeaders(vec![
422 ("x-ash-ts".into(), "not_a_number".into()),
423 ("x-ash-body-hash".into(), "a".repeat(64)),
424 ("x-ash-proof".into(), "b".repeat(64)),
425 ]);
426
427 let input = VerifyRequestInput {
428 headers: &headers,
429 method: "POST",
430 path: "/api/test",
431 raw_query: "",
432 canonical_body: "{}",
433 nonce: "0123456789abcdef0123456789abcdef",
434 context_id: "ctx_test",
435 max_age_seconds: 300,
436 clock_skew_seconds: 60,
437 };
438
439 let result = verify_incoming_request(&input);
440 assert!(!result.ok);
441 assert_eq!(result.error.unwrap().code(), AshErrorCode::TimestampInvalid);
442 }
443
444 #[test]
445 fn test_expired_timestamp_fails() {
446 let headers = TestHeaders(vec![
447 ("x-ash-ts".into(), "1000000000".into()), ("x-ash-body-hash".into(), "a".repeat(64)),
449 ("x-ash-proof".into(), "b".repeat(64)),
450 ]);
451
452 let input = VerifyRequestInput {
453 headers: &headers,
454 method: "POST",
455 path: "/api/test",
456 raw_query: "",
457 canonical_body: "{}",
458 nonce: "0123456789abcdef0123456789abcdef",
459 context_id: "ctx_test",
460 max_age_seconds: 300,
461 clock_skew_seconds: 60,
462 };
463
464 let result = verify_incoming_request(&input);
465 assert!(!result.ok);
466 assert_eq!(result.error.unwrap().code(), AshErrorCode::TimestampInvalid);
467 }
468
469 #[test]
470 fn test_body_hash_mismatch_fails() {
471 let (mut headers, _canonical_body, nonce) = make_valid_request();
472 for (k, v) in &mut headers.0 {
474 if k.to_ascii_lowercase() == "x-ash-body-hash" {
475 *v = "f".repeat(64); }
477 }
478
479 let input = VerifyRequestInput {
480 headers: &headers,
481 method: "POST",
482 path: "/api/transfer",
483 raw_query: "",
484 canonical_body: r#"{"amount":100}"#,
485 nonce: &nonce,
486 context_id: "ctx_test123",
487 max_age_seconds: 300,
488 clock_skew_seconds: 60,
489 };
490
491 let result = verify_incoming_request(&input);
492 assert!(!result.ok);
493 let err = result.error.unwrap();
494 assert_eq!(err.code(), AshErrorCode::ValidationError);
495 assert!(err.message().contains("Body hash"));
496 }
497
498 #[test]
499 fn test_wrong_proof_fails() {
500 let (mut headers, canonical_body, nonce) = make_valid_request();
501 for (k, v) in &mut headers.0 {
503 if k.to_ascii_lowercase() == "x-ash-proof" {
504 *v = "f".repeat(64); }
506 }
507
508 let input = VerifyRequestInput {
509 headers: &headers,
510 method: "POST",
511 path: "/api/transfer",
512 raw_query: "",
513 canonical_body: &canonical_body,
514 nonce: &nonce,
515 context_id: "ctx_test123",
516 max_age_seconds: 300,
517 clock_skew_seconds: 60,
518 };
519
520 let result = verify_incoming_request(&input);
521 assert!(!result.ok);
522 assert_eq!(result.error.unwrap().code(), AshErrorCode::ProofInvalid);
523 }
524
525 #[test]
526 fn test_tampered_body_fails() {
527 let (headers, _canonical_body, nonce) = make_valid_request();
528
529 let input = VerifyRequestInput {
531 headers: &headers,
532 method: "POST",
533 path: "/api/transfer",
534 raw_query: "",
535 canonical_body: r#"{"amount":999}"#, nonce: &nonce,
537 context_id: "ctx_test123",
538 max_age_seconds: 300,
539 clock_skew_seconds: 60,
540 };
541
542 let result = verify_incoming_request(&input);
543 assert!(!result.ok);
544 let err = result.error.unwrap();
546 assert_eq!(err.code(), AshErrorCode::ValidationError);
547 }
548
549 #[test]
552 fn precedence_missing_ts_before_body_hash_mismatch() {
553 let headers = TestHeaders(vec![
555 ("x-ash-body-hash".into(), "wrong".repeat(10)),
557 ("x-ash-proof".into(), "b".repeat(64)),
558 ]);
559
560 let input = VerifyRequestInput {
561 headers: &headers,
562 method: "POST",
563 path: "/api/test",
564 raw_query: "",
565 canonical_body: "{}",
566 nonce: "0123456789abcdef0123456789abcdef",
567 context_id: "ctx_test",
568 max_age_seconds: 300,
569 clock_skew_seconds: 60,
570 };
571
572 let result = verify_incoming_request(&input);
573 assert!(!result.ok);
574 assert_eq!(result.error.unwrap().reason(), InternalReason::HdrMissing);
575 }
576
577 #[test]
578 fn precedence_bad_ts_format_before_bad_nonce() {
579 let headers = TestHeaders(vec![
581 ("x-ash-ts".into(), "not_number".into()),
582 ("x-ash-body-hash".into(), "a".repeat(64)),
583 ("x-ash-proof".into(), "b".repeat(64)),
584 ]);
585
586 let input = VerifyRequestInput {
587 headers: &headers,
588 method: "POST",
589 path: "/api/test",
590 raw_query: "",
591 canonical_body: "{}",
592 nonce: "short", context_id: "ctx_test",
594 max_age_seconds: 300,
595 clock_skew_seconds: 60,
596 };
597
598 let result = verify_incoming_request(&input);
599 assert!(!result.ok);
600 assert_eq!(result.error.unwrap().code(), AshErrorCode::TimestampInvalid);
601 }
602}