Skip to main content

ash_core/
proof.rs

1//! Proof generation and verification.
2//!
3//! Proofs are deterministic integrity tokens derived from:
4//! - Context ID
5//! - Binding (endpoint)
6//! - Canonical payload
7//! - Optional nonce (server-assisted mode)
8
9use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
10use sha2::{Digest, Sha256};
11
12use crate::compare::timing_safe_equal;
13use crate::errors::AshError;
14use crate::types::{AshMode, BuildProofInput, VerifyInput};
15
16// =========================================================================
17// ASH Version Constants (v2.3.1)
18// =========================================================================
19
20/// ASH SDK version (library version).
21pub const ASH_SDK_VERSION: &str = "2.3.2";
22
23/// ASH v1 protocol version prefix.
24pub const ASH_VERSION_PREFIX: &str = "ASHv1";
25
26/// ASH v2.1 protocol version prefix.
27pub const ASH_VERSION_PREFIX_V21: &str = "ASHv2.1";
28
29/// Protocol version identifier (legacy alias for ASH_VERSION_PREFIX).
30const ASH_VERSION: &str = ASH_VERSION_PREFIX;
31
32/// Build a cryptographic proof for request integrity.
33///
34/// The proof is computed as:
35/// ```text
36/// proof = BASE64URL(SHA256(
37///   "ASHv1\n" +
38///   mode + "\n" +
39///   binding + "\n" +
40///   contextId + "\n" +
41///   (nonce + "\n" if present) +
42///   canonicalPayload
43/// ))
44/// ```
45///
46/// # Arguments
47///
48/// * `mode` - Security mode (minimal, balanced, strict)
49/// * `binding` - Canonical binding (e.g., "POST /api/update")
50/// * `context_id` - Context ID from server
51/// * `nonce` - Optional nonce for server-assisted mode
52/// * `canonical_payload` - Canonicalized payload string
53///
54/// # Example
55///
56/// ```rust
57/// use ash_core::{build_proof, AshMode};
58///
59/// let proof = build_proof(
60///     AshMode::Balanced,
61///     "POST /api/profile",
62///     "ctx_abc123",
63///     None,
64///     r#"{"name":"John"}"#,
65/// ).unwrap();
66///
67/// println!("Proof: {}", proof);
68/// ```
69pub fn build_proof(
70    mode: AshMode,
71    binding: &str,
72    context_id: &str,
73    nonce: Option<&str>,
74    canonical_payload: &str,
75) -> Result<String, AshError> {
76    // Build the proof input string
77    let mut input = String::new();
78
79    // Version
80    input.push_str(ASH_VERSION);
81    input.push('\n');
82
83    // Mode
84    input.push_str(&mode.to_string());
85    input.push('\n');
86
87    // Binding
88    input.push_str(binding);
89    input.push('\n');
90
91    // Context ID
92    input.push_str(context_id);
93    input.push('\n');
94
95    // Nonce (if present)
96    if let Some(n) = nonce {
97        input.push_str(n);
98        input.push('\n');
99    }
100
101    // Canonical payload
102    input.push_str(canonical_payload);
103
104    // Compute SHA-256 hash
105    let mut hasher = Sha256::new();
106    hasher.update(input.as_bytes());
107    let hash = hasher.finalize();
108
109    // Encode as Base64URL without padding
110    Ok(URL_SAFE_NO_PAD.encode(hash))
111}
112
113/// Build proof from a structured input.
114///
115/// Convenience wrapper around `build_proof` that accepts `BuildProofInput`.
116#[allow(dead_code)]
117pub fn ash_build_proof(input: &BuildProofInput) -> Result<String, AshError> {
118    build_proof(
119        input.mode,
120        &input.binding,
121        &input.context_id,
122        input.nonce.as_deref(),
123        &input.canonical_payload,
124    )
125}
126
127/// Verify a proof using constant-time comparison.
128///
129/// # Security Note
130///
131/// This function uses constant-time comparison to prevent timing attacks.
132/// The comparison time does not depend on where differences occur.
133///
134/// # Example
135///
136/// ```rust
137/// use ash_core::{build_proof, verify_proof, AshMode, VerifyInput};
138///
139/// let expected = build_proof(
140///     AshMode::Balanced,
141///     "POST /api/profile",
142///     "ctx_abc123",
143///     None,
144///     r#"{"name":"John"}"#,
145/// ).unwrap();
146///
147/// let input = VerifyInput::new(&expected, &expected);
148/// assert!(verify_proof(&input));
149/// ```
150pub fn verify_proof(input: &VerifyInput) -> bool {
151    timing_safe_equal(
152        input.expected_proof.as_bytes(),
153        input.actual_proof.as_bytes(),
154    )
155}
156
157/// Verify that two proofs match.
158///
159/// Convenience function for direct string comparison.
160#[allow(dead_code)]
161pub fn ash_verify_proof(expected: &str, actual: &str) -> bool {
162    timing_safe_equal(expected.as_bytes(), actual.as_bytes())
163}
164
165#[cfg(test)]
166mod tests {
167    use super::*;
168
169    #[test]
170    fn test_build_proof_deterministic() {
171        let proof1 = build_proof(
172            AshMode::Balanced,
173            "POST /api/test",
174            "ctx123",
175            None,
176            r#"{"a":1}"#,
177        )
178        .unwrap();
179
180        let proof2 = build_proof(
181            AshMode::Balanced,
182            "POST /api/test",
183            "ctx123",
184            None,
185            r#"{"a":1}"#,
186        )
187        .unwrap();
188
189        assert_eq!(proof1, proof2);
190    }
191
192    #[test]
193    fn test_build_proof_different_payload() {
194        let proof1 = build_proof(
195            AshMode::Balanced,
196            "POST /api/test",
197            "ctx123",
198            None,
199            r#"{"a":1}"#,
200        )
201        .unwrap();
202
203        let proof2 = build_proof(
204            AshMode::Balanced,
205            "POST /api/test",
206            "ctx123",
207            None,
208            r#"{"a":2}"#,
209        )
210        .unwrap();
211
212        assert_ne!(proof1, proof2);
213    }
214
215    #[test]
216    fn test_build_proof_different_context() {
217        let proof1 = build_proof(
218            AshMode::Balanced,
219            "POST /api/test",
220            "ctx123",
221            None,
222            r#"{"a":1}"#,
223        )
224        .unwrap();
225
226        let proof2 = build_proof(
227            AshMode::Balanced,
228            "POST /api/test",
229            "ctx456",
230            None,
231            r#"{"a":1}"#,
232        )
233        .unwrap();
234
235        assert_ne!(proof1, proof2);
236    }
237
238    #[test]
239    fn test_build_proof_different_binding() {
240        let proof1 = build_proof(
241            AshMode::Balanced,
242            "POST /api/test",
243            "ctx123",
244            None,
245            r#"{"a":1}"#,
246        )
247        .unwrap();
248
249        let proof2 = build_proof(
250            AshMode::Balanced,
251            "PUT /api/test",
252            "ctx123",
253            None,
254            r#"{"a":1}"#,
255        )
256        .unwrap();
257
258        assert_ne!(proof1, proof2);
259    }
260
261    #[test]
262    fn test_build_proof_with_nonce() {
263        let proof_without = build_proof(
264            AshMode::Balanced,
265            "POST /api/test",
266            "ctx123",
267            None,
268            r#"{"a":1}"#,
269        )
270        .unwrap();
271
272        let proof_with = build_proof(
273            AshMode::Balanced,
274            "POST /api/test",
275            "ctx123",
276            Some("nonce456"),
277            r#"{"a":1}"#,
278        )
279        .unwrap();
280
281        assert_ne!(proof_without, proof_with);
282    }
283
284    #[test]
285    fn test_build_proof_different_mode() {
286        let proof1 = build_proof(
287            AshMode::Minimal,
288            "POST /api/test",
289            "ctx123",
290            None,
291            r#"{"a":1}"#,
292        )
293        .unwrap();
294
295        let proof2 = build_proof(
296            AshMode::Strict,
297            "POST /api/test",
298            "ctx123",
299            None,
300            r#"{"a":1}"#,
301        )
302        .unwrap();
303
304        assert_ne!(proof1, proof2);
305    }
306
307    #[test]
308    fn test_verify_proof_match() {
309        let proof = build_proof(
310            AshMode::Balanced,
311            "POST /api/test",
312            "ctx123",
313            None,
314            r#"{"a":1}"#,
315        )
316        .unwrap();
317
318        let input = VerifyInput::new(&proof, &proof);
319        assert!(verify_proof(&input));
320    }
321
322    #[test]
323    fn test_verify_proof_mismatch() {
324        let proof1 = build_proof(
325            AshMode::Balanced,
326            "POST /api/test",
327            "ctx123",
328            None,
329            r#"{"a":1}"#,
330        )
331        .unwrap();
332
333        let proof2 = build_proof(
334            AshMode::Balanced,
335            "POST /api/test",
336            "ctx123",
337            None,
338            r#"{"a":2}"#,
339        )
340        .unwrap();
341
342        let input = VerifyInput::new(&proof1, &proof2);
343        assert!(!verify_proof(&input));
344    }
345
346    #[test]
347    fn test_proof_is_base64url() {
348        let proof = build_proof(
349            AshMode::Balanced,
350            "POST /api/test",
351            "ctx123",
352            None,
353            r#"{"a":1}"#,
354        )
355        .unwrap();
356
357        // Base64URL should not contain + / =
358        assert!(!proof.contains('+'));
359        assert!(!proof.contains('/'));
360        assert!(!proof.contains('='));
361
362        // Should be 43 characters (256 bits / 6 bits per char, no padding)
363        assert_eq!(proof.len(), 43);
364    }
365}
366
367// =========================================================================
368// ASH v2.1 - Derived Client Secret & Cryptographic Proof
369// =========================================================================
370
371use hmac::{Hmac, Mac};
372use sha2::Sha256 as HmacSha256;
373
374type HmacSha256Type = Hmac<HmacSha256>;
375
376/// Generate a cryptographically secure random nonce.
377///
378/// # Arguments
379/// * `bytes` - Number of bytes (default 32)
380///
381/// # Returns
382/// Hex-encoded nonce (64 chars for 32 bytes)
383pub fn generate_nonce(bytes: usize) -> String {
384    use getrandom::getrandom;
385    let mut buf = vec![0u8; bytes];
386    getrandom(&mut buf).expect("Failed to generate random bytes");
387    hex::encode(buf)
388}
389
390/// Generate a unique context ID with "ash_" prefix.
391pub fn generate_context_id() -> String {
392    format!("ash_{}", generate_nonce(16))
393}
394
395/// Derive client secret from server nonce (v2.1).
396///
397/// SECURITY PROPERTIES:
398/// - One-way: Cannot derive nonce from clientSecret (HMAC is irreversible)
399/// - Context-bound: Unique per contextId + binding combination
400/// - Safe to expose: Client can use it but cannot forge other contexts
401///
402/// Formula: clientSecret = HMAC-SHA256(nonce, contextId + "|" + binding)
403pub fn derive_client_secret(nonce: &str, context_id: &str, binding: &str) -> String {
404    let mut mac =
405        HmacSha256Type::new_from_slice(nonce.as_bytes()).expect("HMAC can take key of any size");
406    mac.update(format!("{}|{}", context_id, binding).as_bytes());
407    hex::encode(mac.finalize().into_bytes())
408}
409
410/// Build v2.1 cryptographic proof (client-side).
411///
412/// Formula: proof = HMAC-SHA256(clientSecret, timestamp + "|" + binding + "|" + bodyHash)
413pub fn build_proof_v21(
414    client_secret: &str,
415    timestamp: &str,
416    binding: &str,
417    body_hash: &str,
418) -> String {
419    let message = format!("{}|{}|{}", timestamp, binding, body_hash);
420    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
421        .expect("HMAC can take key of any size");
422    mac.update(message.as_bytes());
423    hex::encode(mac.finalize().into_bytes())
424}
425
426/// Verify v2.1 proof (server-side).
427pub fn verify_proof_v21(
428    nonce: &str,
429    context_id: &str,
430    binding: &str,
431    timestamp: &str,
432    body_hash: &str,
433    client_proof: &str,
434) -> bool {
435    let client_secret = derive_client_secret(nonce, context_id, binding);
436    let expected_proof = build_proof_v21(&client_secret, timestamp, binding, body_hash);
437    timing_safe_equal(expected_proof.as_bytes(), client_proof.as_bytes())
438}
439
440/// Compute SHA-256 hash of canonical body.
441pub fn hash_body(canonical_body: &str) -> String {
442    let mut hasher = Sha256::new();
443    hasher.update(canonical_body.as_bytes());
444    hex::encode(hasher.finalize())
445}
446
447#[cfg(test)]
448mod tests_v21 {
449    use super::*;
450
451    #[test]
452    fn test_derive_client_secret_deterministic() {
453        let secret1 = derive_client_secret("nonce123", "ctx_abc", "POST /login");
454        let secret2 = derive_client_secret("nonce123", "ctx_abc", "POST /login");
455        assert_eq!(secret1, secret2);
456    }
457
458    #[test]
459    fn test_derive_client_secret_different_inputs() {
460        let secret1 = derive_client_secret("nonce123", "ctx_abc", "POST /login");
461        let secret2 = derive_client_secret("nonce456", "ctx_abc", "POST /login");
462        assert_ne!(secret1, secret2);
463    }
464
465    #[test]
466    fn test_build_proof_v21_deterministic() {
467        let proof1 = build_proof_v21("secret", "1234567890", "POST /login", "bodyhash");
468        let proof2 = build_proof_v21("secret", "1234567890", "POST /login", "bodyhash");
469        assert_eq!(proof1, proof2);
470    }
471
472    #[test]
473    fn test_verify_proof_v21() {
474        let nonce = "nonce123";
475        let context_id = "ctx_abc";
476        let binding = "POST /login";
477        let timestamp = "1234567890";
478        let body_hash = "bodyhash123";
479
480        let client_secret = derive_client_secret(nonce, context_id, binding);
481        let proof = build_proof_v21(&client_secret, timestamp, binding, body_hash);
482
483        assert!(verify_proof_v21(
484            nonce, context_id, binding, timestamp, body_hash, &proof
485        ));
486    }
487
488    #[test]
489    fn test_hash_body() {
490        let hash = hash_body(r#"{"name":"John"}"#);
491        assert_eq!(hash.len(), 64); // SHA-256 produces 32 bytes = 64 hex chars
492    }
493}
494
495// =========================================================================
496// ASH v2.2 - Context Scoping (Selective Field Protection)
497// =========================================================================
498
499use serde_json::{Map, Value};
500
501/// Extract scoped fields from a JSON value.
502pub fn extract_scoped_fields(payload: &Value, scope: &[&str]) -> Result<Value, AshError> {
503    if scope.is_empty() {
504        return Ok(payload.clone());
505    }
506
507    let mut result = Map::new();
508
509    for field_path in scope {
510        let value = get_nested_value(payload, field_path);
511        if let Some(v) = value {
512            set_nested_value(&mut result, field_path, v);
513        }
514    }
515
516    Ok(Value::Object(result))
517}
518
519fn get_nested_value(payload: &Value, path: &str) -> Option<Value> {
520    let parts: Vec<&str> = path.split('.').collect();
521    let mut current = payload;
522
523    for part in parts {
524        let (key, index) = parse_array_notation(part);
525
526        match current {
527            Value::Object(map) => {
528                current = map.get(key)?;
529                if let Some(idx) = index {
530                    if let Value::Array(arr) = current {
531                        current = arr.get(idx)?;
532                    } else {
533                        return None;
534                    }
535                }
536            }
537            Value::Array(arr) => {
538                let idx: usize = key.parse().ok()?;
539                current = arr.get(idx)?;
540            }
541            _ => return None,
542        }
543    }
544
545    Some(current.clone())
546}
547
548fn parse_array_notation(part: &str) -> (&str, Option<usize>) {
549    if let Some(bracket_start) = part.find('[') {
550        if let Some(bracket_end) = part.find(']') {
551            let key = &part[..bracket_start];
552            let index_str = &part[bracket_start + 1..bracket_end];
553            if let Ok(index) = index_str.parse::<usize>() {
554                return (key, Some(index));
555            }
556        }
557    }
558    (part, None)
559}
560
561fn set_nested_value(result: &mut Map<String, Value>, path: &str, value: Value) {
562    let parts: Vec<&str> = path.split('.').collect();
563
564    if parts.len() == 1 {
565        let (key, _) = parse_array_notation(parts[0]);
566        result.insert(key.to_string(), value);
567        return;
568    }
569
570    let (first_key, _) = parse_array_notation(parts[0]);
571    let remaining_path = parts[1..].join(".");
572
573    let nested = result
574        .entry(first_key.to_string())
575        .or_insert_with(|| Value::Object(Map::new()));
576
577    if let Value::Object(nested_map) = nested {
578        set_nested_value(nested_map, &remaining_path, value);
579    }
580}
581/// Build v2.2 cryptographic proof with scoped fields.
582pub fn build_proof_v21_scoped(
583    client_secret: &str,
584    timestamp: &str,
585    binding: &str,
586    payload: &str,
587    scope: &[&str],
588) -> Result<(String, String), AshError> {
589    let json_payload: Value = serde_json::from_str(payload)
590        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
591
592    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
593
594    let canonical_scoped = serde_json::to_string(&scoped_payload)
595        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
596
597    let body_hash = hash_body(&canonical_scoped);
598
599    let scope_str = scope.join(",");
600    let scope_hash = hash_body(&scope_str);
601
602    let message = format!("{}|{}|{}|{}", timestamp, binding, body_hash, scope_hash);
603    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
604        .expect("HMAC can take key of any size");
605    mac.update(message.as_bytes());
606    let proof = hex::encode(mac.finalize().into_bytes());
607
608    Ok((proof, scope_hash))
609}
610
611/// Verify v2.2 proof with scoped fields.
612#[allow(clippy::too_many_arguments)]
613pub fn verify_proof_v21_scoped(
614    nonce: &str,
615    context_id: &str,
616    binding: &str,
617    timestamp: &str,
618    payload: &str,
619    scope: &[&str],
620    scope_hash: &str,
621    client_proof: &str,
622) -> Result<bool, AshError> {
623    let scope_str = scope.join(",");
624    let expected_scope_hash = hash_body(&scope_str);
625    if !timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
626        return Ok(false);
627    }
628
629    let client_secret = derive_client_secret(nonce, context_id, binding);
630
631    let (expected_proof, _) =
632        build_proof_v21_scoped(&client_secret, timestamp, binding, payload, scope)?;
633
634    Ok(timing_safe_equal(
635        expected_proof.as_bytes(),
636        client_proof.as_bytes(),
637    ))
638}
639
640/// Hash scoped payload for client-side use.
641pub fn hash_scoped_body(payload: &str, scope: &[&str]) -> Result<String, AshError> {
642    let json_payload: Value = serde_json::from_str(payload)
643        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
644
645    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
646
647    let canonical_scoped = serde_json::to_string(&scoped_payload)
648        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
649
650    Ok(hash_body(&canonical_scoped))
651}
652
653#[cfg(test)]
654mod tests_v22_scoping {
655    use super::*;
656
657    #[test]
658    fn test_build_verify_scoped_proof() {
659        let nonce = "test_nonce_12345";
660        let context_id = "ctx_abc123";
661        let binding = "POST /transfer";
662        let timestamp = "1234567890";
663        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
664        let scope = vec!["amount", "recipient"];
665
666        let client_secret = derive_client_secret(nonce, context_id, binding);
667        let (proof, scope_hash) =
668            build_proof_v21_scoped(&client_secret, timestamp, binding, payload, &scope).unwrap();
669
670        let is_valid = verify_proof_v21_scoped(
671            nonce,
672            context_id,
673            binding,
674            timestamp,
675            payload,
676            &scope,
677            &scope_hash,
678            &proof,
679        )
680        .unwrap();
681
682        assert!(is_valid);
683    }
684
685    #[test]
686    fn test_scoped_proof_ignores_unscoped_changes() {
687        let nonce = "test_nonce_12345";
688        let context_id = "ctx_abc123";
689        let binding = "POST /transfer";
690        let timestamp = "1234567890";
691        let scope = vec!["amount", "recipient"];
692
693        let client_secret = derive_client_secret(nonce, context_id, binding);
694
695        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
696        let (proof, scope_hash) =
697            build_proof_v21_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
698
699        let payload2 = r#"{"amount":1000,"recipient":"user1","notes":"world"}"#;
700
701        let is_valid = verify_proof_v21_scoped(
702            nonce,
703            context_id,
704            binding,
705            timestamp,
706            payload2,
707            &scope,
708            &scope_hash,
709            &proof,
710        )
711        .unwrap();
712
713        assert!(is_valid);
714    }
715
716    #[test]
717    fn test_scoped_proof_detects_scoped_changes() {
718        let nonce = "test_nonce_12345";
719        let context_id = "ctx_abc123";
720        let binding = "POST /transfer";
721        let timestamp = "1234567890";
722        let scope = vec!["amount", "recipient"];
723
724        let client_secret = derive_client_secret(nonce, context_id, binding);
725
726        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
727        let (proof, scope_hash) =
728            build_proof_v21_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
729
730        let payload2 = r#"{"amount":9999,"recipient":"user1","notes":"hello"}"#;
731
732        let is_valid = verify_proof_v21_scoped(
733            nonce,
734            context_id,
735            binding,
736            timestamp,
737            payload2,
738            &scope,
739            &scope_hash,
740            &proof,
741        )
742        .unwrap();
743
744        assert!(!is_valid);
745    }
746}
747
748// =========================================================================
749// ASH v2.3 - Unified Proof Functions (Scoping + Chaining)
750// =========================================================================
751
752/// Result from unified proof generation.
753#[derive(Debug, Clone, PartialEq)]
754pub struct UnifiedProofResult {
755    /// The cryptographic proof.
756    pub proof: String,
757    /// Hash of the scope (empty if no scoping).
758    pub scope_hash: String,
759    /// Hash of the previous proof (empty if no chaining).
760    pub chain_hash: String,
761}
762
763/// Hash a proof for chaining purposes.
764///
765/// Used to create chain links between sequential requests.
766pub fn hash_proof(proof: &str) -> String {
767    let mut hasher = Sha256::new();
768    hasher.update(proof.as_bytes());
769    hex::encode(hasher.finalize())
770}
771
772/// Build unified v2.3 cryptographic proof (client-side).
773///
774/// Supports optional scoping and chaining:
775/// - `scope`: Fields to protect (empty = full payload)
776/// - `previous_proof`: Previous proof in chain (None = no chaining)
777///
778/// Formula:
779/// ```text
780/// scopeHash  = scope.len() > 0 ? SHA256(scope.join(",")) : ""
781/// bodyHash   = SHA256(canonicalize(scopedPayload))
782/// chainHash  = previous_proof.is_some() ? SHA256(previous_proof) : ""
783/// proof      = HMAC-SHA256(clientSecret, timestamp|binding|bodyHash|scopeHash|chainHash)
784/// ```
785pub fn build_proof_v21_unified(
786    client_secret: &str,
787    timestamp: &str,
788    binding: &str,
789    payload: &str,
790    scope: &[&str],
791    previous_proof: Option<&str>,
792) -> Result<UnifiedProofResult, AshError> {
793    // Parse and scope the payload
794    let json_payload: Value = serde_json::from_str(payload)
795        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
796
797    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
798
799    let canonical_scoped = serde_json::to_string(&scoped_payload)
800        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
801
802    let body_hash = hash_body(&canonical_scoped);
803
804    // Compute scope hash (empty string if no scope)
805    let scope_hash = if scope.is_empty() {
806        String::new()
807    } else {
808        hash_body(&scope.join(","))
809    };
810
811    // Compute chain hash (empty string if no previous proof)
812    let chain_hash = match previous_proof {
813        Some(prev) if !prev.is_empty() => hash_proof(prev),
814        _ => String::new(),
815    };
816
817    // Build proof message: timestamp|binding|bodyHash|scopeHash|chainHash
818    let message = format!(
819        "{}|{}|{}|{}|{}",
820        timestamp, binding, body_hash, scope_hash, chain_hash
821    );
822
823    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
824        .expect("HMAC can take key of any size");
825    mac.update(message.as_bytes());
826    let proof = hex::encode(mac.finalize().into_bytes());
827
828    Ok(UnifiedProofResult {
829        proof,
830        scope_hash,
831        chain_hash,
832    })
833}
834
835/// Verify unified v2.3 proof (server-side).
836///
837/// Validates proof with optional scoping and chaining.
838#[allow(clippy::too_many_arguments)]
839pub fn verify_proof_v21_unified(
840    nonce: &str,
841    context_id: &str,
842    binding: &str,
843    timestamp: &str,
844    payload: &str,
845    client_proof: &str,
846    scope: &[&str],
847    scope_hash: &str,
848    previous_proof: Option<&str>,
849    chain_hash: &str,
850) -> Result<bool, AshError> {
851    // Validate scope hash if scoping is used
852    if !scope.is_empty() {
853        let expected_scope_hash = hash_body(&scope.join(","));
854        if !timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
855            return Ok(false);
856        }
857    }
858
859    // Validate chain hash if chaining is used
860    if let Some(prev) = previous_proof {
861        if !prev.is_empty() {
862            let expected_chain_hash = hash_proof(prev);
863            if !timing_safe_equal(expected_chain_hash.as_bytes(), chain_hash.as_bytes()) {
864                return Ok(false);
865            }
866        }
867    }
868
869    // Derive client secret and compute expected proof
870    let client_secret = derive_client_secret(nonce, context_id, binding);
871
872    let result = build_proof_v21_unified(
873        &client_secret,
874        timestamp,
875        binding,
876        payload,
877        scope,
878        previous_proof,
879    )?;
880
881    Ok(timing_safe_equal(
882        result.proof.as_bytes(),
883        client_proof.as_bytes(),
884    ))
885}
886
887#[cfg(test)]
888mod tests_v23_unified {
889    use super::*;
890
891    #[test]
892    fn test_unified_basic() {
893        let nonce = "test_nonce_12345";
894        let context_id = "ctx_abc123";
895        let binding = "POST /api/test";
896        let timestamp = "1234567890";
897        let payload = r#"{"name":"John","age":30}"#;
898
899        let client_secret = derive_client_secret(nonce, context_id, binding);
900        let result = build_proof_v21_unified(
901            &client_secret,
902            timestamp,
903            binding,
904            payload,
905            &[],  // No scoping
906            None, // No chaining
907        )
908        .unwrap();
909
910        assert!(!result.proof.is_empty());
911        assert!(result.scope_hash.is_empty());
912        assert!(result.chain_hash.is_empty());
913
914        let is_valid = verify_proof_v21_unified(
915            nonce,
916            context_id,
917            binding,
918            timestamp,
919            payload,
920            &result.proof,
921            &[],
922            "",
923            None,
924            "",
925        )
926        .unwrap();
927
928        assert!(is_valid);
929    }
930
931    #[test]
932    fn test_unified_scoped_only() {
933        let nonce = "test_nonce_12345";
934        let context_id = "ctx_abc123";
935        let binding = "POST /transfer";
936        let timestamp = "1234567890";
937        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
938        let scope = vec!["amount", "recipient"];
939
940        let client_secret = derive_client_secret(nonce, context_id, binding);
941        let result = build_proof_v21_unified(
942            &client_secret,
943            timestamp,
944            binding,
945            payload,
946            &scope,
947            None, // No chaining
948        )
949        .unwrap();
950
951        assert!(!result.proof.is_empty());
952        assert!(!result.scope_hash.is_empty());
953        assert!(result.chain_hash.is_empty());
954
955        let is_valid = verify_proof_v21_unified(
956            nonce,
957            context_id,
958            binding,
959            timestamp,
960            payload,
961            &result.proof,
962            &scope,
963            &result.scope_hash,
964            None,
965            "",
966        )
967        .unwrap();
968
969        assert!(is_valid);
970    }
971
972    #[test]
973    fn test_unified_chained_only() {
974        let nonce = "test_nonce_12345";
975        let context_id = "ctx_abc123";
976        let binding = "POST /checkout";
977        let timestamp = "1234567890";
978        let payload = r#"{"cart_id":"cart_123"}"#;
979        let previous_proof = "abc123def456";
980
981        let client_secret = derive_client_secret(nonce, context_id, binding);
982        let result = build_proof_v21_unified(
983            &client_secret,
984            timestamp,
985            binding,
986            payload,
987            &[], // No scoping
988            Some(previous_proof),
989        )
990        .unwrap();
991
992        assert!(!result.proof.is_empty());
993        assert!(result.scope_hash.is_empty());
994        assert!(!result.chain_hash.is_empty());
995
996        let is_valid = verify_proof_v21_unified(
997            nonce,
998            context_id,
999            binding,
1000            timestamp,
1001            payload,
1002            &result.proof,
1003            &[],
1004            "",
1005            Some(previous_proof),
1006            &result.chain_hash,
1007        )
1008        .unwrap();
1009
1010        assert!(is_valid);
1011    }
1012
1013    #[test]
1014    fn test_unified_full() {
1015        let nonce = "test_nonce_12345";
1016        let context_id = "ctx_abc123";
1017        let binding = "POST /payment";
1018        let timestamp = "1234567890";
1019        let payload = r#"{"amount":500,"currency":"USD","notes":"tip"}"#;
1020        let scope = vec!["amount", "currency"];
1021        let previous_proof = "checkout_proof_xyz";
1022
1023        let client_secret = derive_client_secret(nonce, context_id, binding);
1024        let result = build_proof_v21_unified(
1025            &client_secret,
1026            timestamp,
1027            binding,
1028            payload,
1029            &scope,
1030            Some(previous_proof),
1031        )
1032        .unwrap();
1033
1034        assert!(!result.proof.is_empty());
1035        assert!(!result.scope_hash.is_empty());
1036        assert!(!result.chain_hash.is_empty());
1037
1038        let is_valid = verify_proof_v21_unified(
1039            nonce,
1040            context_id,
1041            binding,
1042            timestamp,
1043            payload,
1044            &result.proof,
1045            &scope,
1046            &result.scope_hash,
1047            Some(previous_proof),
1048            &result.chain_hash,
1049        )
1050        .unwrap();
1051
1052        assert!(is_valid);
1053    }
1054
1055    #[test]
1056    fn test_unified_chain_broken() {
1057        let nonce = "test_nonce_12345";
1058        let context_id = "ctx_abc123";
1059        let binding = "POST /payment";
1060        let timestamp = "1234567890";
1061        let payload = r#"{"amount":500}"#;
1062        let previous_proof = "original_proof";
1063
1064        let client_secret = derive_client_secret(nonce, context_id, binding);
1065        let result = build_proof_v21_unified(
1066            &client_secret,
1067            timestamp,
1068            binding,
1069            payload,
1070            &[],
1071            Some(previous_proof),
1072        )
1073        .unwrap();
1074
1075        // Try to verify with wrong previous proof
1076        let is_valid = verify_proof_v21_unified(
1077            nonce,
1078            context_id,
1079            binding,
1080            timestamp,
1081            payload,
1082            &result.proof,
1083            &[],
1084            "",
1085            Some("tampered_proof"), // Wrong previous proof
1086            &result.chain_hash,
1087        )
1088        .unwrap();
1089
1090        assert!(!is_valid);
1091    }
1092
1093    #[test]
1094    fn test_unified_scope_tampered() {
1095        let nonce = "test_nonce_12345";
1096        let context_id = "ctx_abc123";
1097        let binding = "POST /transfer";
1098        let timestamp = "1234567890";
1099        let payload = r#"{"amount":1000,"recipient":"user1"}"#;
1100        let scope = vec!["amount"];
1101
1102        let client_secret = derive_client_secret(nonce, context_id, binding);
1103        let result =
1104            build_proof_v21_unified(&client_secret, timestamp, binding, payload, &scope, None)
1105                .unwrap();
1106
1107        // Try to verify with different scope
1108        let tampered_scope = vec!["recipient"];
1109        let is_valid = verify_proof_v21_unified(
1110            nonce,
1111            context_id,
1112            binding,
1113            timestamp,
1114            payload,
1115            &result.proof,
1116            &tampered_scope,    // Different scope
1117            &result.scope_hash, // Original scope hash
1118            None,
1119            "",
1120        )
1121        .unwrap();
1122
1123        assert!(!is_valid);
1124    }
1125
1126    #[test]
1127    fn test_hash_proof() {
1128        let proof = "test_proof_123";
1129        let hash1 = hash_proof(proof);
1130        let hash2 = hash_proof(proof);
1131
1132        assert_eq!(hash1, hash2);
1133        assert_eq!(hash1.len(), 64); // SHA-256 = 64 hex chars
1134    }
1135}