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/// 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 =
396        HmacSha256Type::new_from_slice(nonce.as_bytes()).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(
475            nonce, context_id, binding, timestamp, body_hash, &proof
476        ));
477    }
478
479    #[test]
480    fn test_hash_body() {
481        let hash = hash_body(r#"{"name":"John"}"#);
482        assert_eq!(hash.len(), 64); // SHA-256 produces 32 bytes = 64 hex chars
483    }
484}
485
486// =========================================================================
487// ASH v2.2 - Context Scoping (Selective Field Protection)
488// =========================================================================
489
490use serde_json::{Map, Value};
491
492/// Extract scoped fields from a JSON value.
493pub fn extract_scoped_fields(payload: &Value, scope: &[&str]) -> Result<Value, AshError> {
494    if scope.is_empty() {
495        return Ok(payload.clone());
496    }
497
498    let mut result = Map::new();
499
500    for field_path in scope {
501        let value = get_nested_value(payload, field_path);
502        if let Some(v) = value {
503            set_nested_value(&mut result, field_path, v);
504        }
505    }
506
507    Ok(Value::Object(result))
508}
509
510fn get_nested_value(payload: &Value, path: &str) -> Option<Value> {
511    let parts: Vec<&str> = path.split('.').collect();
512    let mut current = payload;
513
514    for part in parts {
515        let (key, index) = parse_array_notation(part);
516
517        match current {
518            Value::Object(map) => {
519                current = map.get(key)?;
520                if let Some(idx) = index {
521                    if let Value::Array(arr) = current {
522                        current = arr.get(idx)?;
523                    } else {
524                        return None;
525                    }
526                }
527            }
528            Value::Array(arr) => {
529                let idx: usize = key.parse().ok()?;
530                current = arr.get(idx)?;
531            }
532            _ => return None,
533        }
534    }
535
536    Some(current.clone())
537}
538
539fn parse_array_notation(part: &str) -> (&str, Option<usize>) {
540    if let Some(bracket_start) = part.find('[') {
541        if let Some(bracket_end) = part.find(']') {
542            let key = &part[..bracket_start];
543            let index_str = &part[bracket_start + 1..bracket_end];
544            if let Ok(index) = index_str.parse::<usize>() {
545                return (key, Some(index));
546            }
547        }
548    }
549    (part, None)
550}
551
552fn set_nested_value(result: &mut Map<String, Value>, path: &str, value: Value) {
553    let parts: Vec<&str> = path.split('.').collect();
554
555    if parts.len() == 1 {
556        let (key, _) = parse_array_notation(parts[0]);
557        result.insert(key.to_string(), value);
558        return;
559    }
560
561    let (first_key, _) = parse_array_notation(parts[0]);
562    let remaining_path = parts[1..].join(".");
563
564    let nested = result
565        .entry(first_key.to_string())
566        .or_insert_with(|| Value::Object(Map::new()));
567
568    if let Value::Object(nested_map) = nested {
569        set_nested_value(nested_map, &remaining_path, value);
570    }
571}
572/// Build v2.2 cryptographic proof with scoped fields.
573pub fn build_proof_v21_scoped(
574    client_secret: &str,
575    timestamp: &str,
576    binding: &str,
577    payload: &str,
578    scope: &[&str],
579) -> Result<(String, String), AshError> {
580    let json_payload: Value = serde_json::from_str(payload)
581        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
582
583    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
584
585    let canonical_scoped = serde_json::to_string(&scoped_payload)
586        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
587
588    let body_hash = hash_body(&canonical_scoped);
589
590    let scope_str = scope.join(",");
591    let scope_hash = hash_body(&scope_str);
592
593    let message = format!("{}|{}|{}|{}", timestamp, binding, body_hash, scope_hash);
594    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
595        .expect("HMAC can take key of any size");
596    mac.update(message.as_bytes());
597    let proof = hex::encode(mac.finalize().into_bytes());
598
599    Ok((proof, scope_hash))
600}
601
602/// Verify v2.2 proof with scoped fields.
603#[allow(clippy::too_many_arguments)]
604pub fn verify_proof_v21_scoped(
605    nonce: &str,
606    context_id: &str,
607    binding: &str,
608    timestamp: &str,
609    payload: &str,
610    scope: &[&str],
611    scope_hash: &str,
612    client_proof: &str,
613) -> Result<bool, AshError> {
614    let scope_str = scope.join(",");
615    let expected_scope_hash = hash_body(&scope_str);
616    if !timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
617        return Ok(false);
618    }
619
620    let client_secret = derive_client_secret(nonce, context_id, binding);
621
622    let (expected_proof, _) =
623        build_proof_v21_scoped(&client_secret, timestamp, binding, payload, scope)?;
624
625    Ok(timing_safe_equal(
626        expected_proof.as_bytes(),
627        client_proof.as_bytes(),
628    ))
629}
630
631/// Hash scoped payload for client-side use.
632pub fn hash_scoped_body(payload: &str, scope: &[&str]) -> Result<String, AshError> {
633    let json_payload: Value = serde_json::from_str(payload)
634        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
635
636    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
637
638    let canonical_scoped = serde_json::to_string(&scoped_payload)
639        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
640
641    Ok(hash_body(&canonical_scoped))
642}
643
644#[cfg(test)]
645mod tests_v22_scoping {
646    use super::*;
647
648    #[test]
649    fn test_build_verify_scoped_proof() {
650        let nonce = "test_nonce_12345";
651        let context_id = "ctx_abc123";
652        let binding = "POST /transfer";
653        let timestamp = "1234567890";
654        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
655        let scope = vec!["amount", "recipient"];
656
657        let client_secret = derive_client_secret(nonce, context_id, binding);
658        let (proof, scope_hash) =
659            build_proof_v21_scoped(&client_secret, timestamp, binding, payload, &scope).unwrap();
660
661        let is_valid = verify_proof_v21_scoped(
662            nonce,
663            context_id,
664            binding,
665            timestamp,
666            payload,
667            &scope,
668            &scope_hash,
669            &proof,
670        )
671        .unwrap();
672
673        assert!(is_valid);
674    }
675
676    #[test]
677    fn test_scoped_proof_ignores_unscoped_changes() {
678        let nonce = "test_nonce_12345";
679        let context_id = "ctx_abc123";
680        let binding = "POST /transfer";
681        let timestamp = "1234567890";
682        let scope = vec!["amount", "recipient"];
683
684        let client_secret = derive_client_secret(nonce, context_id, binding);
685
686        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
687        let (proof, scope_hash) =
688            build_proof_v21_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
689
690        let payload2 = r#"{"amount":1000,"recipient":"user1","notes":"world"}"#;
691
692        let is_valid = verify_proof_v21_scoped(
693            nonce,
694            context_id,
695            binding,
696            timestamp,
697            payload2,
698            &scope,
699            &scope_hash,
700            &proof,
701        )
702        .unwrap();
703
704        assert!(is_valid);
705    }
706
707    #[test]
708    fn test_scoped_proof_detects_scoped_changes() {
709        let nonce = "test_nonce_12345";
710        let context_id = "ctx_abc123";
711        let binding = "POST /transfer";
712        let timestamp = "1234567890";
713        let scope = vec!["amount", "recipient"];
714
715        let client_secret = derive_client_secret(nonce, context_id, binding);
716
717        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
718        let (proof, scope_hash) =
719            build_proof_v21_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
720
721        let payload2 = r#"{"amount":9999,"recipient":"user1","notes":"hello"}"#;
722
723        let is_valid = verify_proof_v21_scoped(
724            nonce,
725            context_id,
726            binding,
727            timestamp,
728            payload2,
729            &scope,
730            &scope_hash,
731            &proof,
732        )
733        .unwrap();
734
735        assert!(!is_valid);
736    }
737}
738
739// =========================================================================
740// ASH v2.3 - Unified Proof Functions (Scoping + Chaining)
741// =========================================================================
742
743/// Result from unified proof generation.
744#[derive(Debug, Clone, PartialEq)]
745pub struct UnifiedProofResult {
746    /// The cryptographic proof.
747    pub proof: String,
748    /// Hash of the scope (empty if no scoping).
749    pub scope_hash: String,
750    /// Hash of the previous proof (empty if no chaining).
751    pub chain_hash: String,
752}
753
754/// Hash a proof for chaining purposes.
755///
756/// Used to create chain links between sequential requests.
757pub fn hash_proof(proof: &str) -> String {
758    let mut hasher = Sha256::new();
759    hasher.update(proof.as_bytes());
760    hex::encode(hasher.finalize())
761}
762
763/// Build unified v2.3 cryptographic proof (client-side).
764///
765/// Supports optional scoping and chaining:
766/// - `scope`: Fields to protect (empty = full payload)
767/// - `previous_proof`: Previous proof in chain (None = no chaining)
768///
769/// Formula:
770/// ```text
771/// scopeHash  = scope.len() > 0 ? SHA256(scope.join(",")) : ""
772/// bodyHash   = SHA256(canonicalize(scopedPayload))
773/// chainHash  = previous_proof.is_some() ? SHA256(previous_proof) : ""
774/// proof      = HMAC-SHA256(clientSecret, timestamp|binding|bodyHash|scopeHash|chainHash)
775/// ```
776pub fn build_proof_v21_unified(
777    client_secret: &str,
778    timestamp: &str,
779    binding: &str,
780    payload: &str,
781    scope: &[&str],
782    previous_proof: Option<&str>,
783) -> Result<UnifiedProofResult, AshError> {
784    // Parse and scope the payload
785    let json_payload: Value = serde_json::from_str(payload)
786        .map_err(|e| AshError::canonicalization_failed(&format!("Invalid JSON: {}", e)))?;
787
788    let scoped_payload = extract_scoped_fields(&json_payload, scope)?;
789
790    let canonical_scoped = serde_json::to_string(&scoped_payload)
791        .map_err(|e| AshError::canonicalization_failed(&format!("Failed to serialize: {}", e)))?;
792
793    let body_hash = hash_body(&canonical_scoped);
794
795    // Compute scope hash (empty string if no scope)
796    let scope_hash = if scope.is_empty() {
797        String::new()
798    } else {
799        hash_body(&scope.join(","))
800    };
801
802    // Compute chain hash (empty string if no previous proof)
803    let chain_hash = match previous_proof {
804        Some(prev) if !prev.is_empty() => hash_proof(prev),
805        _ => String::new(),
806    };
807
808    // Build proof message: timestamp|binding|bodyHash|scopeHash|chainHash
809    let message = format!(
810        "{}|{}|{}|{}|{}",
811        timestamp, binding, body_hash, scope_hash, chain_hash
812    );
813
814    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
815        .expect("HMAC can take key of any size");
816    mac.update(message.as_bytes());
817    let proof = hex::encode(mac.finalize().into_bytes());
818
819    Ok(UnifiedProofResult {
820        proof,
821        scope_hash,
822        chain_hash,
823    })
824}
825
826/// Verify unified v2.3 proof (server-side).
827///
828/// Validates proof with optional scoping and chaining.
829#[allow(clippy::too_many_arguments)]
830pub fn verify_proof_v21_unified(
831    nonce: &str,
832    context_id: &str,
833    binding: &str,
834    timestamp: &str,
835    payload: &str,
836    client_proof: &str,
837    scope: &[&str],
838    scope_hash: &str,
839    previous_proof: Option<&str>,
840    chain_hash: &str,
841) -> Result<bool, AshError> {
842    // Validate scope hash if scoping is used
843    if !scope.is_empty() {
844        let expected_scope_hash = hash_body(&scope.join(","));
845        if !timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
846            return Ok(false);
847        }
848    }
849
850    // Validate chain hash if chaining is used
851    if let Some(prev) = previous_proof {
852        if !prev.is_empty() {
853            let expected_chain_hash = hash_proof(prev);
854            if !timing_safe_equal(expected_chain_hash.as_bytes(), chain_hash.as_bytes()) {
855                return Ok(false);
856            }
857        }
858    }
859
860    // Derive client secret and compute expected proof
861    let client_secret = derive_client_secret(nonce, context_id, binding);
862
863    let result = build_proof_v21_unified(
864        &client_secret,
865        timestamp,
866        binding,
867        payload,
868        scope,
869        previous_proof,
870    )?;
871
872    Ok(timing_safe_equal(
873        result.proof.as_bytes(),
874        client_proof.as_bytes(),
875    ))
876}
877
878#[cfg(test)]
879mod tests_v23_unified {
880    use super::*;
881
882    #[test]
883    fn test_unified_basic() {
884        let nonce = "test_nonce_12345";
885        let context_id = "ctx_abc123";
886        let binding = "POST /api/test";
887        let timestamp = "1234567890";
888        let payload = r#"{"name":"John","age":30}"#;
889
890        let client_secret = derive_client_secret(nonce, context_id, binding);
891        let result = build_proof_v21_unified(
892            &client_secret,
893            timestamp,
894            binding,
895            payload,
896            &[],  // No scoping
897            None, // No chaining
898        )
899        .unwrap();
900
901        assert!(!result.proof.is_empty());
902        assert!(result.scope_hash.is_empty());
903        assert!(result.chain_hash.is_empty());
904
905        let is_valid = verify_proof_v21_unified(
906            nonce,
907            context_id,
908            binding,
909            timestamp,
910            payload,
911            &result.proof,
912            &[],
913            "",
914            None,
915            "",
916        )
917        .unwrap();
918
919        assert!(is_valid);
920    }
921
922    #[test]
923    fn test_unified_scoped_only() {
924        let nonce = "test_nonce_12345";
925        let context_id = "ctx_abc123";
926        let binding = "POST /transfer";
927        let timestamp = "1234567890";
928        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
929        let scope = vec!["amount", "recipient"];
930
931        let client_secret = derive_client_secret(nonce, context_id, binding);
932        let result = build_proof_v21_unified(
933            &client_secret,
934            timestamp,
935            binding,
936            payload,
937            &scope,
938            None, // No chaining
939        )
940        .unwrap();
941
942        assert!(!result.proof.is_empty());
943        assert!(!result.scope_hash.is_empty());
944        assert!(result.chain_hash.is_empty());
945
946        let is_valid = verify_proof_v21_unified(
947            nonce,
948            context_id,
949            binding,
950            timestamp,
951            payload,
952            &result.proof,
953            &scope,
954            &result.scope_hash,
955            None,
956            "",
957        )
958        .unwrap();
959
960        assert!(is_valid);
961    }
962
963    #[test]
964    fn test_unified_chained_only() {
965        let nonce = "test_nonce_12345";
966        let context_id = "ctx_abc123";
967        let binding = "POST /checkout";
968        let timestamp = "1234567890";
969        let payload = r#"{"cart_id":"cart_123"}"#;
970        let previous_proof = "abc123def456";
971
972        let client_secret = derive_client_secret(nonce, context_id, binding);
973        let result = build_proof_v21_unified(
974            &client_secret,
975            timestamp,
976            binding,
977            payload,
978            &[], // No scoping
979            Some(previous_proof),
980        )
981        .unwrap();
982
983        assert!(!result.proof.is_empty());
984        assert!(result.scope_hash.is_empty());
985        assert!(!result.chain_hash.is_empty());
986
987        let is_valid = verify_proof_v21_unified(
988            nonce,
989            context_id,
990            binding,
991            timestamp,
992            payload,
993            &result.proof,
994            &[],
995            "",
996            Some(previous_proof),
997            &result.chain_hash,
998        )
999        .unwrap();
1000
1001        assert!(is_valid);
1002    }
1003
1004    #[test]
1005    fn test_unified_full() {
1006        let nonce = "test_nonce_12345";
1007        let context_id = "ctx_abc123";
1008        let binding = "POST /payment";
1009        let timestamp = "1234567890";
1010        let payload = r#"{"amount":500,"currency":"USD","notes":"tip"}"#;
1011        let scope = vec!["amount", "currency"];
1012        let previous_proof = "checkout_proof_xyz";
1013
1014        let client_secret = derive_client_secret(nonce, context_id, binding);
1015        let result = build_proof_v21_unified(
1016            &client_secret,
1017            timestamp,
1018            binding,
1019            payload,
1020            &scope,
1021            Some(previous_proof),
1022        )
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        )
1041        .unwrap();
1042
1043        assert!(is_valid);
1044    }
1045
1046    #[test]
1047    fn test_unified_chain_broken() {
1048        let nonce = "test_nonce_12345";
1049        let context_id = "ctx_abc123";
1050        let binding = "POST /payment";
1051        let timestamp = "1234567890";
1052        let payload = r#"{"amount":500}"#;
1053        let previous_proof = "original_proof";
1054
1055        let client_secret = derive_client_secret(nonce, context_id, binding);
1056        let result = build_proof_v21_unified(
1057            &client_secret,
1058            timestamp,
1059            binding,
1060            payload,
1061            &[],
1062            Some(previous_proof),
1063        )
1064        .unwrap();
1065
1066        // Try to verify with wrong previous proof
1067        let is_valid = verify_proof_v21_unified(
1068            nonce,
1069            context_id,
1070            binding,
1071            timestamp,
1072            payload,
1073            &result.proof,
1074            &[],
1075            "",
1076            Some("tampered_proof"), // Wrong previous proof
1077            &result.chain_hash,
1078        )
1079        .unwrap();
1080
1081        assert!(!is_valid);
1082    }
1083
1084    #[test]
1085    fn test_unified_scope_tampered() {
1086        let nonce = "test_nonce_12345";
1087        let context_id = "ctx_abc123";
1088        let binding = "POST /transfer";
1089        let timestamp = "1234567890";
1090        let payload = r#"{"amount":1000,"recipient":"user1"}"#;
1091        let scope = vec!["amount"];
1092
1093        let client_secret = derive_client_secret(nonce, context_id, binding);
1094        let result =
1095            build_proof_v21_unified(&client_secret, timestamp, binding, payload, &scope, None)
1096                .unwrap();
1097
1098        // Try to verify with different scope
1099        let tampered_scope = vec!["recipient"];
1100        let is_valid = verify_proof_v21_unified(
1101            nonce,
1102            context_id,
1103            binding,
1104            timestamp,
1105            payload,
1106            &result.proof,
1107            &tampered_scope,    // Different scope
1108            &result.scope_hash, // Original scope hash
1109            None,
1110            "",
1111        )
1112        .unwrap();
1113
1114        assert!(!is_valid);
1115    }
1116
1117    #[test]
1118    fn test_hash_proof() {
1119        let proof = "test_proof_123";
1120        let hash1 = hash_proof(proof);
1121        let hash2 = hash_proof(proof);
1122
1123        assert_eq!(hash1, hash2);
1124        assert_eq!(hash1.len(), 64); // SHA-256 = 64 hex chars
1125    }
1126}