Skip to main content

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;
44pub mod config;
45mod errors;
46mod proof;
47mod types;
48
49pub use canonicalize::{canonicalize_json, canonicalize_query, canonicalize_urlencoded};
50pub use compare::timing_safe_equal;
51pub use errors::{AshError, AshErrorCode};
52pub use proof::{
53    build_proof,
54    build_proof_v21,
55    build_proof_v21_scoped,
56    build_proof_v21_unified,
57    derive_client_secret,
58    // v2.2 scoping functions
59    extract_scoped_fields,
60    generate_context_id,
61    // v2.1 functions
62    generate_nonce,
63    hash_body,
64    hash_proof,
65    hash_scoped_body,
66    verify_proof,
67    verify_proof_v21,
68    verify_proof_v21_scoped,
69    verify_proof_v21_unified,
70    // v2.3 unified functions (scoping + chaining)
71    UnifiedProofResult,
72};
73pub use types::{AshMode, BuildProofInput, VerifyInput};
74
75/// Normalize a binding string to canonical form (v2.3.2+ format).
76///
77/// Bindings are in the format: `METHOD|PATH|CANONICAL_QUERY`
78///
79/// # Normalization Rules
80/// - Method is uppercased
81/// - Path must start with `/`
82/// - Path has duplicate slashes collapsed
83/// - Trailing slash is removed (except for root `/`)
84/// - Query string is canonicalized (sorted, normalized)
85/// - Parts are joined with `|` (pipe) separator
86///
87/// # Example
88///
89/// ```rust
90/// use ash_core::normalize_binding;
91///
92/// let binding = normalize_binding("post", "/api//users/", "").unwrap();
93/// assert_eq!(binding, "POST|/api/users|");
94///
95/// let binding_with_query = normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap();
96/// assert_eq!(binding_with_query, "GET|/api/users|page=1&sort=name");
97/// ```
98pub fn normalize_binding(method: &str, path: &str, query: &str) -> Result<String, AshError> {
99    // Validate method
100    let method = method.trim().to_uppercase();
101    if method.is_empty() {
102        return Err(AshError::new(
103            AshErrorCode::MalformedRequest,
104            "Method cannot be empty",
105        ));
106    }
107
108    // Validate path starts with /
109    let path = path.trim();
110    if !path.starts_with('/') {
111        return Err(AshError::new(
112            AshErrorCode::MalformedRequest,
113            "Path must start with /",
114        ));
115    }
116
117    // Extract path without query string (in case path contains ?)
118    let path_only = path.split('?').next().unwrap_or(path);
119
120    // Collapse duplicate slashes and normalize
121    let mut normalized_path = String::with_capacity(path_only.len());
122    let mut prev_slash = false;
123
124    for ch in path_only.chars() {
125        if ch == '/' {
126            if !prev_slash {
127                normalized_path.push(ch);
128            }
129            prev_slash = true;
130        } else {
131            normalized_path.push(ch);
132            prev_slash = false;
133        }
134    }
135
136    // Remove trailing slash (except for root)
137    if normalized_path.len() > 1 && normalized_path.ends_with('/') {
138        normalized_path.pop();
139    }
140
141    // Canonicalize query string
142    let canonical_query = if query.is_empty() {
143        String::new()
144    } else {
145        canonicalize::canonicalize_query(query)?
146    };
147
148    // v2.3.2 format: METHOD|PATH|CANONICAL_QUERY
149    Ok(format!(
150        "{}|{}|{}",
151        method, normalized_path, canonical_query
152    ))
153}
154
155/// Normalize a binding from a full URL path (including query string).
156///
157/// This is a convenience function that extracts the query string from the path.
158///
159/// # Example
160///
161/// ```rust
162/// use ash_core::normalize_binding_from_url;
163///
164/// let binding = normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap();
165/// assert_eq!(binding, "GET|/api/users|page=1&sort=name");
166/// ```
167pub fn normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, AshError> {
168    let (path, query) = match full_path.find('?') {
169        Some(pos) => (&full_path[..pos], &full_path[pos + 1..]),
170        None => (full_path, ""),
171    };
172    normalize_binding(method, path, query)
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    // v2.3.2 Binding Format Tests (METHOD|PATH|CANONICAL_QUERY)
180
181    #[test]
182    fn test_normalize_binding_basic() {
183        assert_eq!(
184            normalize_binding("POST", "/api/users", "").unwrap(),
185            "POST|/api/users|"
186        );
187    }
188
189    #[test]
190    fn test_normalize_binding_lowercase_method() {
191        assert_eq!(
192            normalize_binding("post", "/api/users", "").unwrap(),
193            "POST|/api/users|"
194        );
195    }
196
197    #[test]
198    fn test_normalize_binding_duplicate_slashes() {
199        assert_eq!(
200            normalize_binding("GET", "/api//users///profile", "").unwrap(),
201            "GET|/api/users/profile|"
202        );
203    }
204
205    #[test]
206    fn test_normalize_binding_trailing_slash() {
207        assert_eq!(
208            normalize_binding("PUT", "/api/users/", "").unwrap(),
209            "PUT|/api/users|"
210        );
211    }
212
213    #[test]
214    fn test_normalize_binding_root() {
215        assert_eq!(normalize_binding("GET", "/", "").unwrap(), "GET|/|");
216    }
217
218    #[test]
219    fn test_normalize_binding_with_query() {
220        assert_eq!(
221            normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap(),
222            "GET|/api/users|page=1&sort=name"
223        );
224    }
225
226    #[test]
227    fn test_normalize_binding_query_sorted() {
228        assert_eq!(
229            normalize_binding("GET", "/api/users", "z=3&a=1&b=2").unwrap(),
230            "GET|/api/users|a=1&b=2&z=3"
231        );
232    }
233
234    #[test]
235    fn test_normalize_binding_from_url_basic() {
236        assert_eq!(
237            normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap(),
238            "GET|/api/users|page=1&sort=name"
239        );
240    }
241
242    #[test]
243    fn test_normalize_binding_from_url_no_query() {
244        assert_eq!(
245            normalize_binding_from_url("POST", "/api/users").unwrap(),
246            "POST|/api/users|"
247        );
248    }
249
250    #[test]
251    fn test_normalize_binding_from_url_query_sorted() {
252        assert_eq!(
253            normalize_binding_from_url("GET", "/api/search?z=last&a=first").unwrap(),
254            "GET|/api/search|a=first&z=last"
255        );
256    }
257
258    #[test]
259    fn test_normalize_binding_empty_method() {
260        assert!(normalize_binding("", "/api", "").is_err());
261    }
262
263    #[test]
264    fn test_normalize_binding_no_leading_slash() {
265        assert!(normalize_binding("GET", "api/users", "").is_err());
266    }
267}