ash_core/
lib.rs

1//! # ASH Core
2//!
3//! ASH (Anti-tamper Security Hash) is a request integrity and anti-replay protection library.
4//!
5//! This crate provides the core functionality for:
6//! - Deterministic JSON and URL-encoded payload canonicalization
7//! - Cryptographic proof generation and verification
8//! - Constant-time comparison for timing-attack resistance
9//! - Binding normalization for endpoint protection
10//!
11//! ## Features
12//!
13//! - **Tamper Detection**: Cryptographic proof ensures payload integrity
14//! - **Replay Prevention**: One-time contexts prevent request replay
15//! - **Deterministic**: Byte-identical output across all platforms
16//! - **WASM Compatible**: Works in browsers and server environments
17//!
18//! ## Example
19//!
20//! ```rust
21//! use ash_core::{canonicalize_json, build_proof, AshMode};
22//!
23//! // Canonicalize a JSON payload
24//! let canonical = canonicalize_json(r#"{"z":1,"a":2}"#).unwrap();
25//! assert_eq!(canonical, r#"{"a":2,"z":1}"#);
26//!
27//! // Build a proof
28//! let proof = build_proof(
29//!     AshMode::Balanced,
30//!     "POST /api/update",
31//!     "context-id-123",
32//!     None,
33//!     &canonical,
34//! ).unwrap();
35//! ```
36//!
37//! ## Security Notes
38//!
39//! ASH verifies **what** is being submitted, not **who** is submitting it.
40//! It should be used alongside authentication systems (JWT, OAuth, etc.).
41
42mod canonicalize;
43mod compare;
44mod errors;
45mod proof;
46mod types;
47
48pub use canonicalize::{canonicalize_json, canonicalize_urlencoded};
49pub use compare::timing_safe_equal;
50pub use errors::{AshError, AshErrorCode};
51pub use proof::{
52    build_proof, verify_proof,
53    // v2.1 functions
54    generate_nonce, generate_context_id,
55    derive_client_secret, build_proof_v21,
56    verify_proof_v21, hash_body,
57    // v2.2 scoping functions
58    extract_scoped_fields, build_proof_v21_scoped,
59    verify_proof_v21_scoped, hash_scoped_body,
60    // v2.3 unified functions (scoping + chaining)
61    UnifiedProofResult, hash_proof,
62    build_proof_v21_unified, verify_proof_v21_unified,
63};
64pub use types::{AshMode, BuildProofInput, VerifyInput};
65
66/// Normalize a binding string to canonical form.
67///
68/// Bindings are in the format: `METHOD /path`
69///
70/// # Normalization Rules
71/// - Method is uppercased
72/// - Path must start with `/`
73/// - Query string is excluded
74/// - Duplicate slashes are collapsed
75/// - Trailing slash is removed (except for root `/`)
76///
77/// # Example
78///
79/// ```rust
80/// use ash_core::normalize_binding;
81///
82/// let binding = normalize_binding("post", "/api//users/").unwrap();
83/// assert_eq!(binding, "POST /api/users");
84/// ```
85pub fn normalize_binding(method: &str, path: &str) -> Result<String, AshError> {
86    // Validate method
87    let method = method.trim().to_uppercase();
88    if method.is_empty() {
89        return Err(AshError::new(
90            AshErrorCode::MalformedRequest,
91            "Method cannot be empty",
92        ));
93    }
94
95    // Validate path starts with /
96    let path = path.trim();
97    if !path.starts_with('/') {
98        return Err(AshError::new(
99            AshErrorCode::MalformedRequest,
100            "Path must start with /",
101        ));
102    }
103
104    // Remove query string
105    let path = path.split('?').next().unwrap_or(path);
106
107    // Collapse duplicate slashes and normalize
108    let mut normalized = String::with_capacity(path.len());
109    let mut prev_slash = false;
110
111    for ch in path.chars() {
112        if ch == '/' {
113            if !prev_slash {
114                normalized.push(ch);
115            }
116            prev_slash = true;
117        } else {
118            normalized.push(ch);
119            prev_slash = false;
120        }
121    }
122
123    // Remove trailing slash (except for root)
124    if normalized.len() > 1 && normalized.ends_with('/') {
125        normalized.pop();
126    }
127
128    Ok(format!("{} {}", method, normalized))
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn test_normalize_binding_basic() {
137        assert_eq!(
138            normalize_binding("POST", "/api/users").unwrap(),
139            "POST /api/users"
140        );
141    }
142
143    #[test]
144    fn test_normalize_binding_lowercase_method() {
145        assert_eq!(
146            normalize_binding("post", "/api/users").unwrap(),
147            "POST /api/users"
148        );
149    }
150
151    #[test]
152    fn test_normalize_binding_duplicate_slashes() {
153        assert_eq!(
154            normalize_binding("GET", "/api//users///profile").unwrap(),
155            "GET /api/users/profile"
156        );
157    }
158
159    #[test]
160    fn test_normalize_binding_trailing_slash() {
161        assert_eq!(
162            normalize_binding("PUT", "/api/users/").unwrap(),
163            "PUT /api/users"
164        );
165    }
166
167    #[test]
168    fn test_normalize_binding_root() {
169        assert_eq!(normalize_binding("GET", "/").unwrap(), "GET /");
170    }
171
172    #[test]
173    fn test_normalize_binding_query_string() {
174        assert_eq!(
175            normalize_binding("GET", "/api/users?page=1").unwrap(),
176            "GET /api/users"
177        );
178    }
179
180    #[test]
181    fn test_normalize_binding_empty_method() {
182        assert!(normalize_binding("", "/api").is_err());
183    }
184
185    #[test]
186    fn test_normalize_binding_no_leading_slash() {
187        assert!(normalize_binding("GET", "api/users").is_err());
188    }
189}