Skip to main content

ash_core/
verify.rs

1//! High-level request verification (Phase 3-A).
2//!
3//! `verify_incoming_request()` orchestrates existing Core primitives in a
4//! fixed execution order. No new logic — only assembly.
5//!
6//! ## Why This Exists
7//!
8//! Before this function, every middleware reimplemented the same multi-step
9//! pipeline: extract headers → validate timestamp → normalize binding →
10//! hash body → compare → verify proof. Each reimplementation introduced
11//! divergence (different error ordering, different validation sequencing).
12//!
13//! Now the middleware reduces to:
14//! ```text
15//! context = store.lookup(context_id)
16//! canonical_body = canonicalize(body, content_type)
17//! result = verify_incoming_request(input)
18//! if result.ok { proceed } else { return result.error.http_status }
19//! ```
20//!
21//! ## Execution Order (Locked)
22//!
23//! The following order is fixed and must not change. It determines which
24//! error is returned when multiple inputs are invalid.
25//!
26//! 1. Extract headers (ts, body-hash, proof from `HeaderMapView`)
27//! 2. Validate timestamp format
28//! 3. Validate timestamp freshness (skew)
29//! 4. Validate nonce format
30//! 5. Normalize binding (method + path + query)
31//! 6. Hash canonical body
32//! 7. Compare computed body hash with header body hash
33//! 8. Verify proof (re-derives secret, HMAC comparison)
34//! 9. Return ok
35
36use 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
42// ── Input ─────────────────────────────────────────────────────────────
43
44/// Input for high-level request verification.
45///
46/// The middleware is responsible for:
47/// - Looking up the context in the store → providing `nonce` and `context_id`
48/// - Reading the body and canonicalizing it (based on content type)
49/// - Providing the raw HTTP headers via `HeaderMapView`
50///
51/// The verifier handles everything else: header extraction, validation,
52/// binding normalization, body hash comparison, and proof verification.
53pub struct VerifyRequestInput<'a, H: HeaderMapView> {
54    /// HTTP headers (implements `HeaderMapView` for case-insensitive lookup)
55    pub headers: &'a H,
56
57    /// HTTP method (e.g., "POST", "GET")
58    pub method: &'a str,
59
60    /// URL path without query string (e.g., "/api/transfer")
61    pub path: &'a str,
62
63    /// Raw query string without leading `?` (e.g., "page=1&sort=name")
64    pub raw_query: &'a str,
65
66    /// Canonicalized body string (caller canonicalizes based on content type)
67    pub canonical_body: &'a str,
68
69    /// Server nonce (from store lookup, not from headers)
70    pub nonce: &'a str,
71
72    /// Context ID (from store lookup or header extraction)
73    pub context_id: &'a str,
74
75    /// Maximum allowed timestamp age in seconds (e.g., 300 = 5 minutes)
76    pub max_age_seconds: u64,
77
78    /// Clock skew tolerance in seconds (e.g., 60)
79    pub clock_skew_seconds: u64,
80}
81
82// ── Output ────────────────────────────────────────────────────────────
83
84/// Result of high-level request verification.
85pub struct VerifyResult {
86    /// Whether the request passed all checks
87    pub ok: bool,
88
89    /// The error if verification failed (None if ok)
90    pub error: Option<AshError>,
91
92    /// Debug metadata (only populated in debug builds)
93    pub meta: Option<VerifyMeta>,
94}
95
96/// Non-normative debug metadata. Must not contain secrets.
97pub struct VerifyMeta {
98    /// The canonical query string that was computed
99    pub canonical_query: String,
100
101    /// The body hash that was computed from the canonical body
102    pub computed_body_hash: String,
103
104    /// The binding string that was assembled
105    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
126// ── Verification Function ─────────────────────────────────────────────
127
128/// Verify an incoming HTTP request using ASH protocol.
129///
130/// Orchestrates all Core primitives in a fixed execution order.
131/// Returns the first error encountered (no error accumulation).
132///
133/// # Execution Order (locked)
134///
135/// 1. Extract `x-ash-ts`, `x-ash-body-hash`, `x-ash-proof` from headers
136/// 2. Validate timestamp format (digits, no leading zeros, within bounds)
137/// 3. Validate timestamp freshness (not expired, not future)
138/// 4. Validate nonce format (length, hex charset)
139/// 5. Normalize binding (METHOD|PATH|CANONICAL_QUERY)
140/// 6. Hash canonical body → computed_body_hash
141/// 7. Compare computed_body_hash with header body hash (timing-safe)
142/// 8. Verify proof (re-derive secret, build expected proof, compare)
143/// 9. Return ok
144///
145/// # Example
146///
147/// ```rust
148/// use ash_core::headers::HeaderMapView;
149/// use ash_core::verify::{verify_incoming_request, VerifyRequestInput};
150///
151/// struct MyHeaders(Vec<(String, String)>);
152/// impl HeaderMapView for MyHeaders {
153///     fn get_all_ci(&self, name: &str) -> Vec<&str> {
154///         let n = name.to_ascii_lowercase();
155///         self.0.iter()
156///             .filter(|(k, _)| k.to_ascii_lowercase() == n)
157///             .map(|(_, v)| v.as_str())
158///             .collect()
159///     }
160/// }
161///
162/// // In a real middleware, these come from the request + store
163/// let headers = MyHeaders(vec![
164///     ("x-ash-ts".into(), "1700000000".into()),
165///     ("x-ash-body-hash".into(), "some_hash".into()),
166///     ("x-ash-proof".into(), "some_proof".into()),
167/// ]);
168///
169/// let input = VerifyRequestInput {
170///     headers: &headers,
171///     method: "POST",
172///     path: "/api/transfer",
173///     raw_query: "",
174///     canonical_body: "{}",
175///     nonce: "0123456789abcdef0123456789abcdef",
176///     context_id: "ctx_abc123",
177///     max_age_seconds: 300,
178///     clock_skew_seconds: 60,
179/// };
180///
181/// let result = verify_incoming_request(&input);
182/// // result.ok will be false because the proof won't match,
183/// // but the pipeline executes correctly
184/// ```
185pub fn verify_incoming_request<H: HeaderMapView>(input: &VerifyRequestInput<'_, H>) -> VerifyResult {
186    // ── Step 1: Extract required headers ──────────────────────────────
187    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    // ── Step 2: Validate timestamp format ─────────────────────────────
203    if let Err(e) = ash_validate_timestamp_format(&ts) {
204        return VerifyResult::fail(e);
205    }
206
207    // ── Step 3: Validate timestamp freshness ──────────────────────────
208    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    // ── Step 4: Validate nonce format ─────────────────────────────────
217    if let Err(e) = ash_validate_nonce(input.nonce) {
218        return VerifyResult::fail(e);
219    }
220
221    // ── Step 5: Normalize binding ─────────────────────────────────────
222    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    // ── Step 6: Hash canonical body ───────────────────────────────────
228    let computed_body_hash = crate::proof::ash_hash_body(input.canonical_body);
229
230    // ── Step 7: Compare body hashes (timing-safe) ─────────────────────
231    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    // ── Step 8: Verify proof ──────────────────────────────────────────
240    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    // ── Step 9: Success ───────────────────────────────────────────────
258    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
271// ── Internal Helpers ──────────────────────────────────────────────────
272
273/// Extract a single header value with validation.
274/// Reuses the same logic as `ash_extract_headers` but for individual headers.
275fn 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
315/// Validate timestamp freshness using system clock.
316/// Wraps `ash_validate_timestamp` which uses `SystemTime::now()` internally.
317fn 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, &timestamp, 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()), // 2001, way expired
448            ("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        // Tamper with body-hash header
473        for (k, v) in &mut headers.0 {
474            if k.to_ascii_lowercase() == "x-ash-body-hash" {
475                *v = "f".repeat(64); // wrong hash
476            }
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        // Tamper with proof header
502        for (k, v) in &mut headers.0 {
503            if k.to_ascii_lowercase() == "x-ash-proof" {
504                *v = "f".repeat(64); // wrong proof
505            }
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        // Original body was {"amount":100}, send different body
530        let input = VerifyRequestInput {
531            headers: &headers,
532            method: "POST",
533            path: "/api/transfer",
534            raw_query: "",
535            canonical_body: r#"{"amount":999}"#, // tampered
536            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        // Should fail at body hash comparison (step 7)
545        let err = result.error.unwrap();
546        assert_eq!(err.code(), AshErrorCode::ValidationError);
547    }
548
549    // ── Precedence tests ──────────────────────────────────────────────
550
551    #[test]
552    fn precedence_missing_ts_before_body_hash_mismatch() {
553        // Missing timestamp AND wrong body hash → timestamp error first
554        let headers = TestHeaders(vec![
555            // no x-ash-ts
556            ("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        // Bad timestamp format AND bad nonce → timestamp error first
580        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", // bad nonce
593            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}