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}