Skip to main content

ash_core/
proof.rs

1//! Cryptographic proof generation and verification using HMAC-SHA256.
2//!
3//! This module provides the core security functions for the ASH protocol:
4//!
5//! ## Overview
6//!
7//! ASH (Anti-tamper Security Hash) proofs are deterministic integrity tokens that:
8//! - Verify request payload has not been tampered with
9//! - Bind requests to specific endpoints (method + path + query)
10//! - Prevent replay attacks through one-time contexts
11//! - Support field-level scoping for partial payload protection
12//! - Enable request chaining for sequential operations
13//!
14//! ## Core Functions
15//!
16//! | Function | Purpose |
17//! |----------|---------|
18//! | [`ash_derive_client_secret`] | Derive HMAC key from server nonce |
19//! | [`ash_build_proof`] | Generate proof for a request |
20//! | [`ash_verify_proof`] | Verify a proof on the server |
21//! | [`ash_hash_body`] | Hash canonicalized payload |
22//!
23//! ## Scoped Proofs
24//!
25//! | Function | Purpose |
26//! |----------|---------|
27//! | [`ash_build_proof_scoped`] | Proof protecting specific fields only |
28//! | [`ash_verify_proof_scoped`] | Verify scoped proof |
29//! | [`ash_extract_scoped_fields`] | Extract fields from payload by scope |
30//! | [`ash_hash_scoped_body`] | Hash only scoped fields |
31//!
32//! ## Unified Proofs (Scoping + Chaining)
33//!
34//! | Function | Purpose |
35//! |----------|---------|
36//! | [`ash_build_proof_unified`] | Full-featured proof with scoping and chaining |
37//! | [`ash_verify_proof_unified`] | Verify unified proof |
38//!
39//! ## Proof Generation Flow
40//!
41//! ```text
42//! Server                              Client
43//!   │                                    │
44//!   │──── nonce + context_id ──────────>│
45//!   │                                    │
46//!   │                                    ├─ derive_client_secret(nonce, context_id, binding)
47//!   │                                    │
48//!   │                                    ├─ canonicalize(payload)
49//!   │                                    │
50//!   │                                    ├─ hash_body(canonical_payload)
51//!   │                                    │
52//!   │                                    ├─ build_proof(client_secret, timestamp, binding, body_hash)
53//!   │                                    │
54//!   │<─── proof + timestamp + payload ──│
55//!   │                                    │
56//!   ├─ derive_client_secret(...)        │
57//!   ├─ hash_body(...)                   │
58//!   ├─ verify_proof(...)                │
59//!   │                                    │
60//! ```
61//!
62//! ## Security Properties
63//!
64//! - **HMAC-SHA256**: Cryptographically secure message authentication
65//! - **Constant-time comparison**: Prevents timing attacks during verification
66//! - **Minimum entropy**: Requires 128-bit nonces to prevent brute force
67//! - **Context binding**: Proofs are invalid for different endpoints
68//! - **Timestamp validation**: Prevents replay of old requests
69//!
70//! ## Example
71//!
72//! ```rust
73//! use ash_core::{
74//!     ash_derive_client_secret, ash_build_proof, ash_verify_proof,
75//!     ash_hash_body, ash_canonicalize_json,
76//! };
77//!
78//! // Server provides nonce and context_id
79//! let nonce = "0123456789abcdef0123456789abcdef";
80//! let context_id = "ctx_abc123";
81//! let binding = "POST|/api/transfer|";
82//!
83//! // Client derives secret and builds proof
84//! let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
85//! let payload = r#"{"amount":100,"recipient":"alice"}"#;
86//! let canonical = ash_canonicalize_json(payload).unwrap();
87//! let body_hash = ash_hash_body(&canonical);
88//! let timestamp = "1704067200";
89//! let proof = ash_build_proof(&client_secret, timestamp, binding, &body_hash).unwrap();
90//!
91//! // Server verifies proof (re-derives secret from nonce internally)
92//! let is_valid = ash_verify_proof(nonce, context_id, binding, timestamp, &body_hash, &proof).unwrap();
93//! assert!(is_valid);
94//! ```
95
96use sha2::{Digest, Sha256};
97
98use crate::compare::ash_timing_safe_equal;
99use crate::errors::{AshError, AshErrorCode};
100
101// =========================================================================
102// ASH Version Constants
103// =========================================================================
104
105/// ASH SDK version (library version).
106pub const ASH_SDK_VERSION: &str = "2.3.5";
107
108/// ASH protocol version prefix.
109pub const ASH_VERSION_PREFIX: &str = "ASHv2.1";
110
111// =========================================================================
112// Core Proof Functions (HMAC-SHA256)
113// =========================================================================
114
115use hmac::{Hmac, Mac};
116use sha2::Sha256 as HmacSha256;
117
118type HmacSha256Type = Hmac<HmacSha256>;
119
120/// Minimum bytes for nonce generation to ensure adequate entropy.
121const MIN_NONCE_BYTES: usize = 16;
122
123/// Minimum hex characters for nonce in derive_client_secret.
124/// SEC-014: Ensures adequate entropy (32 hex chars = 16 bytes = 128 bits).
125const MIN_NONCE_HEX_CHARS: usize = 32;
126
127/// Maximum HMAC key length in bytes.
128/// Maximum array index allowed in scope paths to prevent memory exhaustion.
129/// SEC-011: Limits memory allocation when processing scope paths like "items[N]".
130const MAX_ARRAY_INDEX: usize = 10000;
131
132/// Maximum total array elements that can be allocated during scope extraction.
133/// BUG-036: Prevents DoS via multiple large array allocations.
134const MAX_TOTAL_ARRAY_ALLOCATION: usize = 10000;
135
136/// Maximum scope path depth to prevent stack overflow.
137/// SEC-019: Limits recursion depth in nested scope paths.
138const MAX_SCOPE_PATH_DEPTH: usize = 32;
139
140/// Maximum reasonable timestamp (year 3000 in Unix time).
141/// SEC-018: Prevents integer overflow and unreasonable future timestamps.
142const MAX_TIMESTAMP: u64 = 32503680000;
143
144/// Maximum number of scope fields to prevent DoS.
145/// BUG-018: Limits processing time for scope extraction.
146const MAX_SCOPE_FIELDS: usize = 100;
147
148/// Scope field delimiter for hashing (using \x1F unit separator to avoid collision).
149/// BUG-002: Prevents collision when field names contain commas.
150const SCOPE_FIELD_DELIMITER: char = '\x1F';
151
152/// Maximum binding length to prevent memory exhaustion.
153/// SEC-AUDIT-004: Prevents DoS via extremely long bindings.
154const MAX_BINDING_LENGTH: usize = 8192; // 8KB
155
156/// Maximum context_id length to prevent DoS via headers/storage.
157/// SEC-CTX-001: Limits context_id to reasonable size for headers and storage.
158const MAX_CONTEXT_ID_LENGTH: usize = 256;
159
160/// Maximum nonce length to prevent DoS via headers/storage.
161/// SEC-NONCE-001: Limits nonce beyond minimum entropy requirement.
162/// SEC-AUDIT-005: HMAC-SHA256 accepts keys of any size, but keys larger than
163/// the block size (64 bytes) are hashed down to 32 bytes internally.
164/// Maximum of 512 hex characters = 256 bytes decoded, well above typical usage.
165const MAX_NONCE_LENGTH: usize = 512;
166
167/// Maximum scope field name length to prevent DoS.
168/// SEC-SCOPE-001: Limits individual field name length.
169const MAX_SCOPE_FIELD_NAME_LENGTH: usize = 64;
170
171/// Maximum total scope string length after canonicalization.
172/// SEC-SCOPE-001: Limits total scope definition size.
173const MAX_TOTAL_SCOPE_LENGTH: usize = 4096;
174
175/// Generate a cryptographically secure random nonce.
176///
177/// # Arguments
178/// * `bytes` - Number of bytes (minimum 16, recommended 32)
179///
180/// # Returns
181/// Hex-encoded nonce (64 chars for 32 bytes), or error if RNG fails.
182///
183/// # Errors
184/// Returns error if:
185/// - `bytes` is less than 16 (insufficient entropy)
186/// - System RNG fails
187///
188/// # Security (SEC-002)
189/// Returns Result instead of panicking on RNG failure.
190pub fn ash_generate_nonce(bytes: usize) -> Result<String, AshError> {
191    // Validate minimum entropy requirement
192    if bytes < MIN_NONCE_BYTES {
193        return Err(AshError::new(
194            AshErrorCode::ValidationError,
195            format!("Nonce must be at least {} bytes for adequate entropy", MIN_NONCE_BYTES),
196        ));
197    }
198
199    use getrandom::getrandom;
200    let mut buf = vec![0u8; bytes];
201    getrandom(&mut buf).map_err(|e| {
202        AshError::new(
203            AshErrorCode::InternalError,
204            format!("Random number generation failed: {}", e),
205        )
206    })?;
207    Ok(hex::encode(buf))
208}
209
210/// Generate a cryptographically secure random nonce (convenience wrapper).
211///
212/// # Panics
213/// Panics if:
214/// - `bytes` is less than 16 (insufficient entropy)
215/// - System RNG fails
216///
217/// Use `ash_generate_nonce` for the fallible version that returns `Result`.
218///
219/// # Deprecated
220/// Prefer `generate_nonce()` which returns Result.
221pub fn ash_generate_nonce_or_panic(bytes: usize) -> String {
222    ash_generate_nonce(bytes).expect("Nonce generation failed (check byte count >= 16 and RNG availability)")
223}
224
225/// Generate a unique context ID with "ash_" prefix.
226///
227/// Uses 128 bits (16 bytes) of randomness by default.
228/// For high-security applications, use `generate_context_id_256` for 256 bits.
229pub fn ash_generate_context_id() -> Result<String, AshError> {
230    Ok(format!("ash_{}", ash_generate_nonce(16)?))
231}
232
233/// Generate a unique context ID with 256 bits of entropy.
234///
235/// # Security (SEC-010)
236/// Uses 32 bytes of randomness for applications requiring higher security.
237pub fn ash_generate_context_id_256() -> Result<String, AshError> {
238    Ok(format!("ash_{}", ash_generate_nonce(32)?))
239}
240
241/// Derive client secret from server nonce.
242///
243/// SECURITY PROPERTIES:
244/// - One-way: Cannot derive nonce from clientSecret (HMAC is irreversible)
245/// - Context-bound: Unique per contextId + binding combination
246/// - Safe to expose: Client can use it but cannot forge other contexts
247///
248/// # Arguments
249///
250/// * `nonce` - Server-generated nonce (minimum 32 hex characters for adequate entropy)
251/// * `context_id` - Context identifier (must not be empty, must not contain `|` delimiter)
252/// * `binding` - Canonical binding string (may contain `|` as part of the v2.3.2+ format)
253///
254/// # Errors
255///
256/// Returns error if:
257/// - `nonce` has fewer than 32 hex characters (SEC-014: weak key material)
258/// - `nonce` contains non-hexadecimal characters (BUG-004: invalid nonce format)
259/// - `context_id` is empty (BUG-041: ambiguous context)
260/// - `context_id` contains `|` character (SEC-015: delimiter collision)
261///
262/// # Security Notes
263///
264/// - **SEC-014**: Requires minimum 32 hex chars (128 bits) to prevent weak key derivation
265/// - **BUG-004**: Validates nonce is valid hexadecimal to ensure entropy
266/// - **BUG-041**: Requires non-empty context_id to prevent ambiguous contexts
267/// - **SEC-015**: Rejects context_id containing `|` to prevent delimiter collision attacks
268/// - **BUG-001**: Delimiter collision prevented by context_id validation (binding may contain `|`)
269///
270/// The HMAC message format is `context_id|binding`. Since context_id is validated to not
271/// contain `|`, the first `|` in the message unambiguously separates context_id from binding.
272/// This allows binding to contain `|` (as in the v2.3.2+ format `METHOD|PATH|QUERY`).
273///
274/// Formula: clientSecret = HMAC-SHA256(nonce, contextId + "|" + binding)
275pub fn ash_derive_client_secret(nonce: &str, context_id: &str, binding: &str) -> Result<String, AshError> {
276    // SEC-014, SEC-NONCE-001, BUG-004: Validate nonce format via standalone validator
277    crate::validate::ash_validate_nonce(nonce)?;
278
279    // BUG-041: Validate context_id is not empty
280    if context_id.is_empty() {
281        return Err(AshError::new(
282            AshErrorCode::ValidationError,
283            "context_id cannot be empty",
284        ));
285    }
286
287    // SEC-CTX-001: Validate context_id doesn't exceed maximum length
288    if context_id.len() > MAX_CONTEXT_ID_LENGTH {
289        return Err(AshError::new(
290            AshErrorCode::ValidationError,
291            format!("context_id exceeds maximum length of {} characters", MAX_CONTEXT_ID_LENGTH),
292        ));
293    }
294
295    // SEC-CTX-001: Validate context_id contains only allowed characters
296    // Allowed: A-Z a-z 0-9 _ - .
297    if !context_id.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.') {
298        return Err(AshError::new(
299            AshErrorCode::ValidationError,
300            "context_id must contain only ASCII alphanumeric characters, underscore, hyphen, or dot",
301        ));
302    }
303
304    // SEC-015 & BUG-001: context_id delimiter collision is prevented by the
305    // charset validation above (only ASCII alphanumeric + _ - . allowed, which excludes |)
306
307    // PT-001: Validate binding is not empty to ensure endpoint-bound secrets
308    if binding.is_empty() {
309        return Err(AshError::new(
310            AshErrorCode::ValidationError,
311            "binding cannot be empty",
312        ));
313    }
314
315    // SEC-AUDIT-004: Validate binding length to prevent memory exhaustion
316    if binding.len() > MAX_BINDING_LENGTH {
317        return Err(AshError::new(
318            AshErrorCode::ValidationError,
319            format!("binding exceeds maximum length of {} bytes", MAX_BINDING_LENGTH),
320        ));
321    }
322
323    let mut mac =
324        HmacSha256Type::new_from_slice(nonce.as_bytes()).expect("HMAC can take key of any size");
325    mac.update(format!("{}|{}", context_id, binding).as_bytes());
326    Ok(hex::encode(mac.finalize().into_bytes()))
327}
328
329/// Expected length of SHA-256 hash in hex (32 bytes = 64 hex chars).
330const SHA256_HEX_LENGTH: usize = 64;
331
332/// Build cryptographic proof (client-side).
333///
334/// Formula: proof = HMAC-SHA256(clientSecret, timestamp + "|" + binding + "|" + bodyHash)
335///
336/// # Arguments
337///
338/// * `client_secret` - Derived client secret (must not be empty)
339/// * `timestamp` - Unix timestamp as string (must not be empty)
340/// * `binding` - Canonical binding (must not be empty)
341/// * `body_hash` - SHA-256 hash of canonical body (must be 64 hex chars)
342///
343/// # Returns
344///
345/// Returns `Ok(proof)` on success, or `Err` if any required input is invalid.
346///
347/// # Security Note (SEC-012)
348///
349/// All inputs are validated to be non-empty and body_hash is validated for format.
350pub fn ash_build_proof(
351    client_secret: &str,
352    timestamp: &str,
353    binding: &str,
354    body_hash: &str,
355) -> Result<String, AshError> {
356    // SEC-012: Validate required inputs are non-empty
357    if client_secret.is_empty() {
358        return Err(AshError::new(
359            AshErrorCode::ValidationError,
360            "client_secret cannot be empty",
361        ));
362    }
363    if timestamp.is_empty() {
364        return Err(AshError::new(
365            AshErrorCode::ValidationError,
366            "timestamp cannot be empty",
367        ));
368    }
369    if binding.is_empty() {
370        return Err(AshError::new(
371            AshErrorCode::ValidationError,
372            "binding cannot be empty",
373        ));
374    }
375
376    // SEC-AUDIT-004: Validate binding length to prevent memory exhaustion
377    if binding.len() > MAX_BINDING_LENGTH {
378        return Err(AshError::new(
379            AshErrorCode::ValidationError,
380            format!("binding exceeds maximum length of {} bytes", MAX_BINDING_LENGTH),
381        ));
382    }
383
384    // BUG-040: Validate body_hash format (must be valid SHA-256 hex)
385    if body_hash.len() != SHA256_HEX_LENGTH {
386        return Err(AshError::new(
387            AshErrorCode::ValidationError,
388            format!(
389                "body_hash must be {} hex characters (SHA-256), got {}",
390                SHA256_HEX_LENGTH,
391                body_hash.len()
392            ),
393        ));
394    }
395    if !body_hash.chars().all(|c| c.is_ascii_hexdigit()) {
396        return Err(AshError::new(
397            AshErrorCode::ValidationError,
398            "body_hash must contain only hexadecimal characters (0-9, a-f, A-F)",
399        ));
400    }
401
402    let message = format!("{}|{}|{}", timestamp, binding, body_hash);
403    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
404        .expect("HMAC can take key of any size");
405    mac.update(message.as_bytes());
406    Ok(hex::encode(mac.finalize().into_bytes()))
407}
408
409/// Verify proof (server-side).
410///
411/// # Returns
412///
413/// `Ok(true)` if proof is valid, `Ok(false)` if proof is invalid,
414/// `Err` if inputs are malformed.
415///
416/// # Timestamp Validation
417///
418/// This function validates the timestamp format but does NOT check expiry.
419/// Use `ash_validate_timestamp()` separately if you need to enforce timestamp freshness.
420pub fn ash_verify_proof(
421    nonce: &str,
422    context_id: &str,
423    binding: &str,
424    timestamp: &str,
425    body_hash: &str,
426    client_proof: &str,
427) -> Result<bool, AshError> {
428    // BUG-007 & BUG-012: Validate timestamp format
429    ash_validate_timestamp_format(timestamp)?;
430
431    let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
432    let expected_proof = ash_build_proof(&client_secret, timestamp, binding, body_hash)?;
433    Ok(ash_timing_safe_equal(expected_proof.as_bytes(), client_proof.as_bytes()))
434}
435
436/// Verify proof with timestamp freshness check (server-side).
437///
438/// SEC-AUDIT-002: Convenience function that combines proof verification
439/// with timestamp freshness validation to prevent replay attacks.
440///
441/// # Returns
442///
443/// `Ok(true)` if proof is valid and timestamp is fresh,
444/// `Ok(false)` if proof is invalid,
445/// `Err` if inputs are malformed or timestamp is expired/future.
446///
447/// # Arguments
448///
449/// * `nonce` - Server-generated nonce
450/// * `context_id` - Context identifier
451/// * `binding` - Canonical binding string
452/// * `timestamp` - Unix timestamp as string
453/// * `body_hash` - SHA-256 hash of canonical body
454/// * `client_proof` - Proof received from client
455/// * `max_age_seconds` - Maximum allowed age of the timestamp
456/// * `clock_skew_seconds` - Tolerance for future timestamps
457///
458/// # Example
459///
460/// ```rust
461/// use ash_core::{ash_verify_proof_with_freshness, ash_derive_client_secret, ash_build_proof, ash_hash_body};
462///
463/// let nonce = "0123456789abcdef0123456789abcdef";
464/// let context_id = "ctx_abc123";
465/// let binding = "POST|/api/test|";
466/// let now = std::time::SystemTime::now()
467///     .duration_since(std::time::UNIX_EPOCH)
468///     .unwrap()
469///     .as_secs();
470/// let timestamp = now.to_string();
471/// let body_hash = ash_hash_body("{}");
472///
473/// let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
474/// let proof = ash_build_proof(&client_secret, &timestamp, binding, &body_hash).unwrap();
475///
476/// // Verify with 5 minute max age and 60 second clock skew
477/// let result = ash_verify_proof_with_freshness(
478///     nonce, context_id, binding, &timestamp, &body_hash, &proof,
479///     300, 60
480/// );
481/// assert!(result.unwrap());
482/// ```
483#[allow(clippy::too_many_arguments)]
484pub fn ash_verify_proof_with_freshness(
485    nonce: &str,
486    context_id: &str,
487    binding: &str,
488    timestamp: &str,
489    body_hash: &str,
490    client_proof: &str,
491    max_age_seconds: u64,
492    clock_skew_seconds: u64,
493) -> Result<bool, AshError> {
494    // First validate timestamp freshness (this also validates format)
495    ash_validate_timestamp(timestamp, max_age_seconds, clock_skew_seconds)?;
496
497    // Then verify the proof
498    let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
499    let expected_proof = ash_build_proof(&client_secret, timestamp, binding, body_hash)?;
500    Ok(ash_timing_safe_equal(expected_proof.as_bytes(), client_proof.as_bytes()))
501}
502
503/// Compute SHA-256 hash of canonical body.
504pub fn ash_hash_body(canonical_body: &str) -> String {
505    let mut hasher = Sha256::new();
506    hasher.update(canonical_body.as_bytes());
507    hex::encode(hasher.finalize())
508}
509
510/// Sort and deduplicate scope fields for deterministic ordering.
511/// BUG-023: Auto-sorting prevents client/server scope order mismatches.
512fn ash_normalize_scope(scope: &[&str]) -> Vec<String> {
513    let mut sorted: Vec<String> = scope.iter().map(|s| s.to_string()).collect();
514    sorted.sort();
515    sorted.dedup();
516    sorted
517}
518
519/// Join scope fields safely using unit separator to prevent collision.
520/// BUG-002: Using '\x1F' (unit separator) instead of comma prevents collision
521/// when field names contain commas.
522/// BUG-023: Scope is now auto-sorted for deterministic ordering.
523/// BUG-028: Validates field names don't contain the delimiter to prevent hash collisions.
524/// BUG-039: Validates field names are not empty to prevent confusion.
525/// SEC-SCOPE-001: Validates field name length and total scope length.
526fn ash_join_scope_fields(scope: &[&str]) -> Result<String, AshError> {
527    let mut total_length: usize = 0;
528
529    for field in scope {
530        // BUG-039: Reject empty field names
531        if field.is_empty() {
532            return Err(AshError::new(
533                AshErrorCode::ValidationError,
534                "Scope field names cannot be empty",
535            ));
536        }
537
538        // SEC-SCOPE-001: Validate individual field name length
539        if field.len() > MAX_SCOPE_FIELD_NAME_LENGTH {
540            return Err(AshError::new(
541                AshErrorCode::ValidationError,
542                format!("Scope field name exceeds maximum length of {} characters", MAX_SCOPE_FIELD_NAME_LENGTH),
543            ));
544        }
545
546        // Track total length (field + delimiter)
547        total_length = total_length.saturating_add(field.len()).saturating_add(1);
548
549        // BUG-028: Validate no field names contain the delimiter character
550        // SEC-AUDIT-003: Use generic error message to avoid information disclosure
551        if field.contains(SCOPE_FIELD_DELIMITER) {
552            return Err(AshError::new(
553                AshErrorCode::ValidationError,
554                "Scope field contains reserved delimiter character (U+001F)",
555            ));
556        }
557    }
558
559    // SEC-SCOPE-001: Validate total scope length
560    if total_length > MAX_TOTAL_SCOPE_LENGTH {
561        return Err(AshError::new(
562            AshErrorCode::ValidationError,
563            format!("Total scope length exceeds maximum of {} bytes", MAX_TOTAL_SCOPE_LENGTH),
564        ));
565    }
566
567    let normalized = ash_normalize_scope(scope);
568    Ok(normalized.join(&SCOPE_FIELD_DELIMITER.to_string()))
569}
570
571/// Compute SHA-256 hash of scope fields.
572/// Uses unit separator ('\x1F') to prevent collision with field names containing commas.
573///
574/// # Errors
575/// Returns error if any field name contains the delimiter character (U+001F).
576/// BUG-028: This prevents hash collisions from field names containing the delimiter.
577pub fn ash_hash_scope(scope: &[&str]) -> Result<String, AshError> {
578    if scope.is_empty() {
579        return Ok(String::new());
580    }
581    Ok(ash_hash_body(&ash_join_scope_fields(scope)?))
582}
583
584#[cfg(test)]
585mod tests_proof {
586    use super::*;
587
588    // Test nonces must be at least 32 hex chars (16 bytes)
589    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef"; // 32 hex chars
590    const TEST_NONCE_2: &str = "fedcba9876543210fedcba9876543210"; // Different 32 hex chars
591    // Valid SHA-256 hash for testing (64 hex chars)
592    const TEST_BODY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
593    #[allow(dead_code)]
594    const TEST_BODY_HASH_2: &str = "d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592";
595
596    #[test]
597    fn test_derive_client_secret_deterministic() {
598        let secret1 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
599        let secret2 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
600        assert_eq!(secret1, secret2);
601    }
602
603    #[test]
604    fn test_derive_client_secret_different_inputs() {
605        let secret1 = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST /login").unwrap();
606        let secret2 = ash_derive_client_secret(TEST_NONCE_2, "ctx_abc", "POST /login").unwrap();
607        assert_ne!(secret1, secret2);
608    }
609
610    #[test]
611    fn test_derive_client_secret_rejects_short_nonce() {
612        // SEC-014: Short nonces should be rejected
613        let result = ash_derive_client_secret("short", "ctx_abc", "POST /login");
614        assert!(result.is_err());
615        assert!(result.unwrap_err().message().contains("hex characters"));
616    }
617
618    #[test]
619    fn test_derive_client_secret_rejects_non_hex_nonce() {
620        // BUG-004: Non-hex nonces should be rejected
621        let result = ash_derive_client_secret("zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", "ctx_abc", "POST /login");
622        assert!(result.is_err());
623        assert!(result.unwrap_err().message().contains("hexadecimal"));
624    }
625
626    #[test]
627    fn test_derive_client_secret_rejects_delimiter_in_context_id() {
628        // SEC-015 & SEC-CTX-001: Context IDs with | should be rejected
629        // Now rejected by charset validation (only alphanumeric + _ - . allowed)
630        let result = ash_derive_client_secret(TEST_NONCE, "ctx|abc", "POST /login");
631        assert!(result.is_err());
632        // Error message mentions either "delimiter" or invalid characters
633        let msg = result.unwrap_err().message().to_lowercase();
634        assert!(msg.contains("delimiter") || msg.contains("alphanumeric") || msg.contains("character"));
635    }
636
637    #[test]
638    fn test_derive_client_secret_allows_delimiter_in_binding() {
639        // BUG-001: Bindings with | are allowed (v2.3.2+ format uses METHOD|PATH|QUERY)
640        // Collision prevented by context_id validation
641        let result = ash_derive_client_secret(TEST_NONCE, "ctx_abc", "POST|/login|");
642        assert!(result.is_ok());
643    }
644
645    #[test]
646    fn test_derive_client_secret_rejects_empty_context_id() {
647        // BUG-041: Empty context_id should be rejected
648        let result = ash_derive_client_secret(TEST_NONCE, "", "POST /login");
649        assert!(result.is_err());
650        assert!(result.unwrap_err().message().contains("empty"));
651    }
652
653    #[test]
654    fn test_build_proof_deterministic() {
655        let proof1 = ash_build_proof("secret", "1234567890", "POST /login", TEST_BODY_HASH).unwrap();
656        let proof2 = ash_build_proof("secret", "1234567890", "POST /login", TEST_BODY_HASH).unwrap();
657        assert_eq!(proof1, proof2);
658    }
659
660    #[test]
661    fn test_build_proof_rejects_empty_inputs() {
662        // SEC-012: Validate that empty inputs are rejected
663        assert!(ash_build_proof("", "1234567890", "POST /login", TEST_BODY_HASH).is_err());
664        assert!(ash_build_proof("secret", "", "POST /login", TEST_BODY_HASH).is_err());
665        assert!(ash_build_proof("secret", "1234567890", "", TEST_BODY_HASH).is_err());
666        // Empty body_hash is now caught by length validation
667        assert!(ash_build_proof("secret", "1234567890", "POST /login", "").is_err());
668    }
669
670    #[test]
671    fn test_build_proof_rejects_invalid_body_hash() {
672        // BUG-040: Invalid body hash format should be rejected
673        // Too short
674        assert!(ash_build_proof("secret", "1234567890", "POST /login", "abc123").is_err());
675        // Too long
676        let too_long = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855aa";
677        assert!(ash_build_proof("secret", "1234567890", "POST /login", too_long).is_err());
678        // Non-hex characters
679        let non_hex = "g3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
680        assert!(ash_build_proof("secret", "1234567890", "POST /login", non_hex).is_err());
681    }
682
683    #[test]
684    fn test_ash_verify_proof() {
685        let nonce = TEST_NONCE;
686        let context_id = "ctx_abc";
687        let binding = "POST /login";
688        let timestamp = "1234567890";
689
690        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
691        let proof = ash_build_proof(&client_secret, timestamp, binding, TEST_BODY_HASH).unwrap();
692
693        assert!(ash_verify_proof(
694            nonce, context_id, binding, timestamp, TEST_BODY_HASH, &proof
695        ).unwrap());
696    }
697
698    #[test]
699    fn test_ash_hash_body() {
700        let hash = ash_hash_body(r#"{"name":"John"}"#);
701        assert_eq!(hash.len(), 64); // SHA-256 produces 32 bytes = 64 hex chars
702    }
703
704    #[test]
705    fn test_timestamp_rejects_leading_zeros() {
706        // BUG-038: Leading zeros should be rejected
707        let result = ash_validate_timestamp_format("0123456789");
708        assert!(result.is_err());
709        assert!(result.unwrap_err().message().contains("leading zeros"));
710
711        // But "0" itself is valid
712        let result = ash_validate_timestamp_format("0");
713        assert!(result.is_ok());
714    }
715
716    // SEC-CTX-001: Context ID length and charset validation
717    #[test]
718    fn test_context_id_max_length() {
719        let long_context = "a".repeat(257); // Over MAX_CONTEXT_ID_LENGTH (256)
720        let result = ash_derive_client_secret(TEST_NONCE, &long_context, "POST|/api|");
721        assert!(result.is_err());
722        assert!(result.unwrap_err().message().contains("maximum length"));
723    }
724
725    #[test]
726    fn test_context_id_at_max_length() {
727        let max_context = "a".repeat(256); // Exactly MAX_CONTEXT_ID_LENGTH
728        let result = ash_derive_client_secret(TEST_NONCE, &max_context, "POST|/api|");
729        assert!(result.is_ok());
730    }
731
732    #[test]
733    fn test_context_id_rejects_invalid_chars() {
734        // SEC-CTX-001: Only ASCII alphanumeric + _ - . allowed
735        let result = ash_derive_client_secret(TEST_NONCE, "ctx with space", "POST|/api|");
736        assert!(result.is_err());
737        assert!(result.unwrap_err().message().contains("alphanumeric"));
738
739        let result = ash_derive_client_secret(TEST_NONCE, "ctx@special", "POST|/api|");
740        assert!(result.is_err());
741
742        let result = ash_derive_client_secret(TEST_NONCE, "ctx\x00null", "POST|/api|");
743        assert!(result.is_err());
744    }
745
746    #[test]
747    fn test_context_id_allows_valid_chars() {
748        // Allowed: A-Z a-z 0-9 _ - .
749        let result = ash_derive_client_secret(TEST_NONCE, "ctx_ABC-123.test", "POST|/api|");
750        assert!(result.is_ok());
751    }
752
753    // SEC-NONCE-001 & SEC-AUDIT-005: Nonce/HMAC key length validation
754    #[test]
755    fn test_nonce_max_length() {
756        let long_nonce = "0".repeat(513); // Over MAX_NONCE_LENGTH (512)
757        let result = ash_derive_client_secret(&long_nonce, "ctx_test", "POST|/api|");
758        assert!(result.is_err());
759        assert!(result.unwrap_err().message().contains("maximum length"));
760    }
761
762    #[test]
763    fn test_nonce_at_max_length() {
764        let max_nonce = "0".repeat(512); // Exactly MAX_NONCE_LENGTH
765        let result = ash_derive_client_secret(&max_nonce, "ctx_test", "POST|/api|");
766        assert!(result.is_ok());
767    }
768}
769
770// SEC-SCOPE-001: Scope field length validation tests
771#[cfg(test)]
772mod tests_sec_scope_001 {
773    use super::*;
774
775    #[test]
776    fn test_scope_field_name_max_length() {
777        let long_field = "a".repeat(65); // Over MAX_SCOPE_FIELD_NAME_LENGTH (64)
778        let scope = vec![long_field.as_str()];
779        let result = ash_hash_scope(&scope);
780        assert!(result.is_err());
781        assert!(result.unwrap_err().message().contains("maximum length"));
782    }
783
784    #[test]
785    fn test_scope_field_name_at_max_length() {
786        let max_field = "a".repeat(64); // Exactly MAX_SCOPE_FIELD_NAME_LENGTH
787        let scope = vec![max_field.as_str()];
788        let result = ash_hash_scope(&scope);
789        assert!(result.is_ok());
790    }
791
792    #[test]
793    fn test_scope_total_length_limit() {
794        // Create many fields that together exceed MAX_TOTAL_SCOPE_LENGTH (4096)
795        // 100 fields * 50 chars each = 5000 bytes > 4096
796        let fields: Vec<String> = (0..100).map(|i| format!("field_{:045}", i)).collect();
797        let scope: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
798        let result = ash_hash_scope(&scope);
799        assert!(result.is_err());
800        assert!(result.unwrap_err().message().contains("Total scope length"));
801    }
802
803    #[test]
804    fn test_scope_within_total_length_limit() {
805        // 50 fields * 50 chars = 2500 bytes < 4096
806        let fields: Vec<String> = (0..50).map(|i| format!("field_{:043}", i)).collect();
807        let scope: Vec<&str> = fields.iter().map(|s| s.as_str()).collect();
808        let result = ash_hash_scope(&scope);
809        assert!(result.is_ok());
810    }
811}
812
813// =========================================================================
814// ASH v2.2 - Context Scoping (Selective Field Protection)
815// =========================================================================
816
817use serde_json::{Map, Value};
818
819use crate::canonicalize::ash_canonicalize_json_value;
820
821/// Default maximum age for timestamps (5 minutes).
822pub const DEFAULT_MAX_TIMESTAMP_AGE_SECONDS: u64 = 300;
823
824/// Default clock skew tolerance (60 seconds).
825pub const DEFAULT_CLOCK_SKEW_SECONDS: u64 = 60;
826
827/// Validate timestamp format only (not freshness).
828///
829/// # BUG-007 & BUG-012
830///
831/// Validates that timestamp:
832/// - Contains only ASCII digits (no whitespace, no signs)
833/// - Has no leading zeros (except "0" itself) - BUG-038
834/// - Parses as valid u64
835/// - Is within reasonable bounds
836///
837/// This is used by verification functions to ensure well-formed input.
838/// For freshness validation, use `ash_validate_timestamp()`.
839pub fn ash_validate_timestamp_format(timestamp: &str) -> Result<u64, AshError> {
840    // BUG-012: Check for non-digit characters (no whitespace, no signs)
841    if timestamp.is_empty() {
842        return Err(AshError::new(
843            AshErrorCode::TimestampInvalid,
844            "Timestamp cannot be empty",
845        ));
846    }
847
848    if !timestamp.chars().all(|c| c.is_ascii_digit()) {
849        return Err(AshError::new(
850            AshErrorCode::TimestampInvalid,
851            "Timestamp must contain only digits (0-9)",
852        ));
853    }
854
855    // BUG-038: Reject leading zeros (except "0" itself)
856    // This ensures cross-implementation consistency in normalization
857    if timestamp.len() > 1 && timestamp.starts_with('0') {
858        return Err(AshError::new(
859            AshErrorCode::TimestampInvalid,
860            "Timestamp must not have leading zeros",
861        ));
862    }
863
864    // Parse timestamp
865    let ts: u64 = timestamp.parse().map_err(|_| {
866        AshError::new(
867            AshErrorCode::TimestampInvalid,
868            "Timestamp must be a valid integer",
869        )
870    })?;
871
872    // SEC-018: Check for unreasonably large timestamp
873    if ts > MAX_TIMESTAMP {
874        return Err(AshError::new(
875            AshErrorCode::TimestampInvalid,
876            "Timestamp exceeds maximum allowed value",
877        ));
878    }
879
880    Ok(ts)
881}
882
883/// Validate a timestamp string.
884///
885/// # Arguments
886/// * `timestamp` - Unix timestamp as string (seconds since epoch)
887/// * `max_age_seconds` - Maximum allowed age of the timestamp
888/// * `clock_skew_seconds` - Tolerance for future timestamps (clock skew)
889///
890/// # Returns
891/// Ok(()) if valid, Err with appropriate error if invalid.
892///
893/// # Boundary Conditions
894/// - A timestamp exactly `max_age_seconds` old is **valid** (boundary inclusive)
895/// - A timestamp exactly `clock_skew_seconds` in the future is **valid**
896///
897/// # Security Notes
898/// - **SEC-005**: Validates timestamps to prevent replay attacks with stale proofs
899/// - **SEC-018**: Rejects unreasonably large timestamps (beyond year 3000)
900///
901/// # Example
902/// ```rust
903/// use ash_core::ash_validate_timestamp;
904///
905/// // Get current timestamp
906/// let now = std::time::SystemTime::now()
907///     .duration_since(std::time::UNIX_EPOCH)
908///     .unwrap()
909///     .as_secs();
910///
911/// // Recent timestamp should be valid
912/// assert!(ash_validate_timestamp(&now.to_string(), 300, 60).is_ok());
913///
914/// // Old timestamp should fail
915/// let old = now - 600; // 10 minutes ago
916/// assert!(ash_validate_timestamp(&old.to_string(), 300, 60).is_err());
917/// ```
918pub fn ash_validate_timestamp(
919    timestamp: &str,
920    max_age_seconds: u64,
921    clock_skew_seconds: u64,
922) -> Result<(), AshError> {
923    use std::time::{SystemTime, UNIX_EPOCH};
924
925    // BUG-007 & BUG-012: Use strict format validation
926    let ts = ash_validate_timestamp_format(timestamp)?;
927
928    // Get current time
929    let now = SystemTime::now()
930        .duration_since(UNIX_EPOCH)
931        .map_err(|_| {
932            AshError::new(
933                AshErrorCode::InternalError,
934                "System time error",
935            )
936        })?
937        .as_secs();
938
939    // BUG-045: Check for future timestamp with overflow protection
940    // Use saturating_add to prevent overflow if now is near u64::MAX
941    if ts > now.saturating_add(clock_skew_seconds) {
942        return Err(AshError::new(
943            AshErrorCode::TimestampInvalid,
944            "Timestamp is in the future",
945        ));
946    }
947
948    // Check for expired timestamp
949    if now > ts && now - ts > max_age_seconds {
950        return Err(AshError::new(
951            AshErrorCode::TimestampInvalid,
952            "Timestamp has expired",
953        ));
954    }
955
956    Ok(())
957}
958
959/// Extract scoped fields from a JSON value.
960///
961/// # Path Syntax
962///
963/// Scope paths use dot notation for nested fields and bracket notation for arrays:
964/// - `"name"` - top-level field
965/// - `"user.name"` - nested field
966/// - `"items[0]"` - array element
967/// - `"items[0].id"` - nested field in array element
968///
969/// # Limitations
970///
971/// **Field names containing dots are NOT supported.** The dot character (`.`) is
972/// used as the path separator, so a field like `{"user.name": "John"}` cannot be
973/// directly addressed. The path `"user.name"` will look for `payload["user"]["name"]`,
974/// not `payload["user.name"]`.
975///
976/// If you need to protect fields with dots in their names, either:
977/// 1. Rename the fields before signing
978/// 2. Use full payload protection (empty scope)
979pub fn ash_extract_scoped_fields(payload: &Value, scope: &[&str]) -> Result<Value, AshError> {
980    ash_extract_scoped_fields_internal(payload, scope, false)
981}
982
983/// Extract scoped fields from a JSON value with strict mode.
984///
985/// # Arguments
986/// * `payload` - The JSON payload to extract from
987/// * `scope` - List of field paths to extract
988/// * `strict` - If true, all scoped fields must exist in payload
989///
990/// # Security (SEC-006)
991/// Strict mode ensures all expected scoped fields are present,
992/// preventing accidental scope mismatches.
993///
994/// # Example
995/// ```rust
996/// use ash_core::ash_extract_scoped_fields_strict;
997/// use serde_json::json;
998///
999/// let payload = json!({"amount": 100});
1000/// let scope = vec!["amount", "recipient"];
1001///
1002/// // Non-strict mode: missing fields are ignored
1003/// assert!(ash_extract_scoped_fields_strict(&payload, &scope, false).is_ok());
1004///
1005/// // Strict mode: missing fields cause error
1006/// assert!(ash_extract_scoped_fields_strict(&payload, &scope, true).is_err());
1007/// ```
1008pub fn ash_extract_scoped_fields_strict(
1009    payload: &Value,
1010    scope: &[&str],
1011    strict: bool,
1012) -> Result<Value, AshError> {
1013    ash_extract_scoped_fields_internal(payload, scope, strict)
1014}
1015
1016/// Internal implementation of scoped field extraction.
1017fn ash_extract_scoped_fields_internal(
1018    payload: &Value,
1019    scope: &[&str],
1020    strict: bool,
1021) -> Result<Value, AshError> {
1022    if scope.is_empty() {
1023        return Ok(payload.clone());
1024    }
1025
1026    // BUG-018: Limit scope field count to prevent DoS
1027    if scope.len() > MAX_SCOPE_FIELDS {
1028        return Err(AshError::new(
1029            AshErrorCode::ValidationError,
1030            format!("Scope exceeds maximum of {} fields", MAX_SCOPE_FIELDS),
1031        ));
1032    }
1033
1034    // BUG-036: Calculate total array allocation needed and validate
1035    let total_allocation = ash_calculate_total_array_allocation(scope);
1036    if total_allocation > MAX_TOTAL_ARRAY_ALLOCATION {
1037        return Err(AshError::new(
1038            AshErrorCode::ValidationError,
1039            format!(
1040                "Scope array indices exceed maximum total allocation of {} elements",
1041                MAX_TOTAL_ARRAY_ALLOCATION
1042            ),
1043        ));
1044    }
1045
1046    let mut result = Map::new();
1047
1048    for field_path in scope {
1049        let value = ash_get_nested_value(payload, field_path);
1050        if let Some(v) = value {
1051            ash_set_nested_value(&mut result, field_path, v);
1052        } else if strict {
1053            // SEC-006: In strict mode, missing fields are an error
1054            return Err(AshError::new(
1055                AshErrorCode::ScopedFieldMissing,
1056                format!("Required scoped field missing: {}", field_path),
1057            ));
1058        }
1059    }
1060
1061    Ok(Value::Object(result))
1062}
1063
1064/// Calculate total array allocation needed for a set of scope paths.
1065/// BUG-036: Prevents DoS via multiple large array allocations.
1066/// BUG-050: Uses saturating arithmetic to prevent overflow panic in debug mode.
1067fn ash_calculate_total_array_allocation(scope: &[&str]) -> usize {
1068    let mut total = 0usize;
1069    for path in scope {
1070        for part in path.split('.') {
1071            let notation = ash_parse_all_array_indices(part);
1072            for idx in &notation.indices {
1073                // Each index requires at least (idx + 1) elements
1074                // BUG-050: Use saturating_add for idx+1 to prevent overflow
1075                total = total.saturating_add(idx.saturating_add(1));
1076            }
1077        }
1078    }
1079    total
1080}
1081
1082fn ash_get_nested_value(payload: &Value, path: &str) -> Option<Value> {
1083    ash_get_nested_value_with_depth(payload, path, 0)
1084}
1085
1086/// Internal implementation with depth tracking.
1087/// BUG-021: Added depth tracking and MAX_ARRAY_INDEX checks for consistency with set_nested_value.
1088fn ash_get_nested_value_with_depth(payload: &Value, path: &str, depth: usize) -> Option<Value> {
1089    // SEC-019: Check path depth to prevent stack overflow
1090    if depth > MAX_SCOPE_PATH_DEPTH {
1091        return None;
1092    }
1093
1094    let parts: Vec<&str> = path.split('.').collect();
1095
1096    // SEC-019: Also check total path depth
1097    if parts.len() > MAX_SCOPE_PATH_DEPTH {
1098        return None;
1099    }
1100
1101    let mut current = payload;
1102
1103    for part in parts {
1104        // BUG-022: Parse all array indices from the part (supports items[0][1])
1105        let indices = ash_parse_all_array_indices(part);
1106
1107        match current {
1108            Value::Object(map) => {
1109                current = map.get(indices.key)?;
1110                // Apply all indices sequentially
1111                for idx in &indices.indices {
1112                    // SEC-011: Check array index limit
1113                    if *idx > MAX_ARRAY_INDEX {
1114                        return None;
1115                    }
1116                    if let Value::Array(arr) = current {
1117                        current = arr.get(*idx)?;
1118                    } else {
1119                        return None;
1120                    }
1121                }
1122            }
1123            Value::Array(arr) => {
1124                // Direct array access (path segment is just a number)
1125                let idx: usize = indices.key.parse().ok()?;
1126                // SEC-011: Check array index limit
1127                if idx > MAX_ARRAY_INDEX {
1128                    return None;
1129                }
1130                current = arr.get(idx)?;
1131                // Apply any additional indices
1132                for idx in &indices.indices {
1133                    if *idx > MAX_ARRAY_INDEX {
1134                        return None;
1135                    }
1136                    if let Value::Array(arr) = current {
1137                        current = arr.get(*idx)?;
1138                    } else {
1139                        return None;
1140                    }
1141                }
1142            }
1143            _ => return None,
1144        }
1145    }
1146
1147    Some(current.clone())
1148}
1149
1150/// Result of parsing array notation from a path segment.
1151/// BUG-022: Supports multi-dimensional arrays like `items[0][1]`.
1152struct ArrayNotation<'a> {
1153    /// The key part (e.g., "items" from "items[0][1]")
1154    key: &'a str,
1155    /// All indices in order (e.g., [0, 1] from "items[0][1]")
1156    indices: Vec<usize>,
1157}
1158
1159/// Parse all array indices from a path segment (e.g., "items[0][1]" -> key="items", indices=[0, 1]).
1160///
1161/// BUG-022: Supports multi-dimensional array access.
1162///
1163/// Handles edge cases:
1164/// - No brackets: "items" -> key="items", indices=[]
1165/// - Single index: "items[0]" -> key="items", indices=[0]
1166/// - Multi-dimensional: "items[0][1]" -> key="items", indices=[0, 1]
1167/// - Empty brackets: "items[]" -> key="items", indices=[] (invalid, treated as no index)
1168/// - Invalid index: "items[abc]" -> key="items", indices=[] (stops parsing)
1169/// - Mixed valid/invalid: "items[0][abc]" -> key="items", indices=[] (invalidated due to unparsed content)
1170/// - Trailing text after valid indices: "items[0]extra" -> key="items", indices=[] (invalidated)
1171fn ash_parse_all_array_indices(part: &str) -> ArrayNotation<'_> {
1172    let bracket_start = match part.find('[') {
1173        Some(pos) => pos,
1174        None => return ArrayNotation { key: part, indices: vec![] },
1175    };
1176
1177    let key = &part[..bracket_start];
1178    let mut indices = Vec::new();
1179    let mut remaining = &part[bracket_start..];
1180
1181    // Parse all [N] patterns
1182    while remaining.starts_with('[') {
1183        let bracket_end = match remaining.find(']') {
1184            Some(pos) => pos,
1185            None => break, // Malformed - no closing bracket
1186        };
1187
1188        let index_str = &remaining[1..bracket_end];
1189        if index_str.is_empty() {
1190            break; // Empty brackets - stop parsing
1191        }
1192
1193        match index_str.parse::<usize>() {
1194            Ok(idx) => indices.push(idx),
1195            Err(_) => break, // Invalid index - stop parsing
1196        }
1197
1198        remaining = &remaining[bracket_end + 1..];
1199    }
1200
1201    // If there's trailing text after the last ], invalidate all indices
1202    // This handles cases like "items[0]extra" or "items[0][abc]"
1203    if !remaining.is_empty() {
1204        // Return with valid indices parsed so far (which may be empty)
1205        // but key is still the proper key part
1206        return ArrayNotation { key, indices: vec![] };
1207    }
1208
1209    ArrayNotation { key, indices }
1210}
1211
1212/// Set a nested value in a JSON map using a dot-separated path.
1213///
1214/// # Limitations
1215///
1216/// - **SEC-011**: Array indices limited to MAX_ARRAY_INDEX (10,000) to prevent memory exhaustion
1217/// - **SEC-019**: Path depth limited to MAX_SCOPE_PATH_DEPTH (32) to prevent stack overflow
1218/// - **BUG-022**: Supports multi-dimensional arrays (e.g., "matrix[0][1]")
1219/// - Field names containing dots cannot be addressed (use top-level keys only)
1220/// - Indices exceeding the limit are silently ignored
1221fn ash_set_nested_value(result: &mut Map<String, Value>, path: &str, value: Value) {
1222    ash_set_nested_value_with_depth(result, path, value, 0);
1223}
1224
1225/// Internal implementation with depth tracking.
1226fn ash_set_nested_value_with_depth(result: &mut Map<String, Value>, path: &str, value: Value, depth: usize) {
1227    // SEC-019: Check recursion depth to prevent stack overflow
1228    if depth > MAX_SCOPE_PATH_DEPTH {
1229        return;
1230    }
1231
1232    let parts: Vec<&str> = path.split('.').collect();
1233    if parts.len() > MAX_SCOPE_PATH_DEPTH {
1234        return; // Silently ignore overly deep paths
1235    }
1236
1237    if parts.len() == 1 {
1238        let notation = ash_parse_all_array_indices(parts[0]);
1239        if notation.indices.is_empty() {
1240            // Simple key, no array notation
1241            result.insert(notation.key.to_string(), value);
1242        } else {
1243            // BUG-022: Handle multi-dimensional array notation
1244            ash_set_value_at_indices(result, notation.key, &notation.indices, value);
1245        }
1246        return;
1247    }
1248
1249    let notation = ash_parse_all_array_indices(parts[0]);
1250    let remaining_path = parts[1..].join(".");
1251
1252    if notation.indices.is_empty() {
1253        // Simple nested object
1254        let nested = result
1255            .entry(notation.key.to_string())
1256            .or_insert_with(|| Value::Object(Map::new()));
1257
1258        if let Value::Object(nested_map) = nested {
1259            ash_set_nested_value_with_depth(nested_map, &remaining_path, value, depth + 1);
1260        }
1261    } else {
1262        // BUG-022: Handle multi-dimensional array in path
1263        let target = ash_get_or_create_at_indices(result, notation.key, &notation.indices);
1264        if let Some(Value::Object(nested_map)) = target {
1265            ash_set_nested_value_with_depth(nested_map, &remaining_path, value, depth + 1);
1266        }
1267    }
1268}
1269
1270/// Set a value at multi-dimensional array indices.
1271/// BUG-022: Supports paths like "matrix[0][1]".
1272fn ash_set_value_at_indices(result: &mut Map<String, Value>, key: &str, indices: &[usize], value: Value) {
1273    if indices.is_empty() {
1274        result.insert(key.to_string(), value);
1275        return;
1276    }
1277
1278    // SEC-011: Check all indices before proceeding
1279    for idx in indices {
1280        if *idx > MAX_ARRAY_INDEX {
1281            return; // Silently ignore oversized indices
1282        }
1283    }
1284
1285    // Get or create the top-level array
1286    let arr = result
1287        .entry(key.to_string())
1288        .or_insert_with(|| Value::Array(Vec::new()));
1289
1290    ash_set_value_in_nested_array(arr, indices, value);
1291}
1292
1293/// Recursively set a value in nested arrays.
1294fn ash_set_value_in_nested_array(current: &mut Value, indices: &[usize], value: Value) {
1295    if indices.is_empty() {
1296        *current = value;
1297        return;
1298    }
1299
1300    let idx = indices[0];
1301    let remaining = &indices[1..];
1302
1303    // Ensure current is an array
1304    if !current.is_array() {
1305        *current = Value::Array(Vec::new());
1306    }
1307
1308    if let Value::Array(arr) = current {
1309        // Extend array if needed
1310        while arr.len() <= idx {
1311            if remaining.is_empty() {
1312                arr.push(Value::Null);
1313            } else {
1314                arr.push(Value::Array(Vec::new()));
1315            }
1316        }
1317
1318        if remaining.is_empty() {
1319            arr[idx] = value;
1320        } else {
1321            ash_set_value_in_nested_array(&mut arr[idx], remaining, value);
1322        }
1323    }
1324}
1325
1326/// Get or create a nested object at multi-dimensional array indices.
1327/// Returns a mutable reference to the nested map if successful.
1328fn ash_get_or_create_at_indices<'a>(
1329    result: &'a mut Map<String, Value>,
1330    key: &str,
1331    indices: &[usize],
1332) -> Option<&'a mut Value> {
1333    if indices.is_empty() {
1334        return result.get_mut(key);
1335    }
1336
1337    // SEC-011: Check all indices before proceeding
1338    for idx in indices {
1339        if *idx > MAX_ARRAY_INDEX {
1340            return None;
1341        }
1342    }
1343
1344    // Get or create the top-level array
1345    let arr = result
1346        .entry(key.to_string())
1347        .or_insert_with(|| Value::Array(Vec::new()));
1348
1349    ash_navigate_to_nested_index(arr, indices)
1350}
1351
1352/// Navigate to a nested position in arrays, creating structure as needed.
1353fn ash_navigate_to_nested_index<'a>(current: &'a mut Value, indices: &[usize]) -> Option<&'a mut Value> {
1354    if indices.is_empty() {
1355        return Some(current);
1356    }
1357
1358    let idx = indices[0];
1359    let remaining = &indices[1..];
1360
1361    // Ensure current is an array
1362    if !current.is_array() {
1363        *current = Value::Array(Vec::new());
1364    }
1365
1366    if let Value::Array(arr) = current {
1367        // Extend array if needed
1368        while arr.len() <= idx {
1369            if remaining.is_empty() {
1370                arr.push(Value::Object(Map::new()));
1371            } else {
1372                arr.push(Value::Array(Vec::new()));
1373            }
1374        }
1375
1376        if remaining.is_empty() {
1377            // Ensure the target is an object for further nesting
1378            if !arr[idx].is_object() {
1379                arr[idx] = Value::Object(Map::new());
1380            }
1381            Some(&mut arr[idx])
1382        } else {
1383            ash_navigate_to_nested_index(&mut arr[idx], remaining)
1384        }
1385    } else {
1386        None
1387    }
1388}
1389/// Build v2.2 cryptographic proof with scoped fields.
1390///
1391/// # Scope Auto-Sorting (BUG-023 fix)
1392///
1393/// The scope array is **automatically sorted** for deterministic ordering.
1394/// `["b", "a"]` and `["a", "b"]` will produce the **same** hash.
1395/// This prevents client/server scope order mismatches.
1396///
1397/// # Empty Payload (BUG-024 fix)
1398///
1399/// Empty string payload `""` is treated as empty object `{}`.
1400pub fn ash_build_proof_scoped(
1401    client_secret: &str,
1402    timestamp: &str,
1403    binding: &str,
1404    payload: &str,
1405    scope: &[&str],
1406) -> Result<(String, String), AshError> {
1407    // BUG-046: Validate required inputs (matching ash_build_proof validation)
1408    if client_secret.is_empty() {
1409        return Err(AshError::new(
1410            AshErrorCode::ValidationError,
1411            "client_secret cannot be empty",
1412        ));
1413    }
1414    if timestamp.is_empty() {
1415        return Err(AshError::new(
1416            AshErrorCode::ValidationError,
1417            "timestamp cannot be empty",
1418        ));
1419    }
1420    if binding.is_empty() {
1421        return Err(AshError::new(
1422            AshErrorCode::ValidationError,
1423            "binding cannot be empty",
1424        ));
1425    }
1426
1427    // BUG-024: Handle empty payload as empty object
1428    let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
1429        Value::Object(serde_json::Map::new())
1430    } else {
1431        // SEC-AUDIT-006: Sanitize error message to prevent information disclosure
1432        serde_json::from_str(payload)
1433            .map_err(|_e| AshError::canonicalization_error())?
1434    };
1435
1436    let scoped_payload = ash_extract_scoped_fields(&json_payload, scope)?;
1437
1438    // Use proper canonicalization (sorted keys, NFC normalization, etc.)
1439    let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
1440
1441    let body_hash = ash_hash_body(&canonical_scoped);
1442
1443    // BUG-002 & BUG-028: Use unit separator instead of comma to prevent collision
1444    let scope_hash = ash_hash_scope(scope)?;
1445
1446    let message = format!("{}|{}|{}|{}", timestamp, binding, body_hash, scope_hash);
1447    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
1448        .expect("HMAC can take key of any size");
1449    mac.update(message.as_bytes());
1450    let proof = hex::encode(mac.finalize().into_bytes());
1451
1452    Ok((proof, scope_hash))
1453}
1454
1455/// Verify v2.2 proof with scoped fields.
1456#[allow(clippy::too_many_arguments)]
1457pub fn ash_verify_proof_scoped(
1458    nonce: &str,
1459    context_id: &str,
1460    binding: &str,
1461    timestamp: &str,
1462    payload: &str,
1463    scope: &[&str],
1464    scope_hash: &str,
1465    client_proof: &str,
1466) -> Result<bool, AshError> {
1467    // BUG-007 & BUG-012: Validate timestamp format
1468    ash_validate_timestamp_format(timestamp)?;
1469
1470    // BUG-049 & SEC-013: Validate consistency - scope_hash must be empty when scope is empty
1471    if scope.is_empty() && !scope_hash.is_empty() {
1472        return Err(AshError::new(
1473            AshErrorCode::ScopeMismatch,
1474            "scope_hash must be empty when scope is empty",
1475        ));
1476    }
1477
1478    // BUG-002 & BUG-028: Use unit separator instead of comma
1479    let expected_scope_hash = ash_hash_scope(scope)?;
1480    if !ash_timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
1481        return Ok(false);
1482    }
1483
1484    let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
1485
1486    let (expected_proof, _) =
1487        ash_build_proof_scoped(&client_secret, timestamp, binding, payload, scope)?;
1488
1489    Ok(ash_timing_safe_equal(
1490        expected_proof.as_bytes(),
1491        client_proof.as_bytes(),
1492    ))
1493}
1494
1495/// Hash scoped payload for client-side use.
1496///
1497/// Missing scope fields are silently ignored. Use `ash_hash_scoped_body_strict`
1498/// if you want to enforce that all scope fields exist.
1499pub fn ash_hash_scoped_body(payload: &str, scope: &[&str]) -> Result<String, AshError> {
1500    ash_hash_scoped_body_internal(payload, scope, false)
1501}
1502
1503/// Hash scoped payload with strict mode.
1504///
1505/// # BUG-011
1506///
1507/// This variant requires all scope fields to exist in the payload,
1508/// matching the behavior of `extract_scoped_fields_strict`.
1509///
1510/// # Example
1511///
1512/// ```rust
1513/// use ash_core::ash_hash_scoped_body_strict;
1514///
1515/// let payload = r#"{"amount": 100}"#;
1516/// let scope = vec!["amount", "recipient"];
1517///
1518/// // Strict mode: missing "recipient" causes error
1519/// assert!(ash_hash_scoped_body_strict(payload, &scope).is_err());
1520/// ```
1521pub fn ash_hash_scoped_body_strict(payload: &str, scope: &[&str]) -> Result<String, AshError> {
1522    ash_hash_scoped_body_internal(payload, scope, true)
1523}
1524
1525/// Internal implementation of hash_scoped_body with strict mode option.
1526/// BUG-024: Handles empty payload as empty object.
1527fn ash_hash_scoped_body_internal(payload: &str, scope: &[&str], strict: bool) -> Result<String, AshError> {
1528    // BUG-024: Handle empty payload as empty object
1529    let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
1530        Value::Object(serde_json::Map::new())
1531    } else {
1532        // SEC-AUDIT-006: Sanitize error message to prevent information disclosure
1533        serde_json::from_str(payload)
1534            .map_err(|_e| AshError::canonicalization_error())?
1535    };
1536
1537    let scoped_payload = ash_extract_scoped_fields_internal(&json_payload, scope, strict)?;
1538
1539    // Use proper canonicalization (sorted keys, NFC normalization, etc.)
1540    let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
1541
1542    Ok(ash_hash_body(&canonical_scoped))
1543}
1544
1545#[cfg(test)]
1546mod tests_scoping {
1547    use super::*;
1548
1549    // Test nonces must be at least 32 hex chars (16 bytes)
1550    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
1551
1552    #[test]
1553    fn test_build_verify_scoped_proof() {
1554        let nonce = TEST_NONCE;
1555        let context_id = "ctx_abc123";
1556        let binding = "POST /transfer";
1557        let timestamp = "1234567890";
1558        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
1559        let scope = vec!["amount", "recipient"];
1560
1561        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1562        let (proof, scope_hash) =
1563            ash_build_proof_scoped(&client_secret, timestamp, binding, payload, &scope).unwrap();
1564
1565        let is_valid = ash_verify_proof_scoped(
1566            nonce,
1567            context_id,
1568            binding,
1569            timestamp,
1570            payload,
1571            &scope,
1572            &scope_hash,
1573            &proof,
1574        )
1575        .unwrap();
1576
1577        assert!(is_valid);
1578    }
1579
1580    #[test]
1581    fn test_scoped_proof_ignores_unscoped_changes() {
1582        let nonce = TEST_NONCE;
1583        let context_id = "ctx_abc123";
1584        let binding = "POST /transfer";
1585        let timestamp = "1234567890";
1586        let scope = vec!["amount", "recipient"];
1587
1588        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1589
1590        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
1591        let (proof, scope_hash) =
1592            ash_build_proof_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
1593
1594        let payload2 = r#"{"amount":1000,"recipient":"user1","notes":"world"}"#;
1595
1596        let is_valid = ash_verify_proof_scoped(
1597            nonce,
1598            context_id,
1599            binding,
1600            timestamp,
1601            payload2,
1602            &scope,
1603            &scope_hash,
1604            &proof,
1605        )
1606        .unwrap();
1607
1608        assert!(is_valid);
1609    }
1610
1611    #[test]
1612    fn test_scoped_proof_detects_scoped_changes() {
1613        let nonce = TEST_NONCE;
1614        let context_id = "ctx_abc123";
1615        let binding = "POST /transfer";
1616        let timestamp = "1234567890";
1617        let scope = vec!["amount", "recipient"];
1618
1619        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1620
1621        let payload1 = r#"{"amount":1000,"recipient":"user1","notes":"hello"}"#;
1622        let (proof, scope_hash) =
1623            ash_build_proof_scoped(&client_secret, timestamp, binding, payload1, &scope).unwrap();
1624
1625        let payload2 = r#"{"amount":9999,"recipient":"user1","notes":"hello"}"#;
1626
1627        let is_valid = ash_verify_proof_scoped(
1628            nonce,
1629            context_id,
1630            binding,
1631            timestamp,
1632            payload2,
1633            &scope,
1634            &scope_hash,
1635            &proof,
1636        )
1637        .unwrap();
1638
1639        assert!(!is_valid);
1640    }
1641
1642    #[test]
1643    fn test_extract_scoped_fields_with_array_index() {
1644        // Test that array notation preserves array structure
1645        let payload: Value = serde_json::from_str(
1646            r#"{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}],"total":100}"#
1647        ).unwrap();
1648
1649        let scope = vec!["items[0]"];
1650        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
1651
1652        // Should preserve array structure: {"items":[{"id":1,"name":"a"}]}
1653        assert!(scoped.is_object());
1654        let items = scoped.get("items").expect("should have items key");
1655        assert!(items.is_array(), "items should be an array, got: {:?}", items);
1656        let arr = items.as_array().unwrap();
1657        assert_eq!(arr.len(), 1);
1658        assert_eq!(arr[0]["id"], 1);
1659    }
1660
1661    #[test]
1662    fn test_extract_scoped_fields_with_nested_array_path() {
1663        // Test nested path with array notation: items[0].id
1664        let payload: Value = serde_json::from_str(
1665            r#"{"items":[{"id":1,"name":"a"},{"id":2,"name":"b"}]}"#
1666        ).unwrap();
1667
1668        let scope = vec!["items[0].id"];
1669        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
1670
1671        // Should be: {"items":[{"id":1}]}
1672        assert!(scoped.is_object());
1673        let items = scoped.get("items").expect("should have items key");
1674        assert!(items.is_array(), "items should be an array");
1675        let arr = items.as_array().unwrap();
1676        assert_eq!(arr.len(), 1);
1677        assert_eq!(arr[0]["id"], 1);
1678    }
1679}
1680
1681// =========================================================================
1682// ASH v2.3 - Unified Proof Functions (Scoping + Chaining)
1683// =========================================================================
1684
1685/// Result from unified proof generation.
1686#[derive(Debug, Clone, PartialEq)]
1687pub struct UnifiedProofResult {
1688    /// The cryptographic proof.
1689    pub proof: String,
1690    /// Hash of the scope (empty if no scoping).
1691    pub scope_hash: String,
1692    /// Hash of the previous proof (empty if no chaining).
1693    pub chain_hash: String,
1694}
1695
1696/// Hash a proof for chaining purposes.
1697///
1698/// Used to create chain links between sequential requests.
1699///
1700/// # Errors
1701/// Returns error if proof is empty. BUG-029: Empty proofs should not be allowed
1702/// in chains to prevent ambiguous chain starts.
1703pub fn ash_hash_proof(proof: &str) -> Result<String, AshError> {
1704    // BUG-029: Validate non-empty proof to prevent ambiguous chain starts
1705    if proof.is_empty() {
1706        return Err(AshError::new(
1707            AshErrorCode::ValidationError,
1708            "proof cannot be empty for chain hashing",
1709        ));
1710    }
1711    let mut hasher = Sha256::new();
1712    hasher.update(proof.as_bytes());
1713    Ok(hex::encode(hasher.finalize()))
1714}
1715
1716/// Build unified v2.3 cryptographic proof (client-side).
1717///
1718/// Supports optional scoping and chaining:
1719/// - `scope`: Fields to protect (empty = full payload)
1720/// - `previous_proof`: Previous proof in chain (None or Some("") = no chaining)
1721///
1722/// # Scope Auto-Sorting (BUG-023 fix)
1723///
1724/// The scope array is **automatically sorted** for deterministic ordering.
1725/// `["b", "a"]` and `["a", "b"]` will produce the **same** hash.
1726/// This prevents client/server scope order mismatches.
1727///
1728/// # Empty Payload (BUG-024 fix)
1729///
1730/// Empty string payload `""` is treated as empty object `{}`.
1731///
1732/// # Note on Empty Previous Proof
1733///
1734/// Both `previous_proof = None` and `previous_proof = Some("")` are treated as
1735/// "no chaining" and produce an empty chain_hash.
1736///
1737/// Formula:
1738/// ```text
1739/// scopeHash  = scope.len() > 0 ? SHA256(sorted(scope).join("\x1F")) : ""
1740/// bodyHash   = SHA256(canonicalize(scopedPayload))
1741/// chainHash  = previous_proof.is_some() ? SHA256(previous_proof) : ""
1742/// proof      = HMAC-SHA256(clientSecret, timestamp|binding|bodyHash|scopeHash|chainHash)
1743/// ```
1744pub fn ash_build_proof_unified(
1745    client_secret: &str,
1746    timestamp: &str,
1747    binding: &str,
1748    payload: &str,
1749    scope: &[&str],
1750    previous_proof: Option<&str>,
1751) -> Result<UnifiedProofResult, AshError> {
1752    // BUG-047: Validate required inputs (matching ash_build_proof validation)
1753    if client_secret.is_empty() {
1754        return Err(AshError::new(
1755            AshErrorCode::ValidationError,
1756            "client_secret cannot be empty",
1757        ));
1758    }
1759    if timestamp.is_empty() {
1760        return Err(AshError::new(
1761            AshErrorCode::ValidationError,
1762            "timestamp cannot be empty",
1763        ));
1764    }
1765    if binding.is_empty() {
1766        return Err(AshError::new(
1767            AshErrorCode::ValidationError,
1768            "binding cannot be empty",
1769        ));
1770    }
1771
1772    // BUG-024: Handle empty payload as empty object
1773    let json_payload: Value = if payload.is_empty() || payload.trim().is_empty() {
1774        Value::Object(serde_json::Map::new())
1775    } else {
1776        // SEC-AUDIT-006: Sanitize error message to prevent information disclosure
1777        serde_json::from_str(payload)
1778            .map_err(|_e| AshError::canonicalization_error())?
1779    };
1780
1781    let scoped_payload = ash_extract_scoped_fields(&json_payload, scope)?;
1782
1783    // Use proper canonicalization (sorted keys, NFC normalization, etc.)
1784    let canonical_scoped = ash_canonicalize_json_value(&scoped_payload)?;
1785
1786    let body_hash = ash_hash_body(&canonical_scoped);
1787
1788    // Compute scope hash (empty string if no scope)
1789    // BUG-002 & BUG-028: Use unit separator instead of comma
1790    let scope_hash = ash_hash_scope(scope)?;
1791
1792    // Compute chain hash (empty string if no previous proof)
1793    // BUG-029: ash_hash_proof now validates non-empty
1794    let chain_hash = match previous_proof {
1795        Some(prev) if !prev.is_empty() => ash_hash_proof(prev)?,
1796        _ => String::new(),
1797    };
1798
1799    // Build proof message: timestamp|binding|bodyHash|scopeHash|chainHash
1800    let message = format!(
1801        "{}|{}|{}|{}|{}",
1802        timestamp, binding, body_hash, scope_hash, chain_hash
1803    );
1804
1805    let mut mac = HmacSha256Type::new_from_slice(client_secret.as_bytes())
1806        .expect("HMAC can take key of any size");
1807    mac.update(message.as_bytes());
1808    let proof = hex::encode(mac.finalize().into_bytes());
1809
1810    Ok(UnifiedProofResult {
1811        proof,
1812        scope_hash,
1813        chain_hash,
1814    })
1815}
1816
1817/// Verify unified v2.3 proof (server-side).
1818///
1819/// Validates proof with optional scoping and chaining.
1820///
1821/// # Consistency Validation (SEC-013)
1822///
1823/// This function validates consistency between parameters:
1824/// - If `scope` is empty, `scope_hash` must also be empty
1825/// - If `previous_proof` is None/empty, `chain_hash` must also be empty
1826///
1827/// This prevents scenarios where a client sends mismatched scope/chain parameters.
1828#[allow(clippy::too_many_arguments)]
1829pub fn ash_verify_proof_unified(
1830    nonce: &str,
1831    context_id: &str,
1832    binding: &str,
1833    timestamp: &str,
1834    payload: &str,
1835    client_proof: &str,
1836    scope: &[&str],
1837    scope_hash: &str,
1838    previous_proof: Option<&str>,
1839    chain_hash: &str,
1840) -> Result<bool, AshError> {
1841    // BUG-007 & BUG-012: Validate timestamp format
1842    ash_validate_timestamp_format(timestamp)?;
1843
1844    // SEC-013: Validate consistency - scope_hash must be empty when scope is empty
1845    if scope.is_empty() && !scope_hash.is_empty() {
1846        return Err(AshError::new(
1847            AshErrorCode::ScopeMismatch,
1848            "scope_hash must be empty when scope is empty",
1849        ));
1850    }
1851
1852    // Validate scope hash if scoping is used
1853    // BUG-002 & BUG-028: Use unit separator instead of comma
1854    if !scope.is_empty() {
1855        let expected_scope_hash = ash_hash_scope(scope)?;
1856        if !ash_timing_safe_equal(expected_scope_hash.as_bytes(), scope_hash.as_bytes()) {
1857            return Ok(false);
1858        }
1859    }
1860
1861    // SEC-013: Validate consistency - chain_hash must be empty when previous_proof is absent
1862    let has_previous = previous_proof.is_some_and(|p| !p.is_empty());
1863    if !has_previous && !chain_hash.is_empty() {
1864        return Err(AshError::new(
1865            AshErrorCode::ChainBroken,
1866            "chain_hash must be empty when previous_proof is absent",
1867        ));
1868    }
1869
1870    // Validate chain hash if chaining is used
1871    // BUG-029: ash_hash_proof now validates non-empty
1872    if let Some(prev) = previous_proof {
1873        if !prev.is_empty() {
1874            let expected_chain_hash = ash_hash_proof(prev)?;
1875            if !ash_timing_safe_equal(expected_chain_hash.as_bytes(), chain_hash.as_bytes()) {
1876                return Ok(false);
1877            }
1878        }
1879    }
1880
1881    // Derive client secret and compute expected proof
1882    let client_secret = ash_derive_client_secret(nonce, context_id, binding)?;
1883
1884    let result = ash_build_proof_unified(
1885        &client_secret,
1886        timestamp,
1887        binding,
1888        payload,
1889        scope,
1890        previous_proof,
1891    )?;
1892
1893    Ok(ash_timing_safe_equal(
1894        result.proof.as_bytes(),
1895        client_proof.as_bytes(),
1896    ))
1897}
1898
1899#[cfg(test)]
1900mod tests_unified {
1901    use super::*;
1902
1903    // Test nonces must be at least 32 hex chars (16 bytes)
1904    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
1905
1906    #[test]
1907    fn test_unified_basic() {
1908        let nonce = TEST_NONCE;
1909        let context_id = "ctx_abc123";
1910        let binding = "POST /api/test";
1911        let timestamp = "1234567890";
1912        let payload = r#"{"name":"John","age":30}"#;
1913
1914        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1915        let result = ash_build_proof_unified(
1916            &client_secret,
1917            timestamp,
1918            binding,
1919            payload,
1920            &[],  // No scoping
1921            None, // No chaining
1922        )
1923        .unwrap();
1924
1925        assert!(!result.proof.is_empty());
1926        assert!(result.scope_hash.is_empty());
1927        assert!(result.chain_hash.is_empty());
1928
1929        let is_valid = ash_verify_proof_unified(
1930            nonce,
1931            context_id,
1932            binding,
1933            timestamp,
1934            payload,
1935            &result.proof,
1936            &[],
1937            "",
1938            None,
1939            "",
1940        )
1941        .unwrap();
1942
1943        assert!(is_valid);
1944    }
1945
1946    #[test]
1947    fn test_unified_scoped_only() {
1948        let nonce = TEST_NONCE;
1949        let context_id = "ctx_abc123";
1950        let binding = "POST /transfer";
1951        let timestamp = "1234567890";
1952        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
1953        let scope = vec!["amount", "recipient"];
1954
1955        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1956        let result = ash_build_proof_unified(
1957            &client_secret,
1958            timestamp,
1959            binding,
1960            payload,
1961            &scope,
1962            None, // No chaining
1963        )
1964        .unwrap();
1965
1966        assert!(!result.proof.is_empty());
1967        assert!(!result.scope_hash.is_empty());
1968        assert!(result.chain_hash.is_empty());
1969
1970        let is_valid = ash_verify_proof_unified(
1971            nonce,
1972            context_id,
1973            binding,
1974            timestamp,
1975            payload,
1976            &result.proof,
1977            &scope,
1978            &result.scope_hash,
1979            None,
1980            "",
1981        )
1982        .unwrap();
1983
1984        assert!(is_valid);
1985    }
1986
1987    #[test]
1988    fn test_unified_chained_only() {
1989        let nonce = TEST_NONCE;
1990        let context_id = "ctx_abc123";
1991        let binding = "POST /checkout";
1992        let timestamp = "1234567890";
1993        let payload = r#"{"cart_id":"cart_123"}"#;
1994        let previous_proof = "abc123def456";
1995
1996        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
1997        let result = ash_build_proof_unified(
1998            &client_secret,
1999            timestamp,
2000            binding,
2001            payload,
2002            &[], // No scoping
2003            Some(previous_proof),
2004        )
2005        .unwrap();
2006
2007        assert!(!result.proof.is_empty());
2008        assert!(result.scope_hash.is_empty());
2009        assert!(!result.chain_hash.is_empty());
2010
2011        let is_valid = ash_verify_proof_unified(
2012            nonce,
2013            context_id,
2014            binding,
2015            timestamp,
2016            payload,
2017            &result.proof,
2018            &[],
2019            "",
2020            Some(previous_proof),
2021            &result.chain_hash,
2022        )
2023        .unwrap();
2024
2025        assert!(is_valid);
2026    }
2027
2028    #[test]
2029    fn test_unified_full() {
2030        let nonce = TEST_NONCE;
2031        let context_id = "ctx_abc123";
2032        let binding = "POST /payment";
2033        let timestamp = "1234567890";
2034        let payload = r#"{"amount":500,"currency":"USD","notes":"tip"}"#;
2035        let scope = vec!["amount", "currency"];
2036        let previous_proof = "checkout_proof_xyz";
2037
2038        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2039        let result = ash_build_proof_unified(
2040            &client_secret,
2041            timestamp,
2042            binding,
2043            payload,
2044            &scope,
2045            Some(previous_proof),
2046        )
2047        .unwrap();
2048
2049        assert!(!result.proof.is_empty());
2050        assert!(!result.scope_hash.is_empty());
2051        assert!(!result.chain_hash.is_empty());
2052
2053        let is_valid = ash_verify_proof_unified(
2054            nonce,
2055            context_id,
2056            binding,
2057            timestamp,
2058            payload,
2059            &result.proof,
2060            &scope,
2061            &result.scope_hash,
2062            Some(previous_proof),
2063            &result.chain_hash,
2064        )
2065        .unwrap();
2066
2067        assert!(is_valid);
2068    }
2069
2070    #[test]
2071    fn test_unified_chain_broken() {
2072        let nonce = TEST_NONCE;
2073        let context_id = "ctx_abc123";
2074        let binding = "POST /payment";
2075        let timestamp = "1234567890";
2076        let payload = r#"{"amount":500}"#;
2077        let previous_proof = "original_proof";
2078
2079        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2080        let result = ash_build_proof_unified(
2081            &client_secret,
2082            timestamp,
2083            binding,
2084            payload,
2085            &[],
2086            Some(previous_proof),
2087        )
2088        .unwrap();
2089
2090        // Try to verify with wrong previous proof
2091        let is_valid = ash_verify_proof_unified(
2092            nonce,
2093            context_id,
2094            binding,
2095            timestamp,
2096            payload,
2097            &result.proof,
2098            &[],
2099            "",
2100            Some("tampered_proof"), // Wrong previous proof
2101            &result.chain_hash,
2102        )
2103        .unwrap();
2104
2105        assert!(!is_valid);
2106    }
2107
2108    #[test]
2109    fn test_unified_scope_tampered() {
2110        let nonce = TEST_NONCE;
2111        let context_id = "ctx_abc123";
2112        let binding = "POST /transfer";
2113        let timestamp = "1234567890";
2114        let payload = r#"{"amount":1000,"recipient":"user1"}"#;
2115        let scope = vec!["amount"];
2116
2117        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2118        let result =
2119            ash_build_proof_unified(&client_secret, timestamp, binding, payload, &scope, None)
2120                .unwrap();
2121
2122        // Try to verify with different scope
2123        let tampered_scope = vec!["recipient"];
2124        let is_valid = ash_verify_proof_unified(
2125            nonce,
2126            context_id,
2127            binding,
2128            timestamp,
2129            payload,
2130            &result.proof,
2131            &tampered_scope,    // Different scope
2132            &result.scope_hash, // Original scope hash
2133            None,
2134            "",
2135        )
2136        .unwrap();
2137
2138        assert!(!is_valid);
2139    }
2140
2141    #[test]
2142    fn test_ash_hash_proof() {
2143        let proof = "test_proof_123";
2144        let hash1 = ash_hash_proof(proof).unwrap();
2145        let hash2 = ash_hash_proof(proof).unwrap();
2146
2147        assert_eq!(hash1, hash2);
2148        assert_eq!(hash1.len(), 64); // SHA-256 = 64 hex chars
2149    }
2150
2151    #[test]
2152    fn test_ash_hash_proof_rejects_empty() {
2153        // BUG-029: Empty proof should be rejected
2154        let result = ash_hash_proof("");
2155        assert!(result.is_err());
2156        assert!(result.unwrap_err().message().contains("empty"));
2157    }
2158
2159    // SEC-013: Consistency validation tests
2160
2161    #[test]
2162    fn test_unified_rejects_scope_hash_when_scope_empty() {
2163        // SEC-013: scope_hash must be empty when scope is empty
2164        let nonce = TEST_NONCE;
2165        let context_id = "ctx_abc123";
2166        let binding = "POST /api/test";
2167        let timestamp = "1234567890";
2168        let payload = r#"{"name":"John"}"#;
2169
2170        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2171        let result = ash_build_proof_unified(
2172            &client_secret,
2173            timestamp,
2174            binding,
2175            payload,
2176            &[], // No scoping
2177            None,
2178        )
2179        .unwrap();
2180
2181        // Try to verify with non-empty scope_hash when scope is empty
2182        let verify_result = ash_verify_proof_unified(
2183            nonce,
2184            context_id,
2185            binding,
2186            timestamp,
2187            payload,
2188            &result.proof,
2189            &[],              // Empty scope
2190            "fake_scope_hash", // Non-empty scope_hash - should fail
2191            None,
2192            "",
2193        );
2194
2195        assert!(verify_result.is_err());
2196        assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ScopeMismatch);
2197    }
2198
2199    #[test]
2200    fn test_unified_rejects_chain_hash_when_no_previous_proof() {
2201        // SEC-013: chain_hash must be empty when previous_proof is absent
2202        let nonce = TEST_NONCE;
2203        let context_id = "ctx_abc123";
2204        let binding = "POST /api/test";
2205        let timestamp = "1234567890";
2206        let payload = r#"{"name":"John"}"#;
2207
2208        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2209        let result = ash_build_proof_unified(
2210            &client_secret,
2211            timestamp,
2212            binding,
2213            payload,
2214            &[],
2215            None, // No chaining
2216        )
2217        .unwrap();
2218
2219        // Try to verify with non-empty chain_hash when previous_proof is None
2220        let verify_result = ash_verify_proof_unified(
2221            nonce,
2222            context_id,
2223            binding,
2224            timestamp,
2225            payload,
2226            &result.proof,
2227            &[],
2228            "",
2229            None,              // No previous proof
2230            "fake_chain_hash", // Non-empty chain_hash - should fail
2231        );
2232
2233        assert!(verify_result.is_err());
2234        assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ChainBroken);
2235    }
2236}
2237
2238// SEC-011: Large array index protection tests
2239#[cfg(test)]
2240mod tests_sec011 {
2241    use super::*;
2242
2243    #[test]
2244    fn test_large_array_index_rejected() {
2245        // SEC-011 & BUG-036: Large array indices should be rejected to prevent memory exhaustion
2246        let payload: Value = serde_json::from_str(
2247            r#"{"items":[{"id":1}]}"#
2248        ).unwrap();
2249
2250        // BUG-036: This should be rejected due to allocation limit
2251        let scope = vec!["items[999999]"];
2252        let result = ash_extract_scoped_fields(&payload, &scope);
2253
2254        assert!(result.is_err());
2255        assert!(result.unwrap_err().message().contains("allocation"));
2256    }
2257
2258    #[test]
2259    fn test_valid_array_index_works() {
2260        // Normal array indices should work
2261        let payload: Value = serde_json::from_str(
2262            r#"{"items":[{"id":1},{"id":2},{"id":3}]}"#
2263        ).unwrap();
2264
2265        let scope = vec!["items[1]"];
2266        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
2267
2268        assert!(scoped.is_object());
2269        let items = scoped.get("items").expect("should have items");
2270        let arr = items.as_array().unwrap();
2271        assert_eq!(arr.len(), 2); // Index 0 is null, index 1 has value
2272        assert_eq!(arr[1]["id"], 2);
2273    }
2274
2275    #[test]
2276    fn test_moderate_array_index_within_limit() {
2277        // Array index within per-index limit (10000) but also within total allocation limit
2278        let payload: Value = serde_json::from_str(
2279            r#"{"items":[{"id":1}]}"#
2280        ).unwrap();
2281
2282        // 100 elements is fine
2283        let scope = vec!["items[99]"];
2284        let result = ash_extract_scoped_fields(&payload, &scope);
2285
2286        // Should succeed (allocation = 100 elements, well under 10000 limit)
2287        assert!(result.is_ok());
2288    }
2289}
2290
2291// SEC-018: Timestamp validation tests
2292#[cfg(test)]
2293mod tests_sec018 {
2294    use super::*;
2295
2296    #[test]
2297    fn test_rejects_unreasonably_large_timestamp() {
2298        // SEC-018: Very large timestamps should be rejected
2299        let huge_timestamp = "99999999999999999"; // Way beyond year 3000
2300        let result = ash_validate_timestamp(huge_timestamp, 300, 60);
2301        assert!(result.is_err());
2302        assert!(result.unwrap_err().message().contains("maximum"));
2303    }
2304
2305    #[test]
2306    fn test_accepts_normal_timestamp() {
2307        use std::time::{SystemTime, UNIX_EPOCH};
2308        let now = SystemTime::now()
2309            .duration_since(UNIX_EPOCH)
2310            .unwrap()
2311            .as_secs();
2312        let result = ash_validate_timestamp(&now.to_string(), 300, 60);
2313        assert!(result.is_ok());
2314    }
2315}
2316
2317// SEC-019: Scope path depth protection tests
2318#[cfg(test)]
2319mod tests_sec019 {
2320    use super::*;
2321
2322    #[test]
2323    fn test_deep_scope_path_ignored() {
2324        // SEC-019: Very deep scope paths should be silently ignored
2325        let payload: Value = serde_json::json!({"a": {"b": {"c": 1}}});
2326
2327        // Create a path deeper than MAX_SCOPE_PATH_DEPTH
2328        let deep_path = (0..35).map(|_| "x").collect::<Vec<_>>().join(".");
2329        let scope = vec![deep_path.as_str()];
2330        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
2331
2332        // Result should be empty object since the deep path is ignored
2333        assert!(scoped.is_object());
2334        assert!(scoped.as_object().unwrap().is_empty());
2335    }
2336
2337    #[test]
2338    fn test_normal_depth_path_works() {
2339        // Paths with normal depth should work
2340        let payload: Value = serde_json::json!({"a": {"b": {"c": 1}}});
2341        let scope = vec!["a.b.c"];
2342        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
2343
2344        assert!(scoped.is_object());
2345        let c_value = scoped.get("a")
2346            .and_then(|a| a.get("b"))
2347            .and_then(|b| b.get("c"));
2348        assert_eq!(c_value, Some(&serde_json::json!(1)));
2349    }
2350}
2351
2352// BUG-022: Multi-dimensional array notation tests
2353#[cfg(test)]
2354mod tests_bug022 {
2355    use super::*;
2356
2357    #[test]
2358    fn test_multi_dimensional_array_get() {
2359        // BUG-022: Support paths like matrix[0][1]
2360        let payload: Value = serde_json::json!({
2361            "matrix": [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
2362        });
2363
2364        let scope = vec!["matrix[1][2]"];
2365        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
2366
2367        // Should extract matrix[1][2] = 6
2368        assert!(scoped.is_object());
2369        let matrix = scoped.get("matrix").expect("should have matrix");
2370        let arr = matrix.as_array().unwrap();
2371        // arr[0] and arr[1][0], arr[1][1] should be null, arr[1][2] should be 6
2372        assert_eq!(arr.len(), 2); // Indices 0 and 1
2373        let inner = arr[1].as_array().unwrap();
2374        assert_eq!(inner.len(), 3); // Indices 0, 1, 2
2375        assert_eq!(inner[2], 6);
2376    }
2377
2378    #[test]
2379    fn test_multi_dimensional_array_nested_object() {
2380        // BUG-022: Support paths like items[0][1].name
2381        let payload: Value = serde_json::json!({
2382            "items": [
2383                [{"id": 1, "name": "a"}, {"id": 2, "name": "b"}],
2384                [{"id": 3, "name": "c"}, {"id": 4, "name": "d"}]
2385            ]
2386        });
2387
2388        let scope = vec!["items[1][0].name"];
2389        let scoped = ash_extract_scoped_fields(&payload, &scope).unwrap();
2390
2391        // Should extract items[1][0].name = "c"
2392        let items = scoped.get("items").expect("should have items");
2393        let outer = items.as_array().unwrap();
2394        assert_eq!(outer.len(), 2);
2395        let inner = outer[1].as_array().unwrap();
2396        assert_eq!(inner.len(), 1);
2397        let obj = inner[0].as_object().unwrap();
2398        assert_eq!(obj.get("name").unwrap(), "c");
2399    }
2400
2401    #[test]
2402    fn test_ash_parse_all_array_indices() {
2403        // Test the helper function directly
2404        let notation = ash_parse_all_array_indices("items[0][1][2]");
2405        assert_eq!(notation.key, "items");
2406        assert_eq!(notation.indices, vec![0, 1, 2]);
2407
2408        let notation2 = ash_parse_all_array_indices("simple");
2409        assert_eq!(notation2.key, "simple");
2410        assert!(notation2.indices.is_empty());
2411
2412        let notation3 = ash_parse_all_array_indices("arr[5]");
2413        assert_eq!(notation3.key, "arr");
2414        assert_eq!(notation3.indices, vec![5]);
2415    }
2416
2417    #[test]
2418    fn test_multi_dimensional_invalid_index() {
2419        // Invalid indices should invalidate - prevents partial/ambiguous access
2420        let notation = ash_parse_all_array_indices("items[0][abc][2]");
2421        assert_eq!(notation.key, "items");
2422        // All indices are invalidated because there's unparseable content
2423        // This is safer than partial access which could lead to unexpected behavior
2424        assert!(notation.indices.is_empty());
2425    }
2426
2427    #[test]
2428    fn test_multi_dimensional_trailing_text() {
2429        // Trailing text after indices invalidates all indices but preserves key
2430        let notation = ash_parse_all_array_indices("items[0][1]extra");
2431        assert_eq!(notation.key, "items");
2432        // Indices are invalidated due to trailing text - safer than partial access
2433        assert!(notation.indices.is_empty());
2434    }
2435}
2436
2437// BUG-023: Scope auto-sorting tests
2438#[cfg(test)]
2439mod tests_bug023 {
2440    use super::*;
2441
2442    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
2443
2444    #[test]
2445    fn test_scope_order_independent() {
2446        // BUG-023: Different scope orders should produce same hash
2447        let scope1 = vec!["amount", "recipient"];
2448        let scope2 = vec!["recipient", "amount"];
2449
2450        let hash1 = ash_hash_scope(&scope1).unwrap();
2451        let hash2 = ash_hash_scope(&scope2).unwrap();
2452
2453        assert_eq!(hash1, hash2, "Scope order should not affect hash");
2454    }
2455
2456    #[test]
2457    fn test_scope_deduplication() {
2458        // BUG-023: Duplicate fields should be deduplicated
2459        let scope1 = vec!["amount", "amount", "recipient"];
2460        let scope2 = vec!["amount", "recipient"];
2461
2462        let hash1 = ash_hash_scope(&scope1).unwrap();
2463        let hash2 = ash_hash_scope(&scope2).unwrap();
2464
2465        assert_eq!(hash1, hash2, "Duplicate fields should be deduplicated");
2466    }
2467
2468    #[test]
2469    fn test_scope_rejects_delimiter_in_field_name() {
2470        // BUG-028: Field names containing delimiter should be rejected
2471        let scope_with_delimiter = vec!["amount", "field\x1Fname"];
2472
2473        let result = ash_hash_scope(&scope_with_delimiter);
2474        assert!(result.is_err(), "Should reject field names containing delimiter");
2475        assert!(result.unwrap_err().message().contains("delimiter"));
2476    }
2477
2478    #[test]
2479    fn test_scoped_proof_order_independent() {
2480        // BUG-023: Client and server with different scope orders should verify successfully
2481        let nonce = TEST_NONCE;
2482        let context_id = "ctx_abc123";
2483        let binding = "POST /transfer";
2484        let timestamp = "1234567890";
2485        let payload = r#"{"amount":1000,"recipient":"user1","notes":"hi"}"#;
2486
2487        // Client uses one order
2488        let client_scope = vec!["recipient", "amount"];
2489        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2490        let (proof, scope_hash) =
2491            ash_build_proof_scoped(&client_secret, timestamp, binding, payload, &client_scope).unwrap();
2492
2493        // Server uses different order
2494        let server_scope = vec!["amount", "recipient"];
2495        let is_valid = ash_verify_proof_scoped(
2496            nonce,
2497            context_id,
2498            binding,
2499            timestamp,
2500            payload,
2501            &server_scope, // Different order!
2502            &scope_hash,
2503            &proof,
2504        )
2505        .unwrap();
2506
2507        assert!(is_valid, "Verification should succeed regardless of scope order");
2508    }
2509}
2510
2511// BUG-036: Total array allocation limit tests
2512#[cfg(test)]
2513mod tests_bug036 {
2514    use super::*;
2515
2516    #[test]
2517    fn test_rejects_excessive_array_allocation() {
2518        // BUG-036: Multiple large array indices should be rejected
2519        let payload: Value = serde_json::json!({});
2520
2521        // Each index creates (idx+1) elements, so this would create way too many
2522        let scope = vec![
2523            "items[9999]",
2524            "other[9999]",
2525        ];
2526        // Total allocation: 10000 + 10000 = 20000, exceeds 10000 limit
2527
2528        let result = ash_extract_scoped_fields(&payload, &scope);
2529        assert!(result.is_err());
2530        assert!(result.unwrap_err().message().contains("allocation"));
2531    }
2532
2533    #[test]
2534    fn test_accepts_reasonable_array_allocation() {
2535        // Reasonable allocation should work
2536        let payload: Value = serde_json::json!({
2537            "items": [{"id": 1}, {"id": 2}, {"id": 3}]
2538        });
2539
2540        let scope = vec!["items[0]", "items[1]", "items[2]"];
2541        // Total allocation: 1 + 2 + 3 = 6 elements
2542
2543        let result = ash_extract_scoped_fields(&payload, &scope);
2544        assert!(result.is_ok());
2545    }
2546
2547    #[test]
2548    fn test_allocation_calculation() {
2549        // Test the allocation calculation helper
2550        let scope = vec!["items[10]", "matrix[5][5]"];
2551        // items[10] = 11 elements
2552        // matrix[5][5] = 6 + 6 = 12 elements
2553        // Total = 23
2554        let total = ash_calculate_total_array_allocation(&scope);
2555        assert_eq!(total, 23);
2556    }
2557
2558    #[test]
2559    fn test_allocation_calculation_overflow_protection() {
2560        // BUG-050: Ensure overflow doesn't cause panic or incorrect result
2561        // Using usize::MAX would overflow idx+1 without protection
2562        // The saturating arithmetic should prevent this
2563        let scope = vec!["items[18446744073709551615]"]; // usize::MAX on 64-bit
2564
2565        // This should not panic and should return usize::MAX (saturated)
2566        let total = ash_calculate_total_array_allocation(&scope);
2567        // With saturating_add, usize::MAX + 1 saturates to usize::MAX
2568        assert_eq!(total, usize::MAX);
2569    }
2570}
2571
2572// BUG-039: Empty scope field name tests
2573#[cfg(test)]
2574mod tests_bug039 {
2575    use super::*;
2576
2577    #[test]
2578    fn test_rejects_empty_scope_field_name() {
2579        // BUG-039: Empty field names should be rejected
2580        let scope = vec!["amount", ""];
2581        let result = ash_hash_scope(&scope);
2582        assert!(result.is_err());
2583        assert!(result.unwrap_err().message().contains("empty"));
2584    }
2585
2586    #[test]
2587    fn test_rejects_only_empty_scope_field() {
2588        let scope = vec![""];
2589        let result = ash_hash_scope(&scope);
2590        assert!(result.is_err());
2591    }
2592
2593    #[test]
2594    fn test_accepts_valid_scope_fields() {
2595        let scope = vec!["amount", "recipient"];
2596        let result = ash_hash_scope(&scope);
2597        assert!(result.is_ok());
2598    }
2599}
2600
2601// BUG-024: Empty payload handling tests
2602#[cfg(test)]
2603mod tests_bug024 {
2604    use super::*;
2605
2606    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
2607
2608    #[test]
2609    fn test_empty_payload_scoped() {
2610        // BUG-024: Empty payload should be treated as empty object
2611        let client_secret = "test_secret";
2612        let timestamp = "1234567890";
2613        let binding = "POST /api/test";
2614
2615        // These should all work without error
2616        let result1 = ash_build_proof_scoped(client_secret, timestamp, binding, "", &[]);
2617        assert!(result1.is_ok(), "Empty string payload should work");
2618
2619        let result2 = ash_build_proof_scoped(client_secret, timestamp, binding, "  ", &[]);
2620        assert!(result2.is_ok(), "Whitespace-only payload should work");
2621    }
2622
2623    #[test]
2624    fn test_empty_payload_unified() {
2625        // BUG-024: Empty payload in unified function
2626        let client_secret = "test_secret";
2627        let timestamp = "1234567890";
2628        let binding = "POST /api/test";
2629
2630        let result = ash_build_proof_unified(
2631            client_secret,
2632            timestamp,
2633            binding,
2634            "",
2635            &[],
2636            None,
2637        );
2638        assert!(result.is_ok(), "Empty string payload should work");
2639    }
2640
2641    #[test]
2642    fn test_empty_payload_hash_scoped_body() {
2643        // BUG-024: hash_scoped_body should handle empty payload
2644        let result = ash_hash_scoped_body("", &[]);
2645        assert!(result.is_ok(), "Empty payload should work");
2646
2647        let result2 = ash_hash_scoped_body("   ", &[]);
2648        assert!(result2.is_ok(), "Whitespace payload should work");
2649    }
2650
2651    #[test]
2652    fn test_empty_payload_produces_consistent_hash() {
2653        // BUG-024: Empty string and {} should produce same result
2654        let hash1 = ash_hash_scoped_body("", &[]).unwrap();
2655        let hash2 = ash_hash_scoped_body("{}", &[]).unwrap();
2656
2657        assert_eq!(hash1, hash2, "Empty string and {{}} should produce same hash");
2658    }
2659
2660    #[test]
2661    fn test_empty_payload_verification() {
2662        // BUG-024: Full verification with empty payload
2663        let nonce = TEST_NONCE;
2664        let context_id = "ctx_abc123";
2665        let binding = "POST /api/test";
2666        let timestamp = "1234567890";
2667
2668        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2669        let result = ash_build_proof_unified(
2670            &client_secret,
2671            timestamp,
2672            binding,
2673            "",
2674            &[],
2675            None,
2676        ).unwrap();
2677
2678        // Verify with empty payload
2679        let is_valid = ash_verify_proof_unified(
2680            nonce,
2681            context_id,
2682            binding,
2683            timestamp,
2684            "",
2685            &result.proof,
2686            &[],
2687            "",
2688            None,
2689            "",
2690        ).unwrap();
2691
2692        assert!(is_valid);
2693    }
2694}
2695
2696// BUG-046, BUG-047: Input validation tests for scoped/unified build functions
2697#[cfg(test)]
2698mod tests_bug046_047 {
2699    use super::*;
2700
2701    #[test]
2702    fn test_build_proof_scoped_rejects_empty_client_secret() {
2703        // BUG-046: Empty client_secret should be rejected
2704        let result = ash_build_proof_scoped("", "1234567890", "POST|/api|", "{}", &[]);
2705        assert!(result.is_err());
2706        assert!(result.unwrap_err().message().contains("client_secret"));
2707    }
2708
2709    #[test]
2710    fn test_build_proof_scoped_rejects_empty_timestamp() {
2711        // BUG-046: Empty timestamp should be rejected
2712        let result = ash_build_proof_scoped("secret", "", "POST|/api|", "{}", &[]);
2713        assert!(result.is_err());
2714        assert!(result.unwrap_err().message().contains("timestamp"));
2715    }
2716
2717    #[test]
2718    fn test_build_proof_scoped_rejects_empty_binding() {
2719        // BUG-046: Empty binding should be rejected
2720        let result = ash_build_proof_scoped("secret", "1234567890", "", "{}", &[]);
2721        assert!(result.is_err());
2722        assert!(result.unwrap_err().message().contains("binding"));
2723    }
2724
2725    #[test]
2726    fn test_build_proof_unified_rejects_empty_client_secret() {
2727        // BUG-047: Empty client_secret should be rejected
2728        let result = ash_build_proof_unified("", "1234567890", "POST|/api|", "{}", &[], None);
2729        assert!(result.is_err());
2730        assert!(result.unwrap_err().message().contains("client_secret"));
2731    }
2732
2733    #[test]
2734    fn test_build_proof_unified_rejects_empty_timestamp() {
2735        // BUG-047: Empty timestamp should be rejected
2736        let result = ash_build_proof_unified("secret", "", "POST|/api|", "{}", &[], None);
2737        assert!(result.is_err());
2738        assert!(result.unwrap_err().message().contains("timestamp"));
2739    }
2740
2741    #[test]
2742    fn test_build_proof_unified_rejects_empty_binding() {
2743        // BUG-047: Empty binding should be rejected
2744        let result = ash_build_proof_unified("secret", "1234567890", "", "{}", &[], None);
2745        assert!(result.is_err());
2746        assert!(result.unwrap_err().message().contains("binding"));
2747    }
2748}
2749
2750// BUG-049: SEC-013 consistency validation in verify_proof_scoped
2751#[cfg(test)]
2752mod tests_bug049 {
2753    use super::*;
2754
2755    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
2756
2757    #[test]
2758    fn test_verify_proof_scoped_rejects_scope_hash_when_scope_empty() {
2759        // BUG-049 & SEC-013: scope_hash must be empty when scope is empty
2760        let nonce = TEST_NONCE;
2761        let context_id = "ctx_abc123";
2762        let binding = "POST /api/test";
2763        let timestamp = "1234567890";
2764        let payload = r#"{"name":"John"}"#;
2765
2766        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2767        let (proof, _) = ash_build_proof_scoped(
2768            &client_secret,
2769            timestamp,
2770            binding,
2771            payload,
2772            &[], // No scoping
2773        ).unwrap();
2774
2775        // Try to verify with non-empty scope_hash when scope is empty
2776        let verify_result = ash_verify_proof_scoped(
2777            nonce,
2778            context_id,
2779            binding,
2780            timestamp,
2781            payload,
2782            &[],               // Empty scope
2783            "fake_scope_hash", // Non-empty scope_hash - should fail
2784            &proof,
2785        );
2786
2787        assert!(verify_result.is_err());
2788        assert_eq!(verify_result.unwrap_err().code(), crate::AshErrorCode::ScopeMismatch);
2789    }
2790
2791    #[test]
2792    fn test_verify_proof_scoped_accepts_valid_empty_scope() {
2793        // BUG-049: Valid case - empty scope with empty scope_hash should work
2794        let nonce = TEST_NONCE;
2795        let context_id = "ctx_abc123";
2796        let binding = "POST /api/test";
2797        let timestamp = "1234567890";
2798        let payload = r#"{"name":"John"}"#;
2799
2800        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2801        let (proof, scope_hash) = ash_build_proof_scoped(
2802            &client_secret,
2803            timestamp,
2804            binding,
2805            payload,
2806            &[], // No scoping
2807        ).unwrap();
2808
2809        assert!(scope_hash.is_empty(), "scope_hash should be empty for empty scope");
2810
2811        // Verify should succeed
2812        let verify_result = ash_verify_proof_scoped(
2813            nonce,
2814            context_id,
2815            binding,
2816            timestamp,
2817            payload,
2818            &[],
2819            "",
2820            &proof,
2821        );
2822
2823        assert!(verify_result.is_ok());
2824        assert!(verify_result.unwrap());
2825    }
2826}
2827
2828// SEC-AUDIT: Security audit tests
2829#[cfg(test)]
2830mod tests_security_audit {
2831    use super::*;
2832
2833    const TEST_NONCE: &str = "0123456789abcdef0123456789abcdef";
2834    const TEST_BODY_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
2835
2836    #[test]
2837    fn test_sec_audit_004_binding_length_limit_derive() {
2838        // SEC-AUDIT-004: Binding length should be limited
2839        let long_binding = "a".repeat(8193); // > 8KB
2840        let result = ash_derive_client_secret(TEST_NONCE, "ctx_abc", &long_binding);
2841        assert!(result.is_err());
2842        assert!(result.unwrap_err().message().contains("maximum length"));
2843    }
2844
2845    #[test]
2846    fn test_sec_audit_004_binding_length_limit_build() {
2847        // SEC-AUDIT-004: Binding length should be limited in build_proof
2848        let long_binding = "a".repeat(8193); // > 8KB
2849        let result = ash_build_proof("secret", "1234567890", &long_binding, TEST_BODY_HASH);
2850        assert!(result.is_err());
2851        assert!(result.unwrap_err().message().contains("maximum length"));
2852    }
2853
2854    #[test]
2855    fn test_sec_audit_004_binding_at_limit_ok() {
2856        // Binding at exactly 8KB should be OK
2857        let long_binding = "a".repeat(8192);
2858        let result = ash_build_proof("secret", "1234567890", &long_binding, TEST_BODY_HASH);
2859        assert!(result.is_ok());
2860    }
2861
2862    #[test]
2863    fn test_sec_audit_002_verify_with_freshness() {
2864        // SEC-AUDIT-002: Test the new convenience function
2865        use std::time::{SystemTime, UNIX_EPOCH};
2866
2867        let nonce = TEST_NONCE;
2868        let context_id = "ctx_abc123";
2869        let binding = "POST|/api/test|";
2870        let now = SystemTime::now()
2871            .duration_since(UNIX_EPOCH)
2872            .unwrap()
2873            .as_secs();
2874        let timestamp = now.to_string();
2875
2876        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2877        let proof = ash_build_proof(&client_secret, &timestamp, binding, TEST_BODY_HASH).unwrap();
2878
2879        // Fresh timestamp should work
2880        let result = ash_verify_proof_with_freshness(
2881            nonce, context_id, binding, &timestamp, TEST_BODY_HASH, &proof,
2882            300, 60
2883        );
2884        assert!(result.is_ok());
2885        assert!(result.unwrap());
2886    }
2887
2888    #[test]
2889    fn test_sec_audit_002_verify_with_freshness_rejects_expired() {
2890        // SEC-AUDIT-002: Expired timestamp should be rejected
2891        use std::time::{SystemTime, UNIX_EPOCH};
2892
2893        let nonce = TEST_NONCE;
2894        let context_id = "ctx_abc123";
2895        let binding = "POST|/api/test|";
2896        let now = SystemTime::now()
2897            .duration_since(UNIX_EPOCH)
2898            .unwrap()
2899            .as_secs();
2900        let old_timestamp = (now - 600).to_string(); // 10 minutes ago
2901
2902        let client_secret = ash_derive_client_secret(nonce, context_id, binding).unwrap();
2903        let proof = ash_build_proof(&client_secret, &old_timestamp, binding, TEST_BODY_HASH).unwrap();
2904
2905        // Expired timestamp should fail
2906        let result = ash_verify_proof_with_freshness(
2907            nonce, context_id, binding, &old_timestamp, TEST_BODY_HASH, &proof,
2908            300, 60  // 5 minute max age
2909        );
2910        assert!(result.is_err());
2911        assert!(result.unwrap_err().message().contains("expired"));
2912    }
2913
2914    #[test]
2915    fn test_sec_audit_003_generic_error_message() {
2916        // SEC-AUDIT-003: Error message should not include user input
2917        let field_with_delimiter = format!("secret_field{}name", SCOPE_FIELD_DELIMITER);
2918        let result = ash_hash_scope(&[&field_with_delimiter]);
2919        assert!(result.is_err());
2920        // Should NOT contain the actual field name in the error
2921        let error_msg = result.unwrap_err().message().to_string();
2922        assert!(!error_msg.contains("secret_field")); // User input should not be echoed
2923        assert!(!error_msg.contains("name")); // User input should not be echoed
2924        assert!(error_msg.contains("delimiter")); // Generic error info is OK
2925    }
2926}