From 230976664c350dc879a489daa303bd4749531a00 Mon Sep 17 00:00:00 2001 From: UneBaguette <28904802+UneBaguette@users.noreply.github.com> Date: Tue, 28 Apr 2026 06:05:57 +0200 Subject: [PATCH] feat!: native-first API refactor, WASM behind feature flag BREAKING: native functions return [u8; 32] instead of Vec. BREAKING: WASM bindings require `wasm` feature flag. New Blake3Error type replaces JsError for native consumers. --- .github/workflows/ci.yml | 2 +- .github/workflows/publish.yml | 2 +- Cargo.lock | 2 +- Cargo.toml | 10 +- Makefile | 8 +- scripts/tpl/index.d.ts | 1 - src/lib.rs | 257 ++++++++++++++++++++++++++-------- 7 files changed, 211 insertions(+), 71 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8854c84..4f975a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,4 +30,4 @@ jobs: run: cargo test - name: WASM tests (Node) - run: wasm-pack test --node + run: wasm-pack test --node --features wasm,talc diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73f0bf0..dfd57df 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -39,7 +39,7 @@ jobs: run: cargo test - name: WASM tests (Node) - run: wasm-pack test --node + run: wasm-pack test --node --features wasm,talc - name: Build package run: make diff --git a/Cargo.lock b/Cargo.lock index 9ec2424..6f02649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -53,7 +53,7 @@ dependencies = [ [[package]] name = "blake3-wasm-rs" -version = "0.5.2" +version = "0.6.0" dependencies = [ "blake3", "hex", diff --git a/Cargo.toml b/Cargo.toml index dd7f6ad..bf9322e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "blake3-wasm-rs" -version = "0.5.2" +version = "0.6.0" edition = "2024" license = "MIT OR Apache-2.0" authors = ["Thomas "] publish = false repository = "https://github.com/UneBaguette/blake3.wasm" -description = "Simple blake3 to wasm project" +description = "BLAKE3 hashing with optional WASM bindings" keywords = ["blake3", "wasm", "hash", "cryptography", "wasm-bindgen"] categories = ["cryptography", "wasm"] @@ -14,11 +14,13 @@ categories = ["cryptography", "wasm"] crate-type = ["cdylib", "rlib"] [features] +default = [] +wasm = ["dep:wasm-bindgen"] talc = ["dep:talc"] [dependencies] -blake3 = { version = "1.8", features = ["wasm32_simd"] } -wasm-bindgen = "=0.2.118" +blake3 = { version = "1", features = ["wasm32_simd"] } +wasm-bindgen = { version = "=0.2.118", optional = true } [dev-dependencies] wasm-bindgen-test = "0.3" diff --git a/Makefile b/Makefile index 537c666..0b741b0 100644 --- a/Makefile +++ b/Makefile @@ -20,7 +20,7 @@ build: build-all node-esm build-all: @echo "Building all target..." - RUSTFLAGS='-C opt-level=3 -C target-feature=+simd128' $(CARGO) build --target $(WASM_TARGET) --release --features talc + RUSTFLAGS='-C opt-level=3 -C target-feature=+simd128' $(CARGO) build --target $(WASM_TARGET) --release --features wasm,talc @mkdir -p $(ROOT)/bundler $(ROOT)/web $(ROOT)/node $(WASM_BINDGEN) --target bundler --out-dir $(ROOT)/bundler $(TARGET_DIR)/$(CRATE).wasm $(WASM_BINDGEN) --target web --out-dir $(ROOT)/web $(TARGET_DIR)/$(CRATE).wasm @@ -35,9 +35,9 @@ build-all: @cp scripts/tpl/index.js.template $(ROOT)/index.js @cp scripts/tpl/index.d.ts $(ROOT)/index.d.ts @cp README.md $(ROOT)/README.md - @cp LICENSE-MIT $(PKG_DIR)/LICENSE-MIT - @cp LICENSE-APACHE $(PKG_DIR)/LICENSE-APACHE - @sed -i 's|// @ts-nocheck|// Types|' $(ROOT)/index.d.ts + @cp LICENSE-MIT $(ROOT)/LICENSE-MIT + @cp LICENSE-APACHE $(ROOT)/LICENSE-APACHE + @sed -i 's|// @ts-nocheck||' $(ROOT)/index.d.ts @node -e "\ const pkg = {\ name: 'blake3-wasm-rs',\ diff --git a/scripts/tpl/index.d.ts b/scripts/tpl/index.d.ts index 07bb873..d11d862 100644 --- a/scripts/tpl/index.d.ts +++ b/scripts/tpl/index.d.ts @@ -1,6 +1,5 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 // Copyright 2026 Thomas -// // @ts-nocheck export { Hasher, deriveKey, hash, hashXof, keyedHash } from './bundler/blake3_wasm_rs'; \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 42fc997..c064cf2 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,8 +1,6 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 // Copyright 2026 Thomas -use wasm_bindgen::prelude::*; - #[cfg(all( not(target_feature = "atomics"), target_family = "wasm", @@ -13,77 +11,77 @@ static TALC: talc::wasm::WasmDynamicTalc = talc::wasm::new_wasm_dynamic_allocato const MAX_XOF_LEN: usize = 1 << 16; // 65_536 -fn checked_xof_len(out_len: usize) -> Result { +#[derive(Debug)] +pub struct Blake3Error(pub String); + +impl core::fmt::Display for Blake3Error { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl std::error::Error for Blake3Error {} + +fn err(msg: &str) -> Blake3Error { + Blake3Error(msg.to_string()) +} + +fn checked_xof_len(out_len: usize) -> Result { if out_len == 0 || out_len > MAX_XOF_LEN { - return Err(JsError::new("out_len must be between 1 and 65536")); + return Err(err("out_len must be between 1 and 65536")); } Ok(out_len) } /// Hash data and return a 32-byte BLAKE3 digest. -#[wasm_bindgen] -pub fn hash(value: &[u8]) -> Vec { - blake3::hash(value).as_bytes().to_vec() +pub fn hash(value: &[u8]) -> [u8; 32] { + *blake3::hash(value).as_bytes() } /// Hash data with variable-length output (XOF mode). /// Returns `out_len` bytes of BLAKE3 extended output. -#[wasm_bindgen(js_name = "hashXof")] -pub fn hash_xof(data: &[u8], out_len: usize) -> Result, JsError> { +pub fn hash_xof(data: &[u8], out_len: usize) -> Result, Blake3Error> { let out_len = checked_xof_len(out_len)?; let mut out = vec![0u8; out_len]; - let mut reader = blake3::Hasher::new().update(data).finalize_xof(); - reader.fill(&mut out); + blake3::Hasher::new() + .update(data) + .finalize_xof() + .fill(&mut out); Ok(out) } /// Compute a keyed BLAKE3 hash (MAC). Key must be exactly 32 bytes. /// Throws if the key length is wrong. -#[wasm_bindgen(js_name = "keyedHash")] -pub fn keyed_hash(data: &[u8], key: &[u8]) -> Result, JsError> { +pub fn keyed_hash(data: &[u8], key: &[u8]) -> Result<[u8; 32], Blake3Error> { let key: &[u8; 32] = key .try_into() - .map_err(|_| JsError::new("key must be exactly 32 bytes"))?; + .map_err(|_| err("key must be exactly 32 bytes"))?; - Ok(blake3::keyed_hash(key, data).as_bytes().to_vec()) + Ok(*blake3::keyed_hash(key, data).as_bytes()) } /// Derive a 32-byte key from a context string and key material. /// Context should be a hardcoded, globally unique, application-specific string. -#[wasm_bindgen(js_name = "deriveKey")] -pub fn derive_key(context: &str, key_material: &[u8]) -> Vec { - blake3::derive_key(context, key_material).to_vec() +pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; 32] { + blake3::derive_key(context, key_material) } - -/// Incremental BLAKE3 hasher for streaming data. -/// -/// ```js -/// const hasher = new Hasher(); -/// hasher.update(chunk1); -/// hasher.update(chunk2); -/// const digest = hasher.finalize(); // 32 bytes -/// ``` -#[wasm_bindgen] pub struct Hasher(blake3::Hasher); -#[wasm_bindgen] impl Hasher { /// Create a new hasher for unkeyed hashing. - #[wasm_bindgen(constructor)] pub fn new() -> Self { Hasher(blake3::Hasher::new()) } /// Create a keyed hasher (MAC mode). Key must be exactly 32 bytes. /// Throws if the key length is wrong. - #[wasm_bindgen(js_name = "newKeyed")] - pub fn new_keyed(key: &[u8]) -> Result { + pub fn new_keyed(key: &[u8]) -> Result { let key: &[u8; 32] = key .try_into() - .map_err(|_| JsError::new("key must be exactly 32 bytes"))?; + .map_err(|_| err("key must be exactly 32 bytes"))?; Ok(Hasher(blake3::Hasher::new_keyed(key))) } @@ -91,8 +89,7 @@ impl Hasher { /// Create a hasher in derive-key mode. /// Context should be a hardcoded, globally unique, application-specific string. /// Feed key material via `update()`, then call `finalize()`. - #[wasm_bindgen(js_name = "newDeriveKey")] - pub fn new_derive_key(context: &str) -> Hasher { + pub fn new_derive_key(context: &str) -> Self { Hasher(blake3::Hasher::new_derive_key(context)) } @@ -102,15 +99,15 @@ impl Hasher { } /// Return the 32-byte hash digest. Non-destructive. Can be called multiple times. - pub fn finalize(&self) -> Vec { - self.0.finalize().as_bytes().to_vec() + pub fn finalize(&self) -> [u8; 32] { + *self.0.finalize().as_bytes() } /// Return `out_len` bytes of extended output (XOF mode). Non-destructive. - #[wasm_bindgen(js_name = "finalizeXof")] - pub fn finalize_xof(&self, out_len: usize) -> Result, JsError> { + pub fn finalize_xof(&self, out_len: usize) -> Result, Blake3Error> { let out_len = checked_xof_len(out_len)?; let mut out = vec![0u8; out_len]; + self.0.finalize_xof().fill(&mut out); Ok(out) @@ -118,9 +115,9 @@ impl Hasher { /// Finalize the hash and reset the hasher in one call. /// Useful for hashing multiple inputs sequentially without creating new instances. - #[wasm_bindgen(js_name = "finalizeAndReset")] - pub fn finalize_and_reset(&mut self) -> Vec { - let out = self.0.finalize().as_bytes().to_vec(); + pub fn finalize_and_reset(&mut self) -> [u8; 32] { + let out = *self.0.finalize().as_bytes(); + self.0.reset(); out @@ -132,17 +129,123 @@ impl Hasher { } } +impl Default for Hasher { + fn default() -> Self { + Self::new() + } +} + +#[cfg(feature = "wasm")] +mod wasm { + use wasm_bindgen::prelude::*; + + #[wasm_bindgen] + pub fn hash(value: &[u8]) -> Vec { + super::hash(value).to_vec() + } + + #[wasm_bindgen(js_name = "hashXof")] + pub fn hash_xof(data: &[u8], out_len: usize) -> Result, JsError> { + super::hash_xof(data, out_len).map_err(|e| JsError::new(&e.0)) + } + + #[wasm_bindgen(js_name = "keyedHash")] + pub fn keyed_hash(data: &[u8], key: &[u8]) -> Result, JsError> { + super::keyed_hash(data, key) + .map(|h| h.to_vec()) + .map_err(|e| JsError::new(&e.0)) + } + + #[wasm_bindgen(js_name = "deriveKey")] + pub fn derive_key(context: &str, key_material: &[u8]) -> Vec { + super::derive_key(context, key_material).to_vec() + } + + /// Incremental BLAKE3 hasher for streaming data. + /// + /// ```js + /// const hasher = new Hasher(); + /// hasher.update(chunk1); + /// hasher.update(chunk2); + /// const digest = hasher.finalize(); // 32 bytes + /// ``` + #[wasm_bindgen] + pub struct Hasher(blake3::Hasher); + + #[wasm_bindgen] + impl Hasher { + /// Create a new hasher for unkeyed hashing. + #[wasm_bindgen(constructor)] + pub fn new() -> Self { + Hasher(blake3::Hasher::new()) + } + + /// Create a keyed hasher (MAC mode). Key must be exactly 32 bytes. + /// Throws if the key length is wrong. + #[wasm_bindgen(js_name = "newKeyed")] + pub fn new_keyed(key: &[u8]) -> Result { + let key: &[u8; 32] = key + .try_into() + .map_err(|_| JsError::new("key must be exactly 32 bytes"))?; + + Ok(Hasher(blake3::Hasher::new_keyed(key))) + } + + /// Create a hasher in derive-key mode. + /// Context should be a hardcoded, globally unique, application-specific string. + /// Feed key material via `update()`, then call `finalize()`. + #[wasm_bindgen(js_name = "newDeriveKey")] + pub fn new_derive_key(context: &str) -> Self { + Hasher(blake3::Hasher::new_derive_key(context)) + } + + /// Feed data into the hasher. Can be called multiple times for streaming. + pub fn update(&mut self, data: &[u8]) { + self.0.update(data); + } + + /// Return the 32-byte hash digest. Non-destructive. Can be called multiple times. + pub fn finalize(&self) -> Vec { + self.0.finalize().as_bytes().to_vec() + } + + /// Return `out_len` bytes of extended output (XOF mode). Non-destructive. + #[wasm_bindgen(js_name = "finalizeXof")] + pub fn finalize_xof(&self, out_len: usize) -> Result, JsError> { + let len = super::checked_xof_len(out_len).map_err(|e| JsError::new(&e.0))?; + let mut out = vec![0u8; len]; + + self.0.finalize_xof().fill(&mut out); + + Ok(out) + } + + /// Finalize the hash and reset the hasher in one call. + /// Useful for hashing multiple inputs sequentially without creating new instances. + #[wasm_bindgen(js_name = "finalizeAndReset")] + pub fn finalize_and_reset(&mut self) -> Vec { + let out = self.0.finalize().as_bytes().to_vec(); + + self.0.reset(); + + out + } + + /// Reset the hasher to its initial state. Preserves the mode (keyed/derive-key). + pub fn reset(&mut self) { + self.0.reset(); + } + } +} + #[cfg(test)] mod tests { use super::*; #[test] fn test_hash_deterministic() { - let a = hash(b"hello"); - let b = hash(b"hello"); - assert_eq!(a, b); + assert_eq!(hash(b"hello"), hash(b"hello")); } - #[test] fn test_hash_length() { assert_eq!(hash(b"hello").len(), 32); @@ -160,6 +263,11 @@ mod tests { assert!(keyed_hash(b"hello", &key).is_ok()); } + #[test] + fn test_keyed_hash_bad_key() { + assert!(keyed_hash(b"hello", &[0u8; 16]).is_err()); + } + #[test] fn test_derive_key() { let a = derive_key("ctx", b"material"); @@ -177,6 +285,7 @@ mod tests { let mut h = Hasher::new(); h.update(b"hello"); h.update(b"world"); + assert_eq!(h.finalize(), oneshot); } @@ -185,6 +294,7 @@ mod tests { let oneshot = derive_key("my-ctx", b"material"); let mut h = Hasher::new_derive_key("my-ctx"); h.update(b"material"); + assert_eq!(h.finalize(), oneshot); } @@ -209,6 +319,7 @@ mod tests { let mut h = Hasher::new_keyed(&key).unwrap(); h.update(b"hello "); h.update(b"world"); + assert_eq!(h.finalize(), oneshot); } @@ -217,6 +328,7 @@ mod tests { let oneshot = hash_xof(b"hello", 64).unwrap(); let mut h = Hasher::new(); h.update(b"hello"); + assert_eq!(h.finalize_xof(64).unwrap(), oneshot); } @@ -225,6 +337,7 @@ mod tests { // First 32 bytes of XOF output should equal the standard hash let standard = hash(b"test data"); let xof = hash_xof(b"test data", 64).unwrap(); + assert_eq!(&xof[..32], standard.as_slice()); } @@ -234,13 +347,30 @@ mod tests { h.update(b"junk"); h.reset(); h.update(b"hello"); + assert_eq!(h.finalize(), hash(b"hello")); } + #[test] + fn test_hasher_keyed_bad_key() { + assert!(Hasher::new_keyed(&[0u8; 10]).is_err()); + } + + #[test] + fn test_hash_xof_zero_len() { + assert!(hash_xof(b"hello", 0).is_err()); + } + + #[test] + fn test_hash_xof_over_max() { + assert!(hash_xof(b"hello", 65_537).is_err()); + } + #[test] fn test_hasher_keyed() { let key = [1u8; 32]; let h = Hasher::new_keyed(&key).unwrap(); + assert_ne!(h.finalize(), hash(b"").as_slice()); } @@ -254,49 +384,58 @@ mod tests { #[test] fn test_vector_empty() { - let out = hash(&[]); let expected = hex::decode("af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262") .unwrap(); - assert_eq!(out, expected); - } + assert_eq!(hash(&[]).as_slice(), expected.as_slice()); + } #[test] fn test_vector_1_byte() { - let input = test_input(1); - let out = hash(&input); let expected = hex::decode("2d3adedff11b61f14c886e35afa036736dcd87a74d27b5c1510225d0f592e213") .unwrap(); - assert_eq!(out, expected); + + assert_eq!(hash(&test_input(1)).as_slice(), expected.as_slice()); } #[test] fn test_vector_1025_bytes() { - let input = test_input(1025); - let out = hash(&input); let expected = hex::decode("d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444") .unwrap(); - assert_eq!(out, expected); + + assert_eq!(hash(&test_input(1025)).as_slice(), expected.as_slice()); } #[test] fn test_vector_keyed_empty() { let key = b"whats the Elvish word for friend"; - let out = keyed_hash(&[], key).unwrap(); let expected = hex::decode("92b2b75604ed3c761f9d6f62392c8a9227ad0ea3f09573e783f1498a4ed60d26") .unwrap(); - assert_eq!(out, expected); + + assert_eq!( + keyed_hash(&[], key).unwrap().as_slice(), + expected.as_slice() + ); } } #[cfg(all(target_arch = "wasm32", test))] mod wasm_tests { - use super::*; + use super::wasm::*; use wasm_bindgen_test::*; + #[wasm_bindgen_test] + fn test_hash_roundtrip() { + let a = hash(b"hello"); + let b = hash(b"hello"); + + assert_eq!(a, b); + assert_eq!(a.len(), 32); + } + #[wasm_bindgen_test] fn test_keyed_hash_bad_key() { assert!(keyed_hash(b"hello", &[0u8; 16]).is_err()); @@ -328,4 +467,4 @@ mod wasm_tests { let h = Hasher::new(); assert!(h.finalize_xof(65_537).is_err()); } -} \ No newline at end of file +}