From 638171efe1955ce6d601696549fa46978fd1d4f5 Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Sat, 31 Jan 2026 22:36:10 +0000 Subject: [PATCH] Add Ed25519 scalar and point operations to WASM crypto backend (Phase 2) Adds 16 operations via curve25519-dalek: scAdd, scSub, scMul, scMulAdd, scMulSub, scReduce32, scReduce64, scInvert, scCheck, scIsZero, scalarMultBase, scalarMultPoint, pointAddCompressed, pointSubCompressed, pointNegate, doubleScalarMultBase. Uses variable-time Straus algorithm for point multiplication. All 44 equivalence tests pass byte-for-byte. Benchmarks: scMulAdd 3.5x, scalarMultBase 2.3x, pointAdd 16.7x faster. --- crates/salvium-crypto/Cargo.lock | 75 +++++++++++ crates/salvium-crypto/Cargo.toml | 1 + crates/salvium-crypto/src/lib.rs | 133 +++++++++++++++++++ src/crypto/backend-js.js | 34 +++++ src/crypto/backend-wasm.js | 20 +++ src/crypto/index.js | 4 + src/crypto/provider.js | 20 +++ test/crypto-provider.test.js | 214 +++++++++++++++++++++++++++++++ 8 files changed, 501 insertions(+) diff --git a/crates/salvium-crypto/Cargo.lock b/crates/salvium-crypto/Cargo.lock index 7bb2d66..7f2e3c6 100644 --- a/crates/salvium-crypto/Cargo.lock +++ b/crates/salvium-crypto/Cargo.lock @@ -43,12 +43,59 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + [[package]] name = "crunchy" version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + +[[package]] +name = "libc" +version = "0.2.180" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" + [[package]] name = "once_cell" version = "1.21.3" @@ -73,6 +120,15 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -84,10 +140,23 @@ name = "salvium-crypto" version = "0.1.0" dependencies = [ "blake2b_simd", + "curve25519-dalek", "tiny-keccak", "wasm-bindgen", ] +[[package]] +name = "semver" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "syn" version = "2.0.114" @@ -158,3 +227,9 @@ checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" dependencies = [ "unicode-ident", ] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" diff --git a/crates/salvium-crypto/Cargo.toml b/crates/salvium-crypto/Cargo.toml index 35fc4d9..b0f0b66 100644 --- a/crates/salvium-crypto/Cargo.toml +++ b/crates/salvium-crypto/Cargo.toml @@ -11,6 +11,7 @@ crate-type = ["cdylib"] tiny-keccak = { version = "2.0", features = ["keccak"] } blake2b_simd = "1.0" wasm-bindgen = "0.2" +curve25519-dalek = { version = "4", features = ["alloc"] } [profile.release] opt-level = 3 diff --git a/crates/salvium-crypto/src/lib.rs b/crates/salvium-crypto/src/lib.rs index 58c8ca8..4a3d594 100644 --- a/crates/salvium-crypto/src/lib.rs +++ b/crates/salvium-crypto/src/lib.rs @@ -1,5 +1,9 @@ use wasm_bindgen::prelude::*; use tiny_keccak::{Hasher, Keccak}; +use curve25519_dalek::scalar::Scalar; +use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint}; +use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; +use curve25519_dalek::traits::VartimeMultiscalarMul; /// Keccak-256 hash (CryptoNote variant with 0x01 padding, NOT SHA3) /// Matches Salvium C++ cn_fast_hash / keccak() @@ -35,3 +39,132 @@ pub fn blake2b_keyed(data: &[u8], out_len: usize, key: &[u8]) -> Vec { .as_bytes() .to_vec() } + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +fn to32(s: &[u8]) -> [u8; 32] { + let mut buf = [0u8; 32]; + let len = s.len().min(32); + buf[..len].copy_from_slice(&s[..len]); + buf +} + +fn to64(s: &[u8]) -> [u8; 64] { + let mut buf = [0u8; 64]; + let len = s.len().min(64); + buf[..len].copy_from_slice(&s[..len]); + buf +} + +// ─── Scalar Operations (mod L) ───────────────────────────────────────────── + +#[wasm_bindgen] +pub fn sc_add(a: &[u8], b: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + (sa + sb).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_sub(a: &[u8], b: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + (sa - sb).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_mul(a: &[u8], b: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + (sa * sb).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_mul_add(a: &[u8], b: &[u8], c: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + let sc = Scalar::from_bytes_mod_order(to32(c)); + (sa * sb + sc).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_mul_sub(a: &[u8], b: &[u8], c: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + let sc = Scalar::from_bytes_mod_order(to32(c)); + (sc - sa * sb).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_reduce32(s: &[u8]) -> Vec { + Scalar::from_bytes_mod_order(to32(s)).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_reduce64(s: &[u8]) -> Vec { + Scalar::from_bytes_mod_order_wide(&to64(s)).to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_invert(a: &[u8]) -> Vec { + Scalar::from_bytes_mod_order(to32(a)).invert().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn sc_check(s: &[u8]) -> bool { + bool::from(Scalar::from_canonical_bytes(to32(s)).is_some()) +} + +#[wasm_bindgen] +pub fn sc_is_zero(s: &[u8]) -> bool { + Scalar::from_bytes_mod_order(to32(s)) == Scalar::ZERO +} + +// ─── Point Operations (compressed Edwards) ────────────────────────────────── + +#[wasm_bindgen] +pub fn scalar_mult_base(s: &[u8]) -> Vec { + let scalar = Scalar::from_bytes_mod_order(to32(s)); + (ED25519_BASEPOINT_TABLE * &scalar).compress().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn scalar_mult_point(s: &[u8], p: &[u8]) -> Vec { + let scalar = Scalar::from_bytes_mod_order(to32(s)); + let point = CompressedEdwardsY(to32(p)).decompress().expect("invalid point"); + // Use variable-time Straus/wNAF — much faster than constant-time mul + EdwardsPoint::vartime_multiscalar_mul(&[scalar], &[point]) + .compress().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn point_add_compressed(p: &[u8], q: &[u8]) -> Vec { + let pp = CompressedEdwardsY(to32(p)).decompress().expect("invalid point p"); + let qq = CompressedEdwardsY(to32(q)).decompress().expect("invalid point q"); + (pp + qq).compress().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn point_sub_compressed(p: &[u8], q: &[u8]) -> Vec { + let pp = CompressedEdwardsY(to32(p)).decompress().expect("invalid point p"); + let qq = CompressedEdwardsY(to32(q)).decompress().expect("invalid point q"); + (pp - qq).compress().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn point_negate(p: &[u8]) -> Vec { + let pp = CompressedEdwardsY(to32(p)).decompress().expect("invalid point"); + (-pp).compress().to_bytes().to_vec() +} + +#[wasm_bindgen] +pub fn double_scalar_mult_base(a: &[u8], p: &[u8], b: &[u8]) -> Vec { + let sa = Scalar::from_bytes_mod_order(to32(a)); + let sb = Scalar::from_bytes_mod_order(to32(b)); + let pp = CompressedEdwardsY(to32(p)).decompress().expect("invalid point"); + // Variable-time multi-scalar: a*P + b*G + EdwardsPoint::vartime_multiscalar_mul( + &[sa, sb], + &[pp, curve25519_dalek::constants::ED25519_BASEPOINT_POINT], + ).compress().to_bytes().to_vec() +} diff --git a/src/crypto/backend-js.js b/src/crypto/backend-js.js index d60b18b..e215b40 100644 --- a/src/crypto/backend-js.js +++ b/src/crypto/backend-js.js @@ -9,6 +9,14 @@ import { keccak256 as jsKeccak } from '../keccak.js'; import { blake2b as jsBlake2b } from '../blake2b.js'; +import { + scAdd, scSub, scMul, scMulAdd, scMulSub, + scReduce32, scReduce64, scInvert, scCheck, scIsZero +} from '../transaction/serialization.js'; +import { + scalarMultBase, scalarMultPoint, pointAddCompressed, + pointSubCompressed, pointNegate, doubleScalarMultBase +} from '../ed25519.js'; export class JsCryptoBackend { constructor() { @@ -26,4 +34,30 @@ export class JsCryptoBackend { blake2b(data, outLen, key) { return jsBlake2b(data, outLen, key); } + + // Scalar ops + scAdd(a, b) { return scAdd(a, b); } + scSub(a, b) { return scSub(a, b); } + scMul(a, b) { return scMul(a, b); } + scMulAdd(a, b, c) { return scMulAdd(a, b, c); } + scMulSub(a, b, c) { return scMulSub(a, b, c); } + scReduce32(s) { return scReduce32(s); } + scReduce64(s) { return scReduce64(s); } + scInvert(a) { return scInvert(a); } + scCheck(s) { return scCheck(s); } + scIsZero(s) { return scIsZero(s); } + + // Point ops + scalarMultBase(s) { return scalarMultBase(s); } + scalarMultPoint(s, p) { return scalarMultPoint(s, p); } + pointAddCompressed(p, q) { return pointAddCompressed(p, q); } + pointSubCompressed(p, q) { return pointSubCompressed(p, q); } + pointNegate(p) { return pointNegate(p); } + doubleScalarMultBase(a, p, b) { + // JS doubleScalarMultBase expects decompressed point object, not bytes. + // Compose from primitives instead: a*P + b*G + const aP = scalarMultPoint(a, p); + const bG = scalarMultBase(b); + return pointAddCompressed(aP, bG); + } } diff --git a/src/crypto/backend-wasm.js b/src/crypto/backend-wasm.js index a25df27..35e7ec0 100644 --- a/src/crypto/backend-wasm.js +++ b/src/crypto/backend-wasm.js @@ -56,4 +56,24 @@ export class WasmCryptoBackend { } return this.wasm.blake2b_hash(data, outLen); } + + // Scalar ops + scAdd(a, b) { return this.wasm.sc_add(a, b); } + scSub(a, b) { return this.wasm.sc_sub(a, b); } + scMul(a, b) { return this.wasm.sc_mul(a, b); } + scMulAdd(a, b, c) { return this.wasm.sc_mul_add(a, b, c); } + scMulSub(a, b, c) { return this.wasm.sc_mul_sub(a, b, c); } + scReduce32(s) { return this.wasm.sc_reduce32(s); } + scReduce64(s) { return this.wasm.sc_reduce64(s); } + scInvert(a) { return this.wasm.sc_invert(a); } + scCheck(s) { return this.wasm.sc_check(s); } + scIsZero(s) { return this.wasm.sc_is_zero(s); } + + // Point ops + scalarMultBase(s) { return this.wasm.scalar_mult_base(s); } + scalarMultPoint(s, p) { return this.wasm.scalar_mult_point(s, p); } + pointAddCompressed(p, q) { return this.wasm.point_add_compressed(p, q); } + pointSubCompressed(p, q) { return this.wasm.point_sub_compressed(p, q); } + pointNegate(p) { return this.wasm.point_negate(p); } + doubleScalarMultBase(a, p, b) { return this.wasm.double_scalar_mult_base(a, p, b); } } diff --git a/src/crypto/index.js b/src/crypto/index.js index fcf01b6..6f9f706 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -14,6 +14,10 @@ export { getCurrentBackendType, keccak256, blake2b, + scAdd, scSub, scMul, scMulAdd, scMulSub, + scReduce32, scReduce64, scInvert, scCheck, scIsZero, + scalarMultBase, scalarMultPoint, pointAddCompressed, + pointSubCompressed, pointNegate, doubleScalarMultBase, } from './provider.js'; // Backends (for direct access / testing) diff --git a/src/crypto/provider.js b/src/crypto/provider.js index 09de874..a149a9d 100644 --- a/src/crypto/provider.js +++ b/src/crypto/provider.js @@ -68,3 +68,23 @@ export function keccak256(data) { export function blake2b(data, outLen, key) { return getCryptoBackend().blake2b(data, outLen, key); } + +// Scalar ops +export function scAdd(a, b) { return getCryptoBackend().scAdd(a, b); } +export function scSub(a, b) { return getCryptoBackend().scSub(a, b); } +export function scMul(a, b) { return getCryptoBackend().scMul(a, b); } +export function scMulAdd(a, b, c) { return getCryptoBackend().scMulAdd(a, b, c); } +export function scMulSub(a, b, c) { return getCryptoBackend().scMulSub(a, b, c); } +export function scReduce32(s) { return getCryptoBackend().scReduce32(s); } +export function scReduce64(s) { return getCryptoBackend().scReduce64(s); } +export function scInvert(a) { return getCryptoBackend().scInvert(a); } +export function scCheck(s) { return getCryptoBackend().scCheck(s); } +export function scIsZero(s) { return getCryptoBackend().scIsZero(s); } + +// Point ops +export function scalarMultBase(s) { return getCryptoBackend().scalarMultBase(s); } +export function scalarMultPoint(s, p) { return getCryptoBackend().scalarMultPoint(s, p); } +export function pointAddCompressed(p, q) { return getCryptoBackend().pointAddCompressed(p, q); } +export function pointSubCompressed(p, q) { return getCryptoBackend().pointSubCompressed(p, q); } +export function pointNegate(p) { return getCryptoBackend().pointNegate(p); } +export function doubleScalarMultBase(a, p, b) { return getCryptoBackend().doubleScalarMultBase(a, p, b); } diff --git a/test/crypto-provider.test.js b/test/crypto-provider.test.js index bd17918..8e0e3f0 100644 --- a/test/crypto-provider.test.js +++ b/test/crypto-provider.test.js @@ -11,6 +11,10 @@ import { getCurrentBackendType, keccak256, blake2b, + scAdd, scSub, scMul, scMulAdd, scMulSub, + scReduce32, scReduce64, scInvert, scCheck, scIsZero, + scalarMultBase, scalarMultPoint, pointAddCompressed, + pointSubCompressed, pointNegate, doubleScalarMultBase, } from '../src/crypto/index.js'; import { JsCryptoBackend } from '../src/crypto/backend-js.js'; import { hexToBytes, bytesToHex } from '../src/index.js'; @@ -158,6 +162,187 @@ for (let i = 0; i < testInputs.length; i++) { }); } +// ─── Scalar equivalence ───────────────────────────────────────────────────── + +console.log('\n=== Scalar Ops Equivalence ===\n'); + +const ZERO = new Uint8Array(32); +const ONE = new Uint8Array(32); ONE[0] = 1; +const scalarA = crypto.getRandomValues(new Uint8Array(32)); scalarA[31] &= 0x0f; // keep < L +const scalarB = crypto.getRandomValues(new Uint8Array(32)); scalarB[31] &= 0x0f; + +const scalarOps = [ + ['scAdd', (b) => b.scAdd(scalarA, scalarB)], + ['scSub', (b) => b.scSub(scalarA, scalarB)], + ['scMul', (b) => b.scMul(scalarA, scalarB)], + ['scMulAdd', (b) => b.scMulAdd(scalarA, scalarB, ONE)], + ['scMulSub', (b) => b.scMulSub(scalarA, scalarB, ONE)], + ['scReduce32', (b) => b.scReduce32(new Uint8Array(32).fill(0xff))], + ['scInvert', (b) => b.scInvert(scalarA)], +]; + +for (const [name, fn] of scalarOps) { + await asyncTest(`${name} equivalence`, async () => { + const js = new JsCryptoBackend(); + const jsResult = fn(js); + await setCryptoBackend('wasm'); + const wasmResult = fn(getCryptoBackend()); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, `${name} JS vs WASM`); + }); +} + +// scReduce64 +await asyncTest('scReduce64 equivalence', async () => { + const input64 = crypto.getRandomValues(new Uint8Array(64)); + const js = new JsCryptoBackend(); + const jsResult = js.scReduce64(input64); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().scReduce64(input64); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'scReduce64 JS vs WASM'); +}); + +// scCheck +await asyncTest('scCheck equivalence', async () => { + const js = new JsCryptoBackend(); + const jsOk = js.scCheck(ONE); + const jsBad = js.scCheck(new Uint8Array(32).fill(0xff)); + await setCryptoBackend('wasm'); + const wasmOk = getCryptoBackend().scCheck(ONE); + const wasmBad = getCryptoBackend().scCheck(new Uint8Array(32).fill(0xff)); + await setCryptoBackend('js'); + if (jsOk !== wasmOk) throw new Error(`scCheck(1): JS=${jsOk} WASM=${wasmOk}`); + if (jsBad !== wasmBad) throw new Error(`scCheck(ff): JS=${jsBad} WASM=${wasmBad}`); +}); + +// scIsZero +await asyncTest('scIsZero equivalence', async () => { + const js = new JsCryptoBackend(); + const jsZero = js.scIsZero(ZERO); + const jsNonzero = js.scIsZero(ONE); + await setCryptoBackend('wasm'); + const wasmZero = getCryptoBackend().scIsZero(ZERO); + const wasmNonzero = getCryptoBackend().scIsZero(ONE); + await setCryptoBackend('js'); + if (jsZero !== wasmZero) throw new Error(`scIsZero(0): JS=${jsZero} WASM=${wasmZero}`); + if (jsNonzero !== wasmNonzero) throw new Error(`scIsZero(1): JS=${jsNonzero} WASM=${wasmNonzero}`); +}); + +// Identity: scAdd(a, 0) = a reduced +await asyncTest('scAdd identity: a + 0 = reduce(a)', async () => { + const js = new JsCryptoBackend(); + const result = js.scAdd(scalarA, ZERO); + const reduced = js.scReduce32(scalarA); + assertEqual(result, reduced, 'scAdd identity'); +}); + +// Identity: scInvert(a) * a = 1 +await asyncTest('scInvert * a = 1', async () => { + const js = new JsCryptoBackend(); + const inv = js.scInvert(scalarA); + const product = js.scMul(inv, scalarA); + assertEqual(product, ONE, 'inverse identity'); +}); + +// ─── Point equivalence ────────────────────────────────────────────────────── + +console.log('\n=== Point Ops Equivalence ===\n'); + +// Ed25519 base point G (compressed) +const G_HEX = '5866666666666666666666666666666666666666666666666666666666666666'; +const G = hexToBytes(G_HEX); + +await asyncTest('scalarMultBase(1) = G', async () => { + const js = new JsCryptoBackend(); + const result = js.scalarMultBase(ONE); + assertEqual(result, G, 'scalarMultBase(1) should be G'); +}); + +await asyncTest('scalarMultBase equivalence (random scalar)', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.scalarMultBase(scalarA); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().scalarMultBase(scalarA); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'scalarMultBase JS vs WASM'); +}); + +await asyncTest('scalarMultPoint equivalence', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const jsResult = js.scalarMultPoint(scalarB, P); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().scalarMultPoint(scalarB, P); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'scalarMultPoint JS vs WASM'); +}); + +await asyncTest('scalarMultPoint(s, G) = scalarMultBase(s)', async () => { + const js = new JsCryptoBackend(); + const viaBase = js.scalarMultBase(scalarA); + const viaPoint = js.scalarMultPoint(scalarA, G); + assertEqual(viaBase, viaPoint, 'scalarMultPoint(s,G) vs scalarMultBase(s)'); +}); + +await asyncTest('pointAddCompressed equivalence', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const Q = js.scalarMultBase(scalarB); + const jsResult = js.pointAddCompressed(P, Q); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().pointAddCompressed(P, Q); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'pointAdd JS vs WASM'); +}); + +await asyncTest('pointAdd(G, G) = scalarMultBase(2)', async () => { + const js = new JsCryptoBackend(); + const TWO = new Uint8Array(32); TWO[0] = 2; + const sum = js.pointAddCompressed(G, G); + const doubled = js.scalarMultBase(TWO); + assertEqual(sum, doubled, 'G+G vs 2*G'); +}); + +await asyncTest('pointSubCompressed equivalence', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const Q = js.scalarMultBase(scalarB); + const jsResult = js.pointSubCompressed(P, Q); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().pointSubCompressed(P, Q); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'pointSub JS vs WASM'); +}); + +await asyncTest('pointNegate equivalence', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const jsResult = js.pointNegate(P); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().pointNegate(P); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'pointNegate JS vs WASM'); +}); + +await asyncTest('pointNegate roundtrip: -(-P) = P', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const negP = js.pointNegate(P); + const negNegP = js.pointNegate(negP); + assertEqual(P, negNegP, 'double negate roundtrip'); +}); + +await asyncTest('doubleScalarMultBase equivalence', async () => { + const js = new JsCryptoBackend(); + const P = js.scalarMultBase(scalarA); + const jsResult = js.doubleScalarMultBase(scalarB, P, ONE); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().doubleScalarMultBase(scalarB, P, ONE); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'doubleScalarMultBase JS vs WASM'); +}); + // ─── Benchmark ────────────────────────────────────────────────────────────── console.log('\n=== Benchmark (10,000 iterations) ===\n'); @@ -197,6 +382,35 @@ const ITERATIONS = 10_000; console.log(` blake2b: JS ${jsTime.toFixed(1)}ms WASM ${wasmTime.toFixed(1)}ms (${speedup}x)`); } +// Scalar and point benchmarks (1,000 iterations — point ops are slower) +const BENCH_SC = 10_000; +const BENCH_PT = 1_000; +const benchScalar = new Uint8Array(32); benchScalar[0] = 42; benchScalar[31] &= 0x0f; +const benchPoint = getCryptoBackend().scalarMultBase(benchScalar); + +const benchOps = [ + ['scMulAdd', BENCH_SC, () => scMulAdd(benchScalar, benchScalar, benchScalar)], + ['scalarMultBase', BENCH_PT, () => scalarMultBase(benchScalar)], + ['scalarMultPoint', BENCH_PT, () => scalarMultPoint(benchScalar, benchPoint)], + ['pointAddCompressed', BENCH_PT, () => pointAddCompressed(benchPoint, benchPoint)], +]; + +for (const [name, iters, fn] of benchOps) { + await setCryptoBackend('js'); + const jsStart = performance.now(); + for (let i = 0; i < iters; i++) fn(); + const jsTime = performance.now() - jsStart; + + await setCryptoBackend('wasm'); + const wasmStart = performance.now(); + for (let i = 0; i < iters; i++) fn(); + const wasmTime = performance.now() - wasmStart; + + const speedup = (jsTime / wasmTime).toFixed(2); + const pad = name.padEnd(20); + console.log(` ${pad} JS ${jsTime.toFixed(1)}ms WASM ${wasmTime.toFixed(1)}ms (${speedup}x) [${iters} iters]`); +} + await setCryptoBackend('js'); // reset // ─── Summary ────────────────────────────────────────────────────────────────