diff --git a/.gitignore b/.gitignore index 955ab12..d580aa0 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,11 @@ node_modules/ build/ TODO +# Rust/WASM build artifacts +crates/*/target/ +src/crypto/wasm/package.json +src/crypto/wasm/*.wasm.d.ts + # Secrets - NEVER commit these .env .env.* diff --git a/crates/salvium-crypto/Cargo.lock b/crates/salvium-crypto/Cargo.lock new file mode 100644 index 0000000..7bb2d66 --- /dev/null +++ b/crates/salvium-crypto/Cargo.lock @@ -0,0 +1,160 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "blake2b_simd" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b79834656f71332577234b50bfc009996f7449e0c056884e6a02492ded0ca2f3" +dependencies = [ + "arrayref", + "arrayvec", + "constant_time_eq", +] + +[[package]] +name = "bumpalo" +version = "3.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "salvium-crypto" +version = "0.1.0" +dependencies = [ + "blake2b_simd", + "tiny-keccak", + "wasm-bindgen", +] + +[[package]] +name = "syn" +version = "2.0.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "unicode-ident" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" + +[[package]] +name = "wasm-bindgen" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.108" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +dependencies = [ + "unicode-ident", +] diff --git a/crates/salvium-crypto/Cargo.toml b/crates/salvium-crypto/Cargo.toml new file mode 100644 index 0000000..35fc4d9 --- /dev/null +++ b/crates/salvium-crypto/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "salvium-crypto" +version = "0.1.0" +edition = "2021" +description = "WASM crypto primitives for salvium-js" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +tiny-keccak = { version = "2.0", features = ["keccak"] } +blake2b_simd = "1.0" +wasm-bindgen = "0.2" + +[profile.release] +opt-level = 3 +lto = true +codegen-units = 1 diff --git a/crates/salvium-crypto/src/lib.rs b/crates/salvium-crypto/src/lib.rs new file mode 100644 index 0000000..58c8ca8 --- /dev/null +++ b/crates/salvium-crypto/src/lib.rs @@ -0,0 +1,37 @@ +use wasm_bindgen::prelude::*; +use tiny_keccak::{Hasher, Keccak}; + +/// Keccak-256 hash (CryptoNote variant with 0x01 padding, NOT SHA3) +/// Matches Salvium C++ cn_fast_hash / keccak() +#[wasm_bindgen] +pub fn keccak256(data: &[u8]) -> Vec { + let mut keccak = Keccak::v256(); + let mut output = [0u8; 32]; + keccak.update(data); + keccak.finalize(&mut output); + output.to_vec() +} + +/// Blake2b with variable output length (unkeyed) +/// Matches Salvium C++ blake2b(out, outLen, data, dataLen, NULL, 0) +#[wasm_bindgen] +pub fn blake2b_hash(data: &[u8], out_len: usize) -> Vec { + blake2b_simd::Params::new() + .hash_length(out_len) + .hash(data) + .as_bytes() + .to_vec() +} + +/// Blake2b with key (keyed variant per RFC 7693) +/// Matches Salvium C++ blake2b(out, outLen, data, dataLen, key, keyLen) +/// Used by CARROT protocol for domain-separated hashing +#[wasm_bindgen] +pub fn blake2b_keyed(data: &[u8], out_len: usize, key: &[u8]) -> Vec { + blake2b_simd::Params::new() + .hash_length(out_len) + .key(key) + .hash(data) + .as_bytes() + .to_vec() +} diff --git a/package.json b/package.json index 4db63b4..3922ebd 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,8 @@ }, "scripts": { "test": "node test/run.js", - "build:wasm": "asc assembly/index.ts --outFile build/randomx.wasm --optimize --enable simd --initialMemory 16 --maximumMemory 8192" + "build:wasm": "asc assembly/index.ts --outFile build/randomx.wasm --optimize --enable simd --initialMemory 16 --maximumMemory 8192", + "build:wasm-crypto": "cd crates/salvium-crypto && PATH=\"$HOME/.cargo/bin:$HOME/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/bin:$PATH\" RUSTUP_HOME=\"$HOME/.rustup\" CARGO_HOME=\"$HOME/.cargo\" wasm-pack build --target web --out-dir ../../src/crypto/wasm" }, "files": [ "src/", diff --git a/src/crypto/backend-js.js b/src/crypto/backend-js.js new file mode 100644 index 0000000..d60b18b --- /dev/null +++ b/src/crypto/backend-js.js @@ -0,0 +1,29 @@ +/** + * JavaScript Crypto Backend + * + * Wraps existing pure-JS implementations behind the unified backend interface. + * All existing code remains untouched — this is just a thin adapter. + * + * @module crypto/backend-js + */ + +import { keccak256 as jsKeccak } from '../keccak.js'; +import { blake2b as jsBlake2b } from '../blake2b.js'; + +export class JsCryptoBackend { + constructor() { + this.name = 'js'; + } + + async init() { + // No initialization needed for JS backend + } + + keccak256(data) { + return jsKeccak(data); + } + + blake2b(data, outLen, key) { + return jsBlake2b(data, outLen, key); + } +} diff --git a/src/crypto/backend-wasm.js b/src/crypto/backend-wasm.js new file mode 100644 index 0000000..a25df27 --- /dev/null +++ b/src/crypto/backend-wasm.js @@ -0,0 +1,59 @@ +/** + * WASM Crypto Backend + * + * Loads Rust-compiled WASM module and wraps it behind the unified backend interface. + * Falls back gracefully if WASM cannot be loaded. + * + * @module crypto/backend-wasm + */ + +import { readFile } from 'fs/promises'; +import { fileURLToPath } from 'url'; +import { dirname, join } from 'path'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +let wasmExports = null; + +/** + * Load and instantiate the WASM module from disk + */ +async function loadWasm() { + if (wasmExports) return wasmExports; + + const wasmPath = join(__dirname, 'wasm', 'salvium_crypto_bg.wasm'); + const wasmBytes = await readFile(wasmPath); + + // Import the JS glue to get the import object and init function + const glue = await import('./wasm/salvium_crypto.js'); + + // Use initSync with the raw WASM bytes (works in Bun/Node, no fetch needed) + glue.initSync({ module: wasmBytes }); + wasmExports = glue; + return wasmExports; +} + +export class WasmCryptoBackend { + constructor() { + this.name = 'wasm'; + this.wasm = null; + } + + async init() { + this.wasm = await loadWasm(); + } + + keccak256(data) { + if (!this.wasm) throw new Error('WASM backend not initialized. Call init() first.'); + return this.wasm.keccak256(data); + } + + blake2b(data, outLen, key) { + if (!this.wasm) throw new Error('WASM backend not initialized. Call init() first.'); + if (key) { + return this.wasm.blake2b_keyed(data, outLen, key); + } + return this.wasm.blake2b_hash(data, outLen); + } +} diff --git a/src/crypto/index.js b/src/crypto/index.js new file mode 100644 index 0000000..fcf01b6 --- /dev/null +++ b/src/crypto/index.js @@ -0,0 +1,20 @@ +/** + * Crypto Module — Public API + * + * Exports the provider (for backend switching) and both backends + * for direct access when needed. + * + * @module crypto + */ + +// Provider (default usage — delegates to active backend) +export { + setCryptoBackend, + getCryptoBackend, + getCurrentBackendType, + keccak256, + blake2b, +} from './provider.js'; + +// Backends (for direct access / testing) +export { JsCryptoBackend } from './backend-js.js'; diff --git a/src/crypto/provider.js b/src/crypto/provider.js new file mode 100644 index 0000000..09de874 --- /dev/null +++ b/src/crypto/provider.js @@ -0,0 +1,70 @@ +/** + * Crypto Provider — Switchable Backend + * + * Enables runtime switching between JS and WASM crypto implementations. + * Default: JS (no async init needed). Switch to WASM for performance. + * + * Usage: + * import { setCryptoBackend, keccak256, blake2b } from './crypto/provider.js'; + * await setCryptoBackend('wasm'); // Switch to WASM + * const hash = keccak256(data); // Uses WASM backend + * await setCryptoBackend('js'); // Switch back to JS + * + * @module crypto/provider + */ + +import { JsCryptoBackend } from './backend-js.js'; + +let currentBackend = null; +let backendType = 'js'; + +/** + * Set the active crypto backend + * @param {'js'|'wasm'} type - Backend type + */ +export async function setCryptoBackend(type) { + if (type === 'js') { + currentBackend = new JsCryptoBackend(); + await currentBackend.init(); + } else if (type === 'wasm') { + // Dynamic import to avoid loading WASM unless requested + const { WasmCryptoBackend } = await import('./backend-wasm.js'); + currentBackend = new WasmCryptoBackend(); + await currentBackend.init(); + } else { + throw new Error(`Unknown crypto backend: ${type}. Use 'js' or 'wasm'.`); + } + backendType = type; +} + +/** + * Get the current crypto backend instance + * @returns {Object} Backend with keccak256, blake2b, etc. + */ +export function getCryptoBackend() { + if (!currentBackend) { + // Lazy-init JS backend (sync, no await needed) + currentBackend = new JsCryptoBackend(); + } + return currentBackend; +} + +/** + * Get the name of the current backend + * @returns {'js'|'wasm'} + */ +export function getCurrentBackendType() { + return backendType; +} + +// ============================================================================= +// Delegating functions — use active backend transparently +// ============================================================================= + +export function keccak256(data) { + return getCryptoBackend().keccak256(data); +} + +export function blake2b(data, outLen, key) { + return getCryptoBackend().blake2b(data, outLen, key); +} diff --git a/test/crypto-provider.test.js b/test/crypto-provider.test.js new file mode 100644 index 0000000..bd17918 --- /dev/null +++ b/test/crypto-provider.test.js @@ -0,0 +1,205 @@ +/** + * Crypto Provider Tests — JS vs WASM Equivalence + Benchmark + * + * Verifies byte-for-byte equivalence between JS and WASM crypto backends, + * and benchmarks relative performance. + */ + +import { + setCryptoBackend, + getCryptoBackend, + getCurrentBackendType, + keccak256, + blake2b, +} from '../src/crypto/index.js'; +import { JsCryptoBackend } from '../src/crypto/backend-js.js'; +import { hexToBytes, bytesToHex } from '../src/index.js'; + +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +async function asyncTest(name, fn) { + try { + await fn(); + console.log(` ✓ ${name}`); + passed++; + } catch (error) { + console.log(` ✗ ${name}`); + console.log(` Error: ${error.message}`); + failed++; + } +} + +function assertEqual(a, b, msg) { + const aHex = a instanceof Uint8Array ? bytesToHex(a) : String(a); + const bHex = b instanceof Uint8Array ? bytesToHex(b) : String(b); + if (aHex !== bHex) { + throw new Error(`${msg || 'Assertion failed'}: ${aHex} !== ${bHex}`); + } +} + +// ─── Test vectors ─────────────────────────────────────────────────────────── + +const testInputs = [ + new Uint8Array(0), // empty + new Uint8Array([0x61, 0x62, 0x63]), // "abc" + new Uint8Array(32).fill(0xff), // 32 bytes of 0xff + crypto.getRandomValues(new Uint8Array(1024)), // 1KB random +]; + +// Known Keccak-256 vector: keccak256("") with CryptoNote 0x01 padding +// From: https://emn178.github.io/online-tools/keccak_256.html +const KECCAK_EMPTY = 'c5d2460186f7233c927e7db2dcc703c0e500b653ca82273b7bfad8045d85a470'; + +// ─── Provider tests ───────────────────────────────────────────────────────── + +console.log('\n=== Crypto Provider ===\n'); + +test('default backend is JS', () => { + assertEqual(getCurrentBackendType(), 'js'); +}); + +test('getCryptoBackend returns JsCryptoBackend by default', () => { + const backend = getCryptoBackend(); + if (backend.name !== 'js') throw new Error(`Expected js, got ${backend.name}`); +}); + +await asyncTest('setCryptoBackend("js") works', async () => { + await setCryptoBackend('js'); + assertEqual(getCurrentBackendType(), 'js'); +}); + +await asyncTest('setCryptoBackend("wasm") works', async () => { + await setCryptoBackend('wasm'); + assertEqual(getCurrentBackendType(), 'wasm'); + await setCryptoBackend('js'); // reset +}); + +// ─── Keccak-256 equivalence ───────────────────────────────────────────────── + +console.log('\n=== Keccak-256 Equivalence ===\n'); + +test('JS keccak256 empty matches known vector', () => { + const js = new JsCryptoBackend(); + const result = js.keccak256(new Uint8Array(0)); + assertEqual(result, hexToBytes(KECCAK_EMPTY)); +}); + +await asyncTest('WASM keccak256 empty matches known vector', async () => { + await setCryptoBackend('wasm'); + const result = keccak256(new Uint8Array(0)); + assertEqual(result, hexToBytes(KECCAK_EMPTY)); + await setCryptoBackend('js'); +}); + +for (let i = 0; i < testInputs.length; i++) { + await asyncTest(`keccak256 equivalence: input[${i}] (${testInputs[i].length} bytes)`, async () => { + const js = new JsCryptoBackend(); + const jsResult = js.keccak256(testInputs[i]); + + await setCryptoBackend('wasm'); + const wasmResult = keccak256(testInputs[i]); + await setCryptoBackend('js'); + + assertEqual(jsResult, wasmResult, 'JS vs WASM mismatch'); + }); +} + +// ─── Blake2b equivalence ──────────────────────────────────────────────────── + +console.log('\n=== Blake2b Equivalence ===\n'); + +const blake2bOutLens = [32, 64]; + +for (const outLen of blake2bOutLens) { + for (let i = 0; i < testInputs.length; i++) { + await asyncTest(`blake2b(outLen=${outLen}) equivalence: input[${i}] (${testInputs[i].length} bytes)`, async () => { + const js = new JsCryptoBackend(); + const jsResult = js.blake2b(testInputs[i], outLen); + + await setCryptoBackend('wasm'); + const wasmResult = blake2b(testInputs[i], outLen); + await setCryptoBackend('js'); + + assertEqual(jsResult, wasmResult, 'JS vs WASM mismatch'); + }); + } +} + +// ─── Blake2b keyed equivalence ────────────────────────────────────────────── + +console.log('\n=== Blake2b Keyed Equivalence ===\n'); + +const testKey = new Uint8Array(32); +testKey.set([0x01, 0x02, 0x03, 0x04]); + +for (let i = 0; i < testInputs.length; i++) { + await asyncTest(`blake2b_keyed(outLen=32) equivalence: input[${i}] (${testInputs[i].length} bytes)`, async () => { + const js = new JsCryptoBackend(); + const jsResult = js.blake2b(testInputs[i], 32, testKey); + + await setCryptoBackend('wasm'); + const wasmResult = blake2b(testInputs[i], 32, testKey); + await setCryptoBackend('js'); + + assertEqual(jsResult, wasmResult, 'JS vs WASM mismatch'); + }); +} + +// ─── Benchmark ────────────────────────────────────────────────────────────── + +console.log('\n=== Benchmark (10,000 iterations) ===\n'); + +const benchData = new Uint8Array(256).fill(0x42); +const ITERATIONS = 10_000; + +// Keccak-256 benchmark +{ + await setCryptoBackend('js'); + const jsStart = performance.now(); + for (let i = 0; i < ITERATIONS; i++) keccak256(benchData); + const jsTime = performance.now() - jsStart; + + await setCryptoBackend('wasm'); + const wasmStart = performance.now(); + for (let i = 0; i < ITERATIONS; i++) keccak256(benchData); + const wasmTime = performance.now() - wasmStart; + + const speedup = (jsTime / wasmTime).toFixed(2); + console.log(` keccak256: JS ${jsTime.toFixed(1)}ms WASM ${wasmTime.toFixed(1)}ms (${speedup}x)`); +} + +// Blake2b benchmark +{ + await setCryptoBackend('js'); + const jsStart = performance.now(); + for (let i = 0; i < ITERATIONS; i++) blake2b(benchData, 32); + const jsTime = performance.now() - jsStart; + + await setCryptoBackend('wasm'); + const wasmStart = performance.now(); + for (let i = 0; i < ITERATIONS; i++) blake2b(benchData, 32); + const wasmTime = performance.now() - wasmStart; + + const speedup = (jsTime / wasmTime).toFixed(2); + console.log(` blake2b: JS ${jsTime.toFixed(1)}ms WASM ${wasmTime.toFixed(1)}ms (${speedup}x)`); +} + +await setCryptoBackend('js'); // reset + +// ─── Summary ──────────────────────────────────────────────────────────────── + +console.log(`\n${passed} passed, ${failed} failed\n`); +if (failed > 0) process.exit(1);