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::{build_proof, verify_proof};
52pub use types::{AshMode, BuildProofInput, VerifyInput};
53
54/// Normalize a binding string to canonical form.
55///
56/// Bindings are in the format: `METHOD /path`
57///
58/// # Normalization Rules
59/// - Method is uppercased
60/// - Path must start with `/`
61/// - Query string is excluded
62/// - Duplicate slashes are collapsed
63/// - Trailing slash is removed (except for root `/`)
64///
65/// # Example
66///
67/// ```rust
68/// use ash_core::normalize_binding;
69///
70/// let binding = normalize_binding("post", "/api//users/").unwrap();
71/// assert_eq!(binding, "POST /api/users");
72/// ```
73pub fn normalize_binding(method: &str, path: &str) -> Result<String, AshError> {
74    // Validate method
75    let method = method.trim().to_uppercase();
76    if method.is_empty() {
77        return Err(AshError::new(
78            AshErrorCode::MalformedRequest,
79            "Method cannot be empty",
80        ));
81    }
82
83    // Validate path starts with /
84    let path = path.trim();
85    if !path.starts_with('/') {
86        return Err(AshError::new(
87            AshErrorCode::MalformedRequest,
88            "Path must start with /",
89        ));
90    }
91
92    // Remove query string
93    let path = path.split('?').next().unwrap_or(path);
94
95    // Collapse duplicate slashes and normalize
96    let mut normalized = String::with_capacity(path.len());
97    let mut prev_slash = false;
98
99    for ch in path.chars() {
100        if ch == '/' {
101            if !prev_slash {
102                normalized.push(ch);
103            }
104            prev_slash = true;
105        } else {
106            normalized.push(ch);
107            prev_slash = false;
108        }
109    }
110
111    // Remove trailing slash (except for root)
112    if normalized.len() > 1 && normalized.ends_with('/') {
113        normalized.pop();
114    }
115
116    Ok(format!("{} {}", method, normalized))
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn test_normalize_binding_basic() {
125        assert_eq!(
126            normalize_binding("POST", "/api/users").unwrap(),
127            "POST /api/users"
128        );
129    }
130
131    #[test]
132    fn test_normalize_binding_lowercase_method() {
133        assert_eq!(
134            normalize_binding("post", "/api/users").unwrap(),
135            "POST /api/users"
136        );
137    }
138
139    #[test]
140    fn test_normalize_binding_duplicate_slashes() {
141        assert_eq!(
142            normalize_binding("GET", "/api//users///profile").unwrap(),
143            "GET /api/users/profile"
144        );
145    }
146
147    #[test]
148    fn test_normalize_binding_trailing_slash() {
149        assert_eq!(
150            normalize_binding("PUT", "/api/users/").unwrap(),
151            "PUT /api/users"
152        );
153    }
154
155    #[test]
156    fn test_normalize_binding_root() {
157        assert_eq!(normalize_binding("GET", "/").unwrap(), "GET /");
158    }
159
160    #[test]
161    fn test_normalize_binding_query_string() {
162        assert_eq!(
163            normalize_binding("GET", "/api/users?page=1").unwrap(),
164            "GET /api/users"
165        );
166    }
167
168    #[test]
169    fn test_normalize_binding_empty_method() {
170        assert!(normalize_binding("", "/api").is_err());
171    }
172
173    #[test]
174    fn test_normalize_binding_no_leading_slash() {
175        assert!(normalize_binding("GET", "api/users").is_err());
176    }
177}