Skip to main content

ash_core/
build.rs

1//! High-level request proof building (Phase 3-B).
2//!
3//! `build_request_proof()` 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 client SDK reimplemented the same multi-step
9//! pipeline: normalize binding → hash body → derive client secret →
10//! build proof → assemble headers. Each reimplementation introduced
11//! divergence (different validation ordering, different normalization paths).
12//!
13//! Now the client SDK reduces to:
14//! ```text
15//! canonical_body = canonicalize(body, content_type)
16//! result = build_request_proof(input)
17//! set_header("x-ash-ts", result.timestamp)
18//! set_header("x-ash-nonce", result.nonce)
19//! set_header("x-ash-body-hash", result.body_hash)
20//! set_header("x-ash-proof", result.proof)
21//! ```
22//!
23//! ## Execution Order (Locked)
24//!
25//! The following order is fixed and must not change:
26//!
27//! 1. Validate nonce format
28//! 2. Validate timestamp format
29//! 3. Normalize binding (method + path + query)
30//! 4. Hash canonical body
31//! 5. Derive client secret (nonce + context_id + binding)
32//! 6. Build proof (client_secret + timestamp + binding + body_hash)
33//! 7. Return assembled result
34
35use crate::errors::AshError;
36use crate::proof::{
37    ash_build_proof, ash_build_proof_scoped, ash_build_proof_unified, ash_derive_client_secret,
38    ash_hash_body, ash_validate_timestamp_format,
39};
40use crate::validate::ash_validate_nonce;
41
42// ── Input ─────────────────────────────────────────────────────────────
43
44/// Input for high-level request proof building.
45///
46/// The client SDK is responsible for:
47/// - Obtaining `nonce` and `context_id` from the server
48/// - Canonicalizing the body (based on content type)
49/// - Generating a current timestamp
50///
51/// The builder handles everything else: nonce validation, timestamp
52/// validation, binding normalization, body hashing, secret derivation,
53/// and proof computation.
54#[derive(Debug)]
55pub struct BuildRequestInput<'a> {
56    /// HTTP method (e.g., "POST", "GET")
57    pub method: &'a str,
58
59    /// URL path without query string (e.g., "/api/transfer")
60    pub path: &'a str,
61
62    /// Raw query string without leading `?` (e.g., "page=1&sort=name")
63    pub raw_query: &'a str,
64
65    /// Canonicalized body string (caller canonicalizes based on content type)
66    pub canonical_body: &'a str,
67
68    /// Server nonce (from context response)
69    pub nonce: &'a str,
70
71    /// Context ID (from context response)
72    pub context_id: &'a str,
73
74    /// Unix timestamp as string (caller generates current time)
75    pub timestamp: &'a str,
76
77    /// Optional: scope fields for scoped proof (e.g., &["amount", "recipient"])
78    pub scope: Option<&'a [&'a str]>,
79
80    /// Optional: previous proof hex for request chaining
81    pub previous_proof: Option<&'a str>,
82}
83
84// ── Output ────────────────────────────────────────────────────────────
85
86/// Result of high-level request proof building.
87///
88/// Contains all values the client needs to set as HTTP headers.
89#[derive(Debug)]
90pub struct BuildRequestResult {
91    /// The cryptographic proof (64-char lowercase hex)
92    pub proof: String,
93
94    /// The body hash (64-char lowercase hex)
95    pub body_hash: String,
96
97    /// The normalized binding string (METHOD|PATH|CANONICAL_QUERY)
98    pub binding: String,
99
100    /// The timestamp used (echoed back for header)
101    pub timestamp: String,
102
103    /// The nonce used (echoed back for header)
104    pub nonce: String,
105
106    /// Scope hash (empty string if no scoping)
107    pub scope_hash: String,
108
109    /// Chain hash (empty string if no chaining)
110    pub chain_hash: String,
111
112    /// Debug metadata (only populated in debug builds)
113    pub meta: Option<BuildMeta>,
114}
115
116/// Non-normative debug metadata. Must not contain secrets.
117#[derive(Debug)]
118pub struct BuildMeta {
119    /// The canonical query string that was computed
120    pub canonical_query: String,
121}
122
123// ── Build Function ───────────────────────────────────────────────────
124
125/// Build an HTTP request proof using ASH protocol.
126///
127/// Orchestrates all Core primitives in a fixed execution order.
128/// Returns the first error encountered (no error accumulation).
129///
130/// # Execution Order (locked)
131///
132/// 1. Validate nonce format (length, hex charset)
133/// 2. Validate timestamp format (digits, no leading zeros, within bounds)
134/// 3. Normalize binding (METHOD|PATH|CANONICAL_QUERY)
135/// 4. Hash canonical body → body_hash
136/// 5. Derive client secret (nonce + context_id + binding)
137/// 6. Build proof (client_secret + timestamp + binding + body_hash)
138/// 7. Return result with all header values
139///
140/// # Proof Modes
141///
142/// - **Basic**: `scope` is None, `previous_proof` is None → standard proof
143/// - **Scoped**: `scope` is Some → scoped proof with scope_hash
144/// - **Unified**: `scope` is Some and/or `previous_proof` is Some → unified proof
145///
146/// # Example
147///
148/// ```rust
149/// use ash_core::build::{build_request_proof, BuildRequestInput};
150///
151/// let input = BuildRequestInput {
152///     method: "POST",
153///     path: "/api/transfer",
154///     raw_query: "",
155///     canonical_body: r#"{"amount":100,"recipient":"alice"}"#,
156///     nonce: "0123456789abcdef0123456789abcdef",
157///     context_id: "ctx_abc123",
158///     timestamp: "1700000000",
159///     scope: None,
160///     previous_proof: None,
161/// };
162///
163/// let result = build_request_proof(&input).unwrap();
164/// assert_eq!(result.body_hash.len(), 64);
165/// assert_eq!(result.proof.len(), 64);
166/// assert_eq!(result.binding, "POST|/api/transfer|");
167/// ```
168pub fn build_request_proof(input: &BuildRequestInput<'_>) -> Result<BuildRequestResult, AshError> {
169    // ── Step 1: Validate nonce format ─────────────────────────────────
170    ash_validate_nonce(input.nonce)?;
171
172    // ── Step 2: Validate timestamp format ─────────────────────────────
173    ash_validate_timestamp_format(input.timestamp)?;
174
175    // ── Step 3: Normalize binding ─────────────────────────────────────
176    let binding =
177        crate::ash_normalize_binding(input.method, input.path, input.raw_query)?;
178
179    // ── Step 4: Hash canonical body ───────────────────────────────────
180    let body_hash = ash_hash_body(input.canonical_body);
181
182    // ── Step 5: Derive client secret ──────────────────────────────────
183    let client_secret = ash_derive_client_secret(input.nonce, input.context_id, &binding)?;
184
185    // ── Step 6: Build proof ───────────────────────────────────────────
186    let (proof, scope_hash, chain_hash) = match (input.scope, input.previous_proof) {
187        // Unified: scope and/or chain
188        (Some(scope), Some(prev)) => {
189            let r = ash_build_proof_unified(
190                &client_secret,
191                input.timestamp,
192                &binding,
193                input.canonical_body,
194                scope,
195                Some(prev),
196            )?;
197            (r.proof, r.scope_hash, r.chain_hash)
198        }
199        // Unified with chain only (no scope)
200        (None, Some(prev)) => {
201            let r = ash_build_proof_unified(
202                &client_secret,
203                input.timestamp,
204                &binding,
205                input.canonical_body,
206                &[],
207                Some(prev),
208            )?;
209            (r.proof, r.scope_hash, r.chain_hash)
210        }
211        // Scoped only (no chain)
212        (Some(scope), None) if !scope.is_empty() => {
213            let (proof, scope_hash) = ash_build_proof_scoped(
214                &client_secret,
215                input.timestamp,
216                &binding,
217                input.canonical_body,
218                scope,
219            )?;
220            (proof, scope_hash, String::new())
221        }
222        // Basic proof (no scope, no chain)
223        _ => {
224            let proof = ash_build_proof(&client_secret, input.timestamp, &binding, &body_hash)?;
225            (proof, String::new(), String::new())
226        }
227    };
228
229    // ── Step 7: Assemble result ───────────────────────────────────────
230    let canonical_query = if binding.contains('|') {
231        // Extract query part from binding (METHOD|PATH|QUERY)
232        binding.rsplitn(2, '|').next().unwrap_or("").to_string()
233    } else {
234        String::new()
235    };
236
237    let meta = if cfg!(debug_assertions) {
238        Some(BuildMeta { canonical_query })
239    } else {
240        None
241    };
242
243    Ok(BuildRequestResult {
244        proof,
245        body_hash,
246        binding,
247        timestamp: input.timestamp.to_string(),
248        nonce: input.nonce.to_string(),
249        scope_hash,
250        chain_hash,
251        meta,
252    })
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::errors::{AshErrorCode, InternalReason};
259
260    #[test]
261    fn test_basic_build_succeeds() {
262        let input = BuildRequestInput {
263            method: "POST",
264            path: "/api/transfer",
265            raw_query: "",
266            canonical_body: r#"{"amount":100}"#,
267            nonce: "0123456789abcdef0123456789abcdef",
268            context_id: "ctx_test123",
269            timestamp: "1700000000",
270            scope: None,
271            previous_proof: None,
272        };
273
274        let result = build_request_proof(&input).unwrap();
275        assert_eq!(result.proof.len(), 64);
276        assert_eq!(result.body_hash.len(), 64);
277        assert_eq!(result.binding, "POST|/api/transfer|");
278        assert_eq!(result.timestamp, "1700000000");
279        assert_eq!(result.nonce, "0123456789abcdef0123456789abcdef");
280        assert!(result.scope_hash.is_empty());
281        assert!(result.chain_hash.is_empty());
282    }
283
284    #[test]
285    fn test_build_normalizes_method() {
286        let input = BuildRequestInput {
287            method: "post",
288            path: "/api/test",
289            raw_query: "",
290            canonical_body: "{}",
291            nonce: "0123456789abcdef0123456789abcdef",
292            context_id: "ctx_test",
293            timestamp: "1700000000",
294            scope: None,
295            previous_proof: None,
296        };
297
298        let result = build_request_proof(&input).unwrap();
299        assert!(result.binding.starts_with("POST|"));
300    }
301
302    #[test]
303    fn test_build_normalizes_path() {
304        let input = BuildRequestInput {
305            method: "GET",
306            path: "/api//users/",
307            raw_query: "",
308            canonical_body: "{}",
309            nonce: "0123456789abcdef0123456789abcdef",
310            context_id: "ctx_test",
311            timestamp: "1700000000",
312            scope: None,
313            previous_proof: None,
314        };
315
316        let result = build_request_proof(&input).unwrap();
317        assert_eq!(result.binding, "GET|/api/users|");
318    }
319
320    #[test]
321    fn test_build_canonicalizes_query() {
322        let input = BuildRequestInput {
323            method: "GET",
324            path: "/api/search",
325            raw_query: "z=3&a=1",
326            canonical_body: "{}",
327            nonce: "0123456789abcdef0123456789abcdef",
328            context_id: "ctx_test",
329            timestamp: "1700000000",
330            scope: None,
331            previous_proof: None,
332        };
333
334        let result = build_request_proof(&input).unwrap();
335        assert_eq!(result.binding, "GET|/api/search|a=1&z=3");
336    }
337
338    #[test]
339    fn test_build_bad_nonce_fails_first() {
340        let input = BuildRequestInput {
341            method: "POST",
342            path: "/api/test",
343            raw_query: "",
344            canonical_body: "{}",
345            nonce: "short",
346            context_id: "ctx_test",
347            timestamp: "1700000000",
348            scope: None,
349            previous_proof: None,
350        };
351
352        let err = build_request_proof(&input).unwrap_err();
353        assert_eq!(err.code(), AshErrorCode::ValidationError);
354        assert_eq!(err.reason(), InternalReason::NonceTooShort);
355    }
356
357    #[test]
358    fn test_build_bad_timestamp_fails_second() {
359        let input = BuildRequestInput {
360            method: "POST",
361            path: "/api/test",
362            raw_query: "",
363            canonical_body: "{}",
364            nonce: "0123456789abcdef0123456789abcdef",
365            context_id: "ctx_test",
366            timestamp: "not_a_number",
367            scope: None,
368            previous_proof: None,
369        };
370
371        let err = build_request_proof(&input).unwrap_err();
372        assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
373    }
374
375    #[test]
376    fn test_build_bad_path_fails() {
377        let input = BuildRequestInput {
378            method: "POST",
379            path: "no_leading_slash",
380            raw_query: "",
381            canonical_body: "{}",
382            nonce: "0123456789abcdef0123456789abcdef",
383            context_id: "ctx_test",
384            timestamp: "1700000000",
385            scope: None,
386            previous_proof: None,
387        };
388
389        let err = build_request_proof(&input).unwrap_err();
390        assert_eq!(err.code(), AshErrorCode::ValidationError);
391    }
392
393    #[test]
394    fn test_build_verify_roundtrip() {
395        // Build a proof, then verify it matches what verify_incoming_request expects
396        let nonce = "0123456789abcdef0123456789abcdef";
397        let context_id = "ctx_roundtrip";
398        let canonical_body = r#"{"amount":100}"#;
399        let timestamp = "1700000000";
400
401        let build_result = build_request_proof(&BuildRequestInput {
402            method: "POST",
403            path: "/api/transfer",
404            raw_query: "sort=name",
405            canonical_body,
406            nonce,
407            context_id,
408            timestamp,
409            scope: None,
410            previous_proof: None,
411        })
412        .unwrap();
413
414        // Re-derive and verify manually using low-level primitives
415        let client_secret =
416            ash_derive_client_secret(nonce, context_id, &build_result.binding).unwrap();
417        let expected_proof =
418            ash_build_proof(&client_secret, timestamp, &build_result.binding, &build_result.body_hash)
419                .unwrap();
420
421        assert_eq!(build_result.proof, expected_proof);
422    }
423
424    #[test]
425    fn test_build_scoped_proof() {
426        let input = BuildRequestInput {
427            method: "POST",
428            path: "/api/transfer",
429            raw_query: "",
430            canonical_body: r#"{"amount":100,"recipient":"alice"}"#,
431            nonce: "0123456789abcdef0123456789abcdef",
432            context_id: "ctx_scoped",
433            timestamp: "1700000000",
434            scope: Some(&["amount", "recipient"]),
435            previous_proof: None,
436        };
437
438        let result = build_request_proof(&input).unwrap();
439        assert_eq!(result.proof.len(), 64);
440        assert!(!result.scope_hash.is_empty());
441        assert!(result.chain_hash.is_empty());
442    }
443
444    #[test]
445    fn test_build_chained_proof() {
446        // First build a basic proof
447        let first = build_request_proof(&BuildRequestInput {
448            method: "POST",
449            path: "/api/step1",
450            raw_query: "",
451            canonical_body: r#"{"step":1}"#,
452            nonce: "0123456789abcdef0123456789abcdef",
453            context_id: "ctx_chain",
454            timestamp: "1700000000",
455            scope: None,
456            previous_proof: None,
457        })
458        .unwrap();
459
460        // Then build a chained proof
461        let second = build_request_proof(&BuildRequestInput {
462            method: "POST",
463            path: "/api/step2",
464            raw_query: "",
465            canonical_body: r#"{"step":2}"#,
466            nonce: "0123456789abcdef0123456789abcdef",
467            context_id: "ctx_chain",
468            timestamp: "1700000001",
469            scope: None,
470            previous_proof: Some(&first.proof),
471        })
472        .unwrap();
473
474        assert_eq!(second.proof.len(), 64);
475        assert!(!second.chain_hash.is_empty());
476        // Chain hash should be SHA-256 of previous proof
477        assert_eq!(second.chain_hash.len(), 64);
478    }
479
480    // ── Precedence tests ──────────────────────────────────────────────
481
482    #[test]
483    fn precedence_bad_nonce_before_bad_timestamp() {
484        let input = BuildRequestInput {
485            method: "POST",
486            path: "/api/test",
487            raw_query: "",
488            canonical_body: "{}",
489            nonce: "short",           // bad nonce
490            context_id: "ctx_test",
491            timestamp: "not_a_number", // bad timestamp
492            scope: None,
493            previous_proof: None,
494        };
495
496        let err = build_request_proof(&input).unwrap_err();
497        // Nonce validation happens before timestamp (step 1 vs step 2)
498        assert_eq!(err.reason(), InternalReason::NonceTooShort);
499    }
500
501    #[test]
502    fn precedence_bad_timestamp_before_bad_path() {
503        let input = BuildRequestInput {
504            method: "POST",
505            path: "no_slash",           // bad path
506            raw_query: "",
507            canonical_body: "{}",
508            nonce: "0123456789abcdef0123456789abcdef",
509            context_id: "ctx_test",
510            timestamp: "not_a_number",  // bad timestamp
511            scope: None,
512            previous_proof: None,
513        };
514
515        let err = build_request_proof(&input).unwrap_err();
516        // Timestamp validation happens before binding (step 2 vs step 3)
517        assert_eq!(err.code(), AshErrorCode::TimestampInvalid);
518    }
519}