diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index d9d870f..7228ade 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,7 +25,7 @@ jobs: run: wasm-pack test --node - name: Build package - run: bash build.sh + run: make - name: Publish to npm run: | diff --git a/Cargo.lock b/Cargo.lock index 7b755f3..3b092ed 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -47,9 +47,10 @@ dependencies = [ [[package]] name = "blake3-wasm-rs" -version = "0.1.2" +version = "0.1.3" dependencies = [ "blake3", + "hex", "wasm-bindgen", "wasm-bindgen-test", ] @@ -127,6 +128,12 @@ dependencies = [ "slab", ] +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + [[package]] name = "itoa" version = "1.0.18" diff --git a/Cargo.toml b/Cargo.toml index 46e28f5..11bce0a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "blake3-wasm-rs" -version = "0.1.2" +version = "0.1.3" edition = "2024" license = "MIT" authors = ["UneBaguette "] @@ -18,4 +18,5 @@ blake3 = { version = "1.8", features = ["wasm32_simd"] } wasm-bindgen = "0.2" [dev-dependencies] -wasm-bindgen-test = "0.3" \ No newline at end of file +wasm-bindgen-test = "0.3" +hex = "0.4" \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d5b71ab --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +ROOT := pkg +CRATE := blake3_wasm_rs +VERSION := 0.3.0 + +.PHONY: all clean build bundler node web node-esm package verify + +all: clean build package + +clean: + rm -rf $(ROOT) + +build: bundler node web node-esm + +bundler: + @echo "Building bundler target..." + wasm-pack build --target bundler -d $(ROOT)/bundler + +node: + @echo "Building nodejs target..." + wasm-pack build --target nodejs -d $(ROOT)/node + +web: + @echo "Building web target..." + wasm-pack build --target web -d $(ROOT)/web + +node-esm: node + @echo "Generating node-esm wrapper..." + @mkdir -p $(ROOT)/node-esm + @node -e "\ + const fs = require('fs'); \ + const dts = fs.readFileSync('$(ROOT)/node/$(CRATE).d.ts', 'utf8'); \ + const fns = [...dts.matchAll(/export function (\w+)/g)].map(m => m[1]); \ + const cls = [...dts.matchAll(/export class (\w+)/g)].map(m => m[1]); \ + const lines = [ \ + \"import { createRequire } from 'module';\", \ + \"const require = createRequire(import.meta.url);\", \ + \"const mod = require('../node/$(CRATE).js');\", \ + \"export default mod;\", \ + ...fns.map(n => 'export const ' + n + ' = mod.' + n + ';'), \ + ...cls.map(n => 'export const ' + n + ' = mod.' + n + ';'), \ + ]; \ + fs.writeFileSync('$(ROOT)/node-esm/index.mjs', lines.join('\n') + '\n'); \ + " + + +package: build + @cp README.md $(ROOT)/README.md + @rm -f $(ROOT)/bundler/package.json $(ROOT)/node/package.json $(ROOT)/web/package.json + @rm -f $(ROOT)/bundler/.gitignore $(ROOT)/node/.gitignore $(ROOT)/web/.gitignore + @node -e "\ + const pkg = { \ + name: 'blake3-wasm-rs', \ + version: '$(VERSION)', \ + description: 'BLAKE3 hashing via Rust/WASM - works in Node.js (CJS + ESM), browsers, and bundlers', \ + license: 'MIT', \ + repository: { \ + type: 'git', \ + url: 'https://github.com/UneBaguette/blake3.wasm', \ + }, \ + exports: { \ + '.': { \ + node: { \ + require: './node/$(CRATE).js', \ + import: './node-esm/index.mjs', \ + }, \ + import: './bundler/$(CRATE).js', \ + default: './web/$(CRATE).js', \ + }, \ + }, \ + types: './bundler/$(CRATE).d.ts', \ + files: ['node/', 'node-esm/', 'bundler/', 'web/', 'README.md'], \ + keywords: ['blake3', 'wasm', 'hash', 'cryptography', 'wasm-bindgen'], \ + }; \ + require('fs').writeFileSync('$(ROOT)/package.json', JSON.stringify(pkg, null, 2) + '\n'); \ + " + @echo "" + @echo "Done! Package ready in $(ROOT)/" + @echo " node (CJS): $(ROOT)/node/" + @echo " node (ESM): $(ROOT)/node-esm/" + @echo " bundler: $(ROOT)/bundler/" + @echo " web: $(ROOT)/web/" + +verify: package + cd $(ROOT) && npm pack --dry-run + diff --git a/build.sh b/build.sh deleted file mode 100644 index 078a477..0000000 --- a/build.sh +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT="pkg" -rm -rf "$ROOT" - -echo "Building bundler target..." -wasm-pack build --target bundler -d "$ROOT/bundler" - -echo "Building nodejs target..." -wasm-pack build --target nodejs -d "$ROOT/node" - -echo "Building web target..." -wasm-pack build --target web -d "$ROOT/web" - -# Generate node-esm wrapper from the nodejs build exports -echo "Generating node-esm wrapper..." -mkdir -p "$ROOT/node-esm" - -cp README.md "$ROOT/README.md" - -# Extract export names from the nodejs .js file -EXPORTS=$(grep -oP '(?<=module\.exports\.)\w+' "$ROOT/node/blake3_wasm_rs.js" | sort -u || true) - -# Fallback: parse from .d.ts if module.exports pattern not found -if [ -z "$EXPORTS" ]; then - EXPORTS=$(grep -oP '(?<=export function )\w+' "$ROOT/node/blake3_wasm_rs.d.ts" | sort -u || true) -fi - -# Also check for exported classes -CLASSES=$(grep -oP '(?<=export class )\w+' "$ROOT/node/blake3_wasm_rs.d.ts" | sort -u || true) - -{ - echo "import { createRequire } from 'module';" - echo "const require = createRequire(import.meta.url);" - echo "const mod = require('../node/blake3_wasm_rs.js');" - echo "export default mod;" - - for name in $EXPORTS; do - echo "export const $name = mod.$name;" - done - - for name in $CLASSES; do - echo "export const $name = mod.$name;" - done -} > "$ROOT/node-esm/index.mjs" - -# Clean up wasm-pack generated package.json in each subfolder -rm -f "$ROOT/bundler/package.json" "$ROOT/node/package.json" "$ROOT/web/package.json" -rm -f "$ROOT/bundler/.gitignore" "$ROOT/node/.gitignore" "$ROOT/web/.gitignore" - -# Generate root package.json -cat > "$ROOT/package.json" << 'EOF' -{ - "name": "blake3-wasm-rs", - "version": "0.2.2", - "description": "BLAKE3 hashing via Rust/WASM - works in Node.js (CJS + ESM), browsers, and bundlers", - "license": "MIT", - "repository": { - "type": "git", - "url": "https://github.com/UneBaguette/blake3.wasm" - }, - "exports": { - ".": { - "node": { - "require": "./node/blake3_wasm_rs.js", - "import": "./node-esm/index.mjs" - }, - "import": "./bundler/blake3_wasm_rs.js", - "default": "./web/blake3_wasm_rs.js" - } - }, - "types": "./bundler/blake3_wasm_rs.d.ts", - "files": [ - "node/", - "node-esm/", - "bundler/", - "web/", - "README.md" - ], - "keywords": [ - "blake3", - "wasm", - "hash", - "cryptography", - "wasm-bindgen" - ] -} -EOF - -echo "" -echo "Done! Package ready in $ROOT/" -echo " node (CJS): $ROOT/node/" -echo " node (ESM): $ROOT/node-esm/" -echo " bundler: $ROOT/bundler/" -echo " web: $ROOT/web/" -echo "" -echo "Verify with: cd $ROOT && npm pack --dry-run" \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index 037d27a..59cd17e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,10 +1,13 @@ use wasm_bindgen::prelude::*; +/// Hash data and return a 32-byte BLAKE3 digest. #[wasm_bindgen] pub fn hash(value: &[u8]) -> Vec { blake3::hash(value).as_bytes().to_vec() } +/// Hash data with variable-length output (XOF mode). +/// Returns `out_len` bytes of BLAKE3 extended output. #[wasm_bindgen] pub fn hash_xof(data: &[u8], out_len: usize) -> Vec { let mut out = vec![0u8; out_len]; @@ -15,50 +18,88 @@ pub fn hash_xof(data: &[u8], out_len: usize) -> Vec { out } +/// Compute a keyed BLAKE3 hash (MAC). Key must be exactly 32 bytes. +/// Throws if the key length is wrong. #[wasm_bindgen] pub fn keyed_hash(data: &[u8], key: &[u8]) -> Result, JsError> { let key: &[u8; 32] = key .try_into() .map_err(|_| JsError::new("key must be exactly 32 bytes"))?; + Ok(blake3::keyed_hash(key, data).as_bytes().to_vec()) } +/// Derive a 32-byte key from a context string and key material. +/// Context should be a hardcoded, globally unique, application-specific string. #[wasm_bindgen] pub fn derive_key(context: &str, key_material: &[u8]) -> Vec { blake3::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. 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()`. + pub fn new_derive_key(context: &str) -> Hasher { + 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. pub fn finalize_xof(&self, out_len: usize) -> Vec { let mut out = vec![0u8; out_len]; self.0.finalize_xof().fill(&mut out); + out } + /// Finalize the hash and reset the hasher in one call. + /// Useful for hashing multiple inputs sequentially without creating new instances. + 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(); } @@ -112,6 +153,54 @@ mod tests { assert_eq!(h.finalize(), oneshot); } + #[test] + fn test_hasher_derive_key() { + 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); + } + + #[test] + fn test_hasher_finalize_and_reset() { + let mut h = Hasher::new(); + h.update(b"hello"); + let first = h.finalize_and_reset(); + assert_eq!(first, hash(b"hello")); + + // After reset, hashing different data should produce different output + h.update(b"world"); + let second = h.finalize(); + assert_eq!(second, hash(b"world")); + assert_ne!(first, second); + } + + #[test] + fn test_keyed_hash_matches_streaming() { + let key = [42u8; 32]; + let oneshot = keyed_hash(b"hello world", &key).unwrap(); + let mut h = Hasher::new_keyed(&key).unwrap(); + h.update(b"hello "); + h.update(b"world"); + assert_eq!(h.finalize(), oneshot); + } + + #[test] + fn test_hasher_finalize_xof() { + let oneshot = hash_xof(b"hello", 64); + let mut h = Hasher::new(); + h.update(b"hello"); + assert_eq!(h.finalize_xof(64), oneshot); + } + + #[test] + fn test_hasher_finalize_xof_prefix_matches_hash() { + // 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); + assert_eq!(&xof[..32], standard.as_slice()); + } + #[test] fn test_hasher_reset() { let mut h = Hasher::new(); @@ -127,6 +216,53 @@ mod tests { let h = Hasher::new_keyed(&key).unwrap(); assert_ne!(h.finalize(), hash(b"").as_slice()); } + + // BLAKE3 official test vectors (from BLAKE3 spec) + // Reference: https://github.com/BLAKE3-team/BLAKE3/blob/master/test_vectors/test_vectors.json + // Input: 0x00..0xfa repeating pattern, first N bytes + + fn test_input(len: usize) -> Vec { + (0..len).map(|i| (i % 251) as u8).collect() + } + + #[test] + fn test_vector_empty() { + let out = hash(&[]); + let expected = + hex::decode("af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262") + .unwrap(); + assert_eq!(out, expected); + } + + #[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); + } + + #[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); + } + + #[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); + } } #[cfg(all(target_arch = "wasm32", test))] @@ -143,4 +279,4 @@ mod wasm_tests { fn test_hasher_keyed_bad_key() { assert!(Hasher::new_keyed(&[0u8; 10]).is_err()); } -} \ No newline at end of file +}