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