Skip to main content

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/// Canonicalize a URL query string according to ASH specification.
121///
122/// # Canonicalization Rules (9 MUST rules)
123/// - Sort by key lexicographically
124/// - Preserve order of duplicate keys
125/// - Percent-decode and re-encode consistently
126/// - Unicode NFC normalized
127///
128/// @param query - Query string to canonicalize (with or without leading ?)
129/// @returns Canonical query string
130/// @throws Error if query cannot be canonicalized
131#[wasm_bindgen(js_name = "ashCanonicalizeQuery")]
132pub fn ash_canonicalize_query(query: &str) -> Result<String, JsValue> {
133    ash_core::canonicalize_query(query).map_err(|e| JsValue::from_str(&e.to_string()))
134}
135
136/// Normalize a binding string to canonical form (v2.3.2+ format).
137///
138/// Bindings are in the format: "METHOD|PATH|CANONICAL_QUERY"
139///
140/// # Normalization Rules
141/// - Method uppercased
142/// - Path starts with /
143/// - Duplicate slashes collapsed
144/// - Trailing slash removed
145/// - Query string canonicalized
146/// - Parts joined with | (pipe)
147///
148/// @param method - HTTP method (GET, POST, etc.)
149/// @param path - URL path
150/// @param query - Query string (empty string if none)
151/// @returns Canonical binding string (METHOD|PATH|QUERY)
152/// @throws Error if method is empty or path doesn't start with /
153#[wasm_bindgen(js_name = "ashNormalizeBinding")]
154pub fn ash_normalize_binding(method: &str, path: &str, query: &str) -> Result<String, JsValue> {
155    ash_core::normalize_binding(method, path, query).map_err(|e| JsValue::from_str(&e.to_string()))
156}
157
158/// Normalize a binding from a full URL path (including query string).
159///
160/// This is a convenience function that extracts the query from the path.
161///
162/// @param method - HTTP method (GET, POST, etc.)
163/// @param fullPath - Full URL path including query string (e.g., "/api/users?page=1")
164/// @returns Canonical binding string (METHOD|PATH|QUERY)
165/// @throws Error if method is empty or path doesn't start with /
166#[wasm_bindgen(js_name = "ashNormalizeBindingFromUrl")]
167pub fn ash_normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, JsValue> {
168    ash_core::normalize_binding_from_url(method, full_path)
169        .map_err(|e| JsValue::from_str(&e.to_string()))
170}
171
172/// Constant-time comparison of two strings.
173///
174/// Use this for comparing any security-sensitive values.
175///
176/// @param a - First string
177/// @param b - Second string
178/// @returns true if strings are equal, false otherwise
179#[wasm_bindgen(js_name = "ashTimingSafeEqual")]
180pub fn ash_timing_safe_equal(a: &str, b: &str) -> bool {
181    ash_core::timing_safe_equal(a.as_bytes(), b.as_bytes())
182}
183
184/// Get the ASH protocol version.
185///
186/// @returns Version string (e.g., "ASHv1")
187#[wasm_bindgen(js_name = "ashVersion")]
188pub fn ash_version() -> String {
189    "ASHv2.1".to_string()
190}
191
192/// Get the library version.
193///
194/// @returns Semantic version string
195#[wasm_bindgen(js_name = "ashLibraryVersion")]
196pub fn ash_library_version() -> String {
197    env!("CARGO_PKG_VERSION").to_string()
198}
199
200// Re-export for convenience without prefix (backwards compatibility)
201// These will be deprecated in future versions
202
203#[wasm_bindgen(js_name = "canonicalizeJson")]
204pub fn canonicalize_json(input: &str) -> Result<String, JsValue> {
205    ash_canonicalize_json(input)
206}
207
208#[wasm_bindgen(js_name = "canonicalizeUrlencoded")]
209pub fn canonicalize_urlencoded(input: &str) -> Result<String, JsValue> {
210    ash_canonicalize_urlencoded(input)
211}
212
213#[wasm_bindgen(js_name = "buildProof")]
214pub fn build_proof(
215    mode: &str,
216    binding: &str,
217    context_id: &str,
218    nonce: Option<String>,
219    canonical_payload: &str,
220) -> Result<String, JsValue> {
221    ash_build_proof(mode, binding, context_id, nonce, canonical_payload)
222}
223
224#[wasm_bindgen(js_name = "verifyProof")]
225pub fn verify_proof(expected: &str, actual: &str) -> bool {
226    ash_verify_proof(expected, actual)
227}
228
229#[wasm_bindgen(js_name = "normalizeBinding")]
230pub fn normalize_binding(method: &str, path: &str, query: &str) -> Result<String, JsValue> {
231    ash_normalize_binding(method, path, query)
232}
233
234#[wasm_bindgen(js_name = "canonicalizeQuery")]
235pub fn canonicalize_query(query: &str) -> Result<String, JsValue> {
236    ash_canonicalize_query(query)
237}
238
239#[wasm_bindgen(js_name = "normalizeBindingFromUrl")]
240pub fn normalize_binding_from_url(method: &str, full_path: &str) -> Result<String, JsValue> {
241    ash_normalize_binding_from_url(method, full_path)
242}
243
244#[cfg(test)]
245mod tests {
246    use super::*;
247
248    #[test]
249    fn test_canonicalize_json() {
250        let result = ash_canonicalize_json(r#"{"z":1,"a":2}"#).unwrap();
251        assert_eq!(result, r#"{"a":2,"z":1}"#);
252    }
253
254    #[test]
255    fn test_canonicalize_urlencoded() {
256        let result = ash_canonicalize_urlencoded("z=1&a=2").unwrap();
257        assert_eq!(result, "a=2&z=1");
258    }
259
260    #[test]
261    fn test_build_and_verify_proof() {
262        let proof1 =
263            ash_build_proof("balanced", "POST /api/test", "ctx123", None, r#"{"a":1}"#).unwrap();
264
265        let proof2 =
266            ash_build_proof("balanced", "POST /api/test", "ctx123", None, r#"{"a":1}"#).unwrap();
267
268        assert!(ash_verify_proof(&proof1, &proof2));
269    }
270
271    #[test]
272    fn test_normalize_binding() {
273        let result = ash_normalize_binding("post", "/api//test/", "").unwrap();
274        assert_eq!(result, "POST|/api/test|");
275    }
276
277    #[test]
278    fn test_normalize_binding_with_query() {
279        let result = ash_normalize_binding("GET", "/api/users", "page=1&sort=name").unwrap();
280        assert_eq!(result, "GET|/api/users|page=1&sort=name");
281    }
282
283    #[test]
284    fn test_normalize_binding_from_url() {
285        let result = ash_normalize_binding_from_url("GET", "/api/search?z=3&a=1").unwrap();
286        assert_eq!(result, "GET|/api/search|a=1&z=3");
287    }
288
289    #[test]
290    fn test_canonicalize_query() {
291        let result = ash_canonicalize_query("z=3&a=1&b=2").unwrap();
292        assert_eq!(result, "a=1&b=2&z=3");
293    }
294
295    #[test]
296    fn test_version() {
297        assert_eq!(ash_version(), "ASHv2.1");
298    }
299}
300
301// =========================================================================
302// ASH v2.1 - Derived Client Secret & Cryptographic Proof (WASM Bindings)
303// =========================================================================
304
305/// Generate a cryptographically secure random nonce.
306/// @param bytes - Number of bytes (default 32)
307/// @returns Hex-encoded nonce
308#[wasm_bindgen(js_name = "ashGenerateNonce")]
309pub fn ash_generate_nonce(bytes: Option<usize>) -> String {
310    ash_core::generate_nonce(bytes.unwrap_or(32))
311}
312
313/// Generate a unique context ID with "ash_" prefix.
314#[wasm_bindgen(js_name = "ashGenerateContextId")]
315pub fn ash_generate_context_id() -> String {
316    ash_core::generate_context_id()
317}
318
319/// Derive client secret from server nonce (v2.1).
320/// @param nonce - Server-side secret nonce
321/// @param contextId - Context identifier  
322/// @param binding - Request binding (e.g., "POST /login")
323/// @returns Derived client secret (64 hex chars)
324#[wasm_bindgen(js_name = "ashDeriveClientSecret")]
325pub fn ash_derive_client_secret(nonce: &str, context_id: &str, binding: &str) -> String {
326    ash_core::derive_client_secret(nonce, context_id, binding)
327}
328
329/// Build v2.1 cryptographic proof.
330/// @param clientSecret - Derived client secret
331/// @param timestamp - Request timestamp (milliseconds as string)
332/// @param binding - Request binding
333/// @param bodyHash - SHA-256 hash of canonical body
334/// @returns Proof (64 hex chars)
335#[wasm_bindgen(js_name = "ashBuildProofV21")]
336pub fn ash_build_proof_v21(
337    client_secret: &str,
338    timestamp: &str,
339    binding: &str,
340    body_hash: &str,
341) -> String {
342    ash_core::build_proof_v21(client_secret, timestamp, binding, body_hash)
343}
344
345/// Verify v2.1 proof.
346/// @param nonce - Server-side secret nonce
347/// @param contextId - Context identifier
348/// @param binding - Request binding
349/// @param timestamp - Request timestamp
350/// @param bodyHash - SHA-256 hash of canonical body
351/// @param clientProof - Proof received from client
352/// @returns true if proof is valid
353#[wasm_bindgen(js_name = "ashVerifyProofV21")]
354pub fn ash_verify_proof_v21(
355    nonce: &str,
356    context_id: &str,
357    binding: &str,
358    timestamp: &str,
359    body_hash: &str,
360    client_proof: &str,
361) -> bool {
362    ash_core::verify_proof_v21(
363        nonce,
364        context_id,
365        binding,
366        timestamp,
367        body_hash,
368        client_proof,
369    )
370}
371
372/// Compute SHA-256 hash of canonical body.
373/// @param canonicalBody - Canonicalized request body
374/// @returns SHA-256 hash (64 hex chars)
375#[wasm_bindgen(js_name = "ashHashBody")]
376pub fn ash_hash_body(canonical_body: &str) -> String {
377    ash_core::hash_body(canonical_body)
378}
379
380// =========================================================================
381// ASH v2.2 - Context Scoping WASM Bindings
382// =========================================================================
383
384/// Build v2.2 cryptographic proof with scoped fields.
385/// @param clientSecret - Derived client secret
386/// @param timestamp - Request timestamp (milliseconds as string)
387/// @param binding - Request binding
388/// @param payload - Full JSON payload
389/// @param scope - Comma-separated list of fields to protect (e.g., "amount,recipient")
390/// @returns Object with { proof, scopeHash }
391#[wasm_bindgen(js_name = "ashBuildProofScoped")]
392pub fn ash_build_proof_scoped(
393    client_secret: &str,
394    timestamp: &str,
395    binding: &str,
396    payload: &str,
397    scope: &str,
398) -> Result<JsValue, JsValue> {
399    let scope_vec: Vec<&str> = if scope.is_empty() {
400        vec![]
401    } else {
402        scope.split(',').collect()
403    };
404
405    let (proof, scope_hash) =
406        ash_core::build_proof_v21_scoped(client_secret, timestamp, binding, payload, &scope_vec)
407            .map_err(|e| JsValue::from_str(&e.to_string()))?;
408
409    let result = serde_json::json!({
410        "proof": proof,
411        "scopeHash": scope_hash
412    });
413
414    Ok(JsValue::from_str(&result.to_string()))
415}
416
417/// Verify v2.2 proof with scoped fields.
418/// @param nonce - Server-side secret nonce
419/// @param contextId - Context identifier
420/// @param binding - Request binding
421/// @param timestamp - Request timestamp
422/// @param payload - Full JSON payload
423/// @param scope - Comma-separated list of protected fields
424/// @param scopeHash - Scope hash from client
425/// @param clientProof - Proof received from client
426/// @returns true if proof is valid
427#[allow(clippy::too_many_arguments)]
428#[wasm_bindgen(js_name = "ashVerifyProofScoped")]
429pub fn ash_verify_proof_scoped(
430    nonce: &str,
431    context_id: &str,
432    binding: &str,
433    timestamp: &str,
434    payload: &str,
435    scope: &str,
436    scope_hash: &str,
437    client_proof: &str,
438) -> Result<bool, JsValue> {
439    let scope_vec: Vec<&str> = if scope.is_empty() {
440        vec![]
441    } else {
442        scope.split(',').collect()
443    };
444
445    ash_core::verify_proof_v21_scoped(
446        nonce,
447        context_id,
448        binding,
449        timestamp,
450        payload,
451        &scope_vec,
452        scope_hash,
453        client_proof,
454    )
455    .map_err(|e| JsValue::from_str(&e.to_string()))
456}
457
458/// Hash scoped payload fields.
459/// @param payload - Full JSON payload
460/// @param scope - Comma-separated list of fields to hash
461/// @returns SHA-256 hash of scoped fields
462#[wasm_bindgen(js_name = "ashHashScopedBody")]
463pub fn ash_hash_scoped_body(payload: &str, scope: &str) -> Result<String, JsValue> {
464    let scope_vec: Vec<&str> = if scope.is_empty() {
465        vec![]
466    } else {
467        scope.split(',').collect()
468    };
469
470    ash_core::hash_scoped_body(payload, &scope_vec).map_err(|e| JsValue::from_str(&e.to_string()))
471}
472
473// =========================================================================
474// ASH v2.3 - Unified Proof Functions (Scoping + Chaining) WASM Bindings
475// =========================================================================
476
477/// Hash a proof for chaining purposes.
478/// @param proof - Proof to hash
479/// @returns SHA-256 hash of the proof (64 hex chars)
480#[wasm_bindgen(js_name = "ashHashProof")]
481pub fn ash_hash_proof(proof: &str) -> String {
482    ash_core::hash_proof(proof)
483}
484
485/// Build unified v2.3 cryptographic proof with optional scoping and chaining.
486/// @param clientSecret - Derived client secret
487/// @param timestamp - Request timestamp (milliseconds as string)
488/// @param binding - Request binding
489/// @param payload - Full JSON payload
490/// @param scope - Comma-separated list of fields to protect (empty for full payload)
491/// @param previousProof - Previous proof in chain (empty or null for no chaining)
492/// @returns Object with { proof, scopeHash, chainHash }
493#[wasm_bindgen(js_name = "ashBuildProofUnified")]
494pub fn ash_build_proof_unified(
495    client_secret: &str,
496    timestamp: &str,
497    binding: &str,
498    payload: &str,
499    scope: &str,
500    previous_proof: Option<String>,
501) -> Result<JsValue, JsValue> {
502    let scope_vec: Vec<&str> = if scope.is_empty() {
503        vec![]
504    } else {
505        scope.split(',').collect()
506    };
507
508    let prev_proof = previous_proof.as_deref().filter(|s| !s.is_empty());
509
510    let result = ash_core::build_proof_v21_unified(
511        client_secret,
512        timestamp,
513        binding,
514        payload,
515        &scope_vec,
516        prev_proof,
517    )
518    .map_err(|e| JsValue::from_str(&e.to_string()))?;
519
520    let json_result = serde_json::json!({
521        "proof": result.proof,
522        "scopeHash": result.scope_hash,
523        "chainHash": result.chain_hash
524    });
525
526    Ok(JsValue::from_str(&json_result.to_string()))
527}
528
529/// Verify unified v2.3 proof with optional scoping and chaining.
530/// @param nonce - Server-side secret nonce
531/// @param contextId - Context identifier
532/// @param binding - Request binding
533/// @param timestamp - Request timestamp
534/// @param payload - Full JSON payload
535/// @param clientProof - Proof received from client
536/// @param scope - Comma-separated list of protected fields (empty for full payload)
537/// @param scopeHash - Scope hash from client (empty if no scoping)
538/// @param previousProof - Previous proof in chain (empty or null if no chaining)
539/// @param chainHash - Chain hash from client (empty if no chaining)
540/// @returns true if proof is valid
541#[allow(clippy::too_many_arguments)]
542#[wasm_bindgen(js_name = "ashVerifyProofUnified")]
543pub fn ash_verify_proof_unified(
544    nonce: &str,
545    context_id: &str,
546    binding: &str,
547    timestamp: &str,
548    payload: &str,
549    client_proof: &str,
550    scope: &str,
551    scope_hash: &str,
552    previous_proof: Option<String>,
553    chain_hash: &str,
554) -> Result<bool, JsValue> {
555    let scope_vec: Vec<&str> = if scope.is_empty() {
556        vec![]
557    } else {
558        scope.split(',').collect()
559    };
560
561    let prev_proof = previous_proof.as_deref().filter(|s| !s.is_empty());
562
563    ash_core::verify_proof_v21_unified(
564        nonce,
565        context_id,
566        binding,
567        timestamp,
568        payload,
569        client_proof,
570        &scope_vec,
571        scope_hash,
572        prev_proof,
573        chain_hash,
574    )
575    .map_err(|e| JsValue::from_str(&e.to_string()))
576}