ash_wasm/
lib.rs

1//! # ASH WASM
2//!
3//! WebAssembly bindings for ASH (Anti-tamper Security Hash).
4//!
5//! This module provides universal access to ASH functionality from any
6//! WASM-compatible environment: browsers, Node.js, Deno, Python, Go, .NET, PHP.
7//!
8//! ## Usage (JavaScript/TypeScript)
9//!
10//! ```javascript
11//! import * as ash from '@3meam/ash';
12//!
13//! // Canonicalize JSON
14//! const canonical = ash.canonicalizeJson('{"z":1,"a":2}');
15//! // => '{"a":2,"z":1}'
16//!
17//! // Build proof
18//! const proof = ash.buildProof('balanced', 'POST /api/update', 'ctx123', null, canonical);
19//!
20//! // Verify proof
21//! const isValid = ash.verifyProof(expectedProof, actualProof);
22//! ```
23
24use wasm_bindgen::prelude::*;
25
26// Initialize panic hook for better error messages in development
27#[cfg(feature = "console_error_panic_hook")]
28pub fn set_panic_hook() {
29    console_error_panic_hook::set_once();
30}
31
32/// Initialize the ASH WASM module.
33///
34/// Call this once before using other functions.
35/// Sets up panic hooks for better error messages.
36#[wasm_bindgen(js_name = "ashInit")]
37pub fn ash_init() {
38    #[cfg(feature = "console_error_panic_hook")]
39    set_panic_hook();
40}
41
42/// Canonicalize a JSON string to deterministic form.
43///
44/// # Canonicalization Rules
45/// - Object keys sorted lexicographically
46/// - No whitespace
47/// - Unicode NFC normalized
48/// - Numbers normalized (no -0, no trailing zeros)
49///
50/// @param input - JSON string to canonicalize
51/// @returns Canonical JSON string
52/// @throws Error if input is not valid JSON
53#[wasm_bindgen(js_name = "ashCanonicalizeJson")]
54pub fn ash_canonicalize_json(input: &str) -> Result<String, JsValue> {
55    ash_core::canonicalize_json(input).map_err(|e| JsValue::from_str(&e.to_string()))
56}
57
58/// Canonicalize URL-encoded form data to deterministic form.
59///
60/// # Canonicalization Rules
61/// - Key-value pairs sorted by key
62/// - Percent-decoded and re-encoded consistently
63/// - Unicode NFC normalized
64///
65/// @param input - URL-encoded string to canonicalize
66/// @returns Canonical URL-encoded string
67/// @throws Error if input cannot be canonicalized
68#[wasm_bindgen(js_name = "ashCanonicalizeUrlencoded")]
69pub fn ash_canonicalize_urlencoded(input: &str) -> Result<String, JsValue> {
70    ash_core::canonicalize_urlencoded(input).map_err(|e| JsValue::from_str(&e.to_string()))
71}
72
73/// Build a cryptographic proof for request integrity.
74///
75/// The proof binds the payload to a specific context and endpoint,
76/// preventing tampering and replay attacks.
77///
78/// @param mode - Security mode: "minimal", "balanced", or "strict"
79/// @param binding - Endpoint binding: "METHOD /path"
80/// @param contextId - Context ID from server
81/// @param nonce - Optional nonce for server-assisted mode (null if not used)
82/// @param canonicalPayload - Canonicalized payload string
83/// @returns Base64URL-encoded proof string
84/// @throws Error if mode is invalid
85#[wasm_bindgen(js_name = "ashBuildProof")]
86pub fn ash_build_proof(
87    mode: &str,
88    binding: &str,
89    context_id: &str,
90    nonce: Option<String>,
91    canonical_payload: &str,
92) -> Result<String, JsValue> {
93    let ash_mode: ash_core::AshMode = mode
94        .parse()
95        .map_err(|e: ash_core::AshError| JsValue::from_str(&e.to_string()))?;
96
97    ash_core::build_proof(
98        ash_mode,
99        binding,
100        context_id,
101        nonce.as_deref(),
102        canonical_payload,
103    )
104    .map_err(|e| JsValue::from_str(&e.to_string()))
105}
106
107/// Verify that two proofs match using constant-time comparison.
108///
109/// This function is safe against timing attacks - the comparison
110/// takes the same amount of time regardless of where differences occur.
111///
112/// @param expected - Expected proof (computed by server)
113/// @param actual - Actual proof (received from client)
114/// @returns true if proofs match, false otherwise
115#[wasm_bindgen(js_name = "ashVerifyProof")]
116pub fn ash_verify_proof(expected: &str, actual: &str) -> bool {
117    ash_core::timing_safe_equal(expected.as_bytes(), actual.as_bytes())
118}
119
120/// Normalize a binding string to canonical form.
121///
122/// Bindings are in the format: "METHOD /path"
123///
124/// # Normalization Rules
125/// - Method uppercased
126/// - Path starts with /
127/// - Query string excluded
128/// - Duplicate slashes collapsed
129/// - Trailing slash removed
130///
131/// @param method - HTTP method (GET, POST, etc.)
132/// @param path - URL path
133/// @returns Canonical binding string
134/// @throws Error if method is empty or path doesn't start with /
135#[wasm_bindgen(js_name = "ashNormalizeBinding")]
136pub fn ash_normalize_binding(method: &str, path: &str) -> Result<String, JsValue> {
137    ash_core::normalize_binding(method, path).map_err(|e| JsValue::from_str(&e.to_string()))
138}
139
140/// Constant-time comparison of two strings.
141///
142/// Use this for comparing any security-sensitive values.
143///
144/// @param a - First string
145/// @param b - Second string
146/// @returns true if strings are equal, false otherwise
147#[wasm_bindgen(js_name = "ashTimingSafeEqual")]
148pub fn ash_timing_safe_equal(a: &str, b: &str) -> bool {
149    ash_core::timing_safe_equal(a.as_bytes(), b.as_bytes())
150}
151
152/// Get the ASH protocol version.
153///
154/// @returns Version string (e.g., "ASHv1")
155#[wasm_bindgen(js_name = "ashVersion")]
156pub fn ash_version() -> String {
157    "ASHv2.1".to_string()
158}
159
160/// Get the library version.
161///
162/// @returns Semantic version string
163#[wasm_bindgen(js_name = "ashLibraryVersion")]
164pub fn ash_library_version() -> String {
165    env!("CARGO_PKG_VERSION").to_string()
166}
167
168// Re-export for convenience without prefix (backwards compatibility)
169// These will be deprecated in future versions
170
171#[wasm_bindgen(js_name = "canonicalizeJson")]
172pub fn canonicalize_json(input: &str) -> Result<String, JsValue> {
173    ash_canonicalize_json(input)
174}
175
176#[wasm_bindgen(js_name = "canonicalizeUrlencoded")]
177pub fn canonicalize_urlencoded(input: &str) -> Result<String, JsValue> {
178    ash_canonicalize_urlencoded(input)
179}
180
181#[wasm_bindgen(js_name = "buildProof")]
182pub fn build_proof(
183    mode: &str,
184    binding: &str,
185    context_id: &str,
186    nonce: Option<String>,
187    canonical_payload: &str,
188) -> Result<String, JsValue> {
189    ash_build_proof(mode, binding, context_id, nonce, canonical_payload)
190}
191
192#[wasm_bindgen(js_name = "verifyProof")]
193pub fn verify_proof(expected: &str, actual: &str) -> bool {
194    ash_verify_proof(expected, actual)
195}
196
197#[wasm_bindgen(js_name = "normalizeBinding")]
198pub fn normalize_binding(method: &str, path: &str) -> Result<String, JsValue> {
199    ash_normalize_binding(method, path)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn test_canonicalize_json() {
208        let result = ash_canonicalize_json(r#"{"z":1,"a":2}"#).unwrap();
209        assert_eq!(result, r#"{"a":2,"z":1}"#);
210    }
211
212    #[test]
213    fn test_canonicalize_urlencoded() {
214        let result = ash_canonicalize_urlencoded("z=1&a=2").unwrap();
215        assert_eq!(result, "a=2&z=1");
216    }
217
218    #[test]
219    fn test_build_and_verify_proof() {
220        let proof1 =
221            ash_build_proof("balanced", "POST /api/test", "ctx123", None, r#"{"a":1}"#).unwrap();
222
223        let proof2 =
224            ash_build_proof("balanced", "POST /api/test", "ctx123", None, r#"{"a":1}"#).unwrap();
225
226        assert!(ash_verify_proof(&proof1, &proof2));
227    }
228
229    #[test]
230    fn test_normalize_binding() {
231        let result = ash_normalize_binding("post", "/api//test/").unwrap();
232        assert_eq!(result, "POST /api/test");
233    }
234
235    #[test]
236    fn test_version() {
237        assert_eq!(ash_version(), "ASHv2.1");
238    }
239}
240
241// =========================================================================
242// ASH v2.1 - Derived Client Secret & Cryptographic Proof (WASM Bindings)
243// =========================================================================
244
245/// Generate a cryptographically secure random nonce.
246/// @param bytes - Number of bytes (default 32)
247/// @returns Hex-encoded nonce
248#[wasm_bindgen(js_name = "ashGenerateNonce")]
249pub fn ash_generate_nonce(bytes: Option<usize>) -> String {
250    ash_core::generate_nonce(bytes.unwrap_or(32))
251}
252
253/// Generate a unique context ID with "ash_" prefix.
254#[wasm_bindgen(js_name = "ashGenerateContextId")]
255pub fn ash_generate_context_id() -> String {
256    ash_core::generate_context_id()
257}
258
259/// Derive client secret from server nonce (v2.1).
260/// @param nonce - Server-side secret nonce
261/// @param contextId - Context identifier  
262/// @param binding - Request binding (e.g., "POST /login")
263/// @returns Derived client secret (64 hex chars)
264#[wasm_bindgen(js_name = "ashDeriveClientSecret")]
265pub fn ash_derive_client_secret(nonce: &str, context_id: &str, binding: &str) -> String {
266    ash_core::derive_client_secret(nonce, context_id, binding)
267}
268
269/// Build v2.1 cryptographic proof.
270/// @param clientSecret - Derived client secret
271/// @param timestamp - Request timestamp (milliseconds as string)
272/// @param binding - Request binding
273/// @param bodyHash - SHA-256 hash of canonical body
274/// @returns Proof (64 hex chars)
275#[wasm_bindgen(js_name = "ashBuildProofV21")]
276pub fn ash_build_proof_v21(
277    client_secret: &str,
278    timestamp: &str,
279    binding: &str,
280    body_hash: &str,
281) -> String {
282    ash_core::build_proof_v21(client_secret, timestamp, binding, body_hash)
283}
284
285/// Verify v2.1 proof.
286/// @param nonce - Server-side secret nonce
287/// @param contextId - Context identifier
288/// @param binding - Request binding
289/// @param timestamp - Request timestamp
290/// @param bodyHash - SHA-256 hash of canonical body
291/// @param clientProof - Proof received from client
292/// @returns true if proof is valid
293#[wasm_bindgen(js_name = "ashVerifyProofV21")]
294pub fn ash_verify_proof_v21(
295    nonce: &str,
296    context_id: &str,
297    binding: &str,
298    timestamp: &str,
299    body_hash: &str,
300    client_proof: &str,
301) -> bool {
302    ash_core::verify_proof_v21(nonce, context_id, binding, timestamp, body_hash, client_proof)
303}
304
305/// Compute SHA-256 hash of canonical body.
306/// @param canonicalBody - Canonicalized request body
307/// @returns SHA-256 hash (64 hex chars)
308#[wasm_bindgen(js_name = "ashHashBody")]
309pub fn ash_hash_body(canonical_body: &str) -> String {
310    ash_core::hash_body(canonical_body)
311}
312
313// =========================================================================
314// ASH v2.2 - Context Scoping WASM Bindings
315// =========================================================================
316
317/// Build v2.2 cryptographic proof with scoped fields.
318/// @param clientSecret - Derived client secret
319/// @param timestamp - Request timestamp (milliseconds as string)
320/// @param binding - Request binding
321/// @param payload - Full JSON payload
322/// @param scope - Comma-separated list of fields to protect (e.g., "amount,recipient")
323/// @returns Object with { proof, scopeHash }
324#[wasm_bindgen(js_name = "ashBuildProofScoped")]
325pub fn ash_build_proof_scoped(
326    client_secret: &str,
327    timestamp: &str,
328    binding: &str,
329    payload: &str,
330    scope: &str,
331) -> Result<JsValue, JsValue> {
332    let scope_vec: Vec<&str> = if scope.is_empty() {
333        vec![]
334    } else {
335        scope.split(',').collect()
336    };
337
338    let (proof, scope_hash) = ash_core::build_proof_v21_scoped(
339        client_secret,
340        timestamp,
341        binding,
342        payload,
343        &scope_vec,
344    ).map_err(|e| JsValue::from_str(&e.to_string()))?;
345
346    let result = serde_json::json!({
347        "proof": proof,
348        "scopeHash": scope_hash
349    });
350
351    Ok(JsValue::from_str(&result.to_string()))
352}
353
354/// Verify v2.2 proof with scoped fields.
355/// @param nonce - Server-side secret nonce
356/// @param contextId - Context identifier
357/// @param binding - Request binding
358/// @param timestamp - Request timestamp
359/// @param payload - Full JSON payload
360/// @param scope - Comma-separated list of protected fields
361/// @param scopeHash - Scope hash from client
362/// @param clientProof - Proof received from client
363/// @returns true if proof is valid
364#[wasm_bindgen(js_name = "ashVerifyProofScoped")]
365pub fn ash_verify_proof_scoped(
366    nonce: &str,
367    context_id: &str,
368    binding: &str,
369    timestamp: &str,
370    payload: &str,
371    scope: &str,
372    scope_hash: &str,
373    client_proof: &str,
374) -> Result<bool, JsValue> {
375    let scope_vec: Vec<&str> = if scope.is_empty() {
376        vec![]
377    } else {
378        scope.split(',').collect()
379    };
380
381    ash_core::verify_proof_v21_scoped(
382        nonce,
383        context_id,
384        binding,
385        timestamp,
386        payload,
387        &scope_vec,
388        scope_hash,
389        client_proof,
390    ).map_err(|e| JsValue::from_str(&e.to_string()))
391}
392
393/// Hash scoped payload fields.
394/// @param payload - Full JSON payload
395/// @param scope - Comma-separated list of fields to hash
396/// @returns SHA-256 hash of scoped fields
397#[wasm_bindgen(js_name = "ashHashScopedBody")]
398pub fn ash_hash_scoped_body(payload: &str, scope: &str) -> Result<String, JsValue> {
399    let scope_vec: Vec<&str> = if scope.is_empty() {
400        vec![]
401    } else {
402        scope.split(',').collect()
403    };
404
405    ash_core::hash_scoped_body(payload, &scope_vec)
406        .map_err(|e| JsValue::from_str(&e.to_string()))
407}
408
409// =========================================================================
410// ASH v2.3 - Unified Proof Functions (Scoping + Chaining) WASM Bindings
411// =========================================================================
412
413/// Hash a proof for chaining purposes.
414/// @param proof - Proof to hash
415/// @returns SHA-256 hash of the proof (64 hex chars)
416#[wasm_bindgen(js_name = "ashHashProof")]
417pub fn ash_hash_proof(proof: &str) -> String {
418    ash_core::hash_proof(proof)
419}
420
421/// Build unified v2.3 cryptographic proof with optional scoping and chaining.
422/// @param clientSecret - Derived client secret
423/// @param timestamp - Request timestamp (milliseconds as string)
424/// @param binding - Request binding
425/// @param payload - Full JSON payload
426/// @param scope - Comma-separated list of fields to protect (empty for full payload)
427/// @param previousProof - Previous proof in chain (empty or null for no chaining)
428/// @returns Object with { proof, scopeHash, chainHash }
429#[wasm_bindgen(js_name = "ashBuildProofUnified")]
430pub fn ash_build_proof_unified(
431    client_secret: &str,
432    timestamp: &str,
433    binding: &str,
434    payload: &str,
435    scope: &str,
436    previous_proof: Option<String>,
437) -> Result<JsValue, JsValue> {
438    let scope_vec: Vec<&str> = if scope.is_empty() {
439        vec![]
440    } else {
441        scope.split(',').collect()
442    };
443
444    let prev_proof = previous_proof.as_deref().filter(|s| !s.is_empty());
445
446    let result = ash_core::build_proof_v21_unified(
447        client_secret,
448        timestamp,
449        binding,
450        payload,
451        &scope_vec,
452        prev_proof,
453    ).map_err(|e| JsValue::from_str(&e.to_string()))?;
454
455    let json_result = serde_json::json!({
456        "proof": result.proof,
457        "scopeHash": result.scope_hash,
458        "chainHash": result.chain_hash
459    });
460
461    Ok(JsValue::from_str(&json_result.to_string()))
462}
463
464/// Verify unified v2.3 proof with optional scoping and chaining.
465/// @param nonce - Server-side secret nonce
466/// @param contextId - Context identifier
467/// @param binding - Request binding
468/// @param timestamp - Request timestamp
469/// @param payload - Full JSON payload
470/// @param clientProof - Proof received from client
471/// @param scope - Comma-separated list of protected fields (empty for full payload)
472/// @param scopeHash - Scope hash from client (empty if no scoping)
473/// @param previousProof - Previous proof in chain (empty or null if no chaining)
474/// @param chainHash - Chain hash from client (empty if no chaining)
475/// @returns true if proof is valid
476#[wasm_bindgen(js_name = "ashVerifyProofUnified")]
477pub fn ash_verify_proof_unified(
478    nonce: &str,
479    context_id: &str,
480    binding: &str,
481    timestamp: &str,
482    payload: &str,
483    client_proof: &str,
484    scope: &str,
485    scope_hash: &str,
486    previous_proof: Option<String>,
487    chain_hash: &str,
488) -> Result<bool, JsValue> {
489    let scope_vec: Vec<&str> = if scope.is_empty() {
490        vec![]
491    } else {
492        scope.split(',').collect()
493    };
494
495    let prev_proof = previous_proof.as_deref().filter(|s| !s.is_empty());
496
497    ash_core::verify_proof_v21_unified(
498        nonce,
499        context_id,
500        binding,
501        timestamp,
502        payload,
503        client_proof,
504        &scope_vec,
505        scope_hash,
506        prev_proof,
507        chain_hash,
508    ).map_err(|e| JsValue::from_str(&e.to_string()))
509}