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    // Version constants (v2.3.1)
73    ASH_SDK_VERSION,
74    ASH_VERSION_PREFIX,
75    ASH_VERSION_PREFIX_V21,
76};
77pub use types::{AshMode, BuildProofInput, VerifyInput};
78
79/// Normalize a binding string to canonical form (v2.3.2+ format).
80///
81/// Bindings are in the format: `METHOD|PATH|CANONICAL_QUERY`
82///
83/// # Normalization Rules
84/// - Method is uppercased
85/// - Path must start with `/`
86/// - Path has duplicate slashes collapsed
87/// - Trailing slash is removed (except for root `/`)
88/// - Query string is canonicalized (sorted, normalized)
89/// - Parts are joined with `|` (pipe) separator
90///
91/// # Example
92///
93/// ```rust
94/// use ash_core::normalize_binding;
95///
96/// let binding = normalize_binding("post", "/api//users/", "").unwrap();
97/// assert_eq!(binding, "POST|/api/users|");
98///
99/// let binding_with_query = normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap();
100/// assert_eq!(binding_with_query, "GET|/api/users|page=1&sort=name");
101/// ```
102pub fn normalize_binding(method: &str, path: &str, query: &str) -> Result<String, AshError> {
103    // Validate method
104    let method = method.trim().to_uppercase();
105    if method.is_empty() {
106        return Err(AshError::new(
107            AshErrorCode::MalformedRequest,
108            "Method cannot be empty",
109        ));
110    }
111
112    // Validate path starts with /
113    let path = path.trim();
114    if !path.starts_with('/') {
115        return Err(AshError::new(
116            AshErrorCode::MalformedRequest,
117            "Path must start with /",
118        ));
119    }
120
121    // Extract path without query string (in case path contains ?)
122    let path_only = path.split('?').next().unwrap_or(path);
123
124    // Collapse duplicate slashes and normalize
125    let mut normalized_path = String::with_capacity(path_only.len());
126    let mut prev_slash = false;
127
128    for ch in path_only.chars() {
129        if ch == '/' {
130            if !prev_slash {
131                normalized_path.push(ch);
132            }
133            prev_slash = true;
134        } else {
135            normalized_path.push(ch);
136            prev_slash = false;
137        }
138    }
139
140    // Remove trailing slash (except for root)
141    if normalized_path.len() > 1 && normalized_path.ends_with('/') {
142        normalized_path.pop();
143    }
144
145    // Canonicalize query string
146    let canonical_query = if query.is_empty() {
147        String::new()
148    } else {
149        canonicalize::canonicalize_query(query)?
150    };
151
152    // v2.3.2 format: METHOD|PATH|CANONICAL_QUERY
153    Ok(format!(
154        "{}|{}|{}",
155        method, normalized_path, canonical_query
156    ))
157}
158
159/// Normalize a binding from a full URL path (including query string).
160///
161/// This is a convenience function that extracts the query string from the path.
162///
163/// # Example
164///
165/// ```rust
166/// use ash_core::normalize_binding_from_url;
167///
168/// let binding = normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap();
169/// assert_eq!(binding, "GET|/api/users|page=1&sort=name");
170/// ```
171pub fn normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, AshError> {
172    let (path, query) = match full_path.find('?') {
173        Some(pos) => (&full_path[..pos], &full_path[pos + 1..]),
174        None => (full_path, ""),
175    };
176    normalize_binding(method, path, query)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // v2.3.2 Binding Format Tests (METHOD|PATH|CANONICAL_QUERY)
184
185    #[test]
186    fn test_normalize_binding_basic() {
187        assert_eq!(
188            normalize_binding("POST", "/api/users", "").unwrap(),
189            "POST|/api/users|"
190        );
191    }
192
193    #[test]
194    fn test_normalize_binding_lowercase_method() {
195        assert_eq!(
196            normalize_binding("post", "/api/users", "").unwrap(),
197            "POST|/api/users|"
198        );
199    }
200
201    #[test]
202    fn test_normalize_binding_duplicate_slashes() {
203        assert_eq!(
204            normalize_binding("GET", "/api//users///profile", "").unwrap(),
205            "GET|/api/users/profile|"
206        );
207    }
208
209    #[test]
210    fn test_normalize_binding_trailing_slash() {
211        assert_eq!(
212            normalize_binding("PUT", "/api/users/", "").unwrap(),
213            "PUT|/api/users|"
214        );
215    }
216
217    #[test]
218    fn test_normalize_binding_root() {
219        assert_eq!(normalize_binding("GET", "/", "").unwrap(), "GET|/|");
220    }
221
222    #[test]
223    fn test_normalize_binding_with_query() {
224        assert_eq!(
225            normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap(),
226            "GET|/api/users|page=1&sort=name"
227        );
228    }
229
230    #[test]
231    fn test_normalize_binding_query_sorted() {
232        assert_eq!(
233            normalize_binding("GET", "/api/users", "z=3&a=1&b=2").unwrap(),
234            "GET|/api/users|a=1&b=2&z=3"
235        );
236    }
237
238    #[test]
239    fn test_normalize_binding_from_url_basic() {
240        assert_eq!(
241            normalize_binding_from_url("GET", "/api/users?page=1&sort=name").unwrap(),
242            "GET|/api/users|page=1&sort=name"
243        );
244    }
245
246    #[test]
247    fn test_normalize_binding_from_url_no_query() {
248        assert_eq!(
249            normalize_binding_from_url("POST", "/api/users").unwrap(),
250            "POST|/api/users|"
251        );
252    }
253
254    #[test]
255    fn test_normalize_binding_from_url_query_sorted() {
256        assert_eq!(
257            normalize_binding_from_url("GET", "/api/search?z=last&a=first").unwrap(),
258            "GET|/api/search|a=first&z=last"
259        );
260    }
261
262    #[test]
263    fn test_normalize_binding_empty_method() {
264        assert!(normalize_binding("", "/api", "").is_err());
265    }
266
267    #[test]
268    fn test_normalize_binding_no_leading_slash() {
269        assert!(normalize_binding("GET", "api/users", "").is_err());
270    }
271
272    // v2.3.1 Version Constants Tests
273
274    #[test]
275    fn test_version_constants() {
276        use crate::{ASH_SDK_VERSION, ASH_VERSION_PREFIX, ASH_VERSION_PREFIX_V21};
277
278        assert_eq!(ASH_SDK_VERSION, "2.3.1");
279        assert_eq!(ASH_VERSION_PREFIX, "ASHv1");
280        assert_eq!(ASH_VERSION_PREFIX_V21, "ASHv2.1");
281    }
282
283    // v2.3.1 Query Canonicalization in Binding Tests
284
285    #[test]
286    fn test_normalize_binding_strips_fragment() {
287        // Fragment should be stripped from query string
288        assert_eq!(
289            normalize_binding("GET", "/api/search", "q=test#section").unwrap(),
290            "GET|/api/search|q=test"
291        );
292    }
293
294    #[test]
295    fn test_normalize_binding_plus_literal() {
296        // + is literal plus in query strings, not space
297        assert_eq!(
298            normalize_binding("GET", "/api/search", "q=a+b").unwrap(),
299            "GET|/api/search|q=a%2Bb"
300        );
301    }
302}