From 0bf0c9e4b35d7b38907e4b39d7e8d57089b9fb0e Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Sun, 1 Feb 2026 01:24:43 +0000 Subject: [PATCH] Add Pedersen commitment functions to WASM crypto backend (Phase 4) Implement pedersen_commit, zero_commit, and gen_commitment_mask in Rust with hardcoded H generator point. All 56 equivalence tests pass including homomorphic property verification: commit(a,m) - zeroCommit(a) = m*G. --- crates/salvium-crypto/src/elligator2.rs | 426 ++++++++++++++++++++++++ crates/salvium-crypto/src/lib.rs | 138 ++++++++ src/crypto/backend-js.js | 15 + src/crypto/backend-wasm.js | 48 +++ src/crypto/index.js | 3 + src/crypto/provider.js | 12 + test/crypto-provider.test.js | 133 ++++++++ 7 files changed, 775 insertions(+) create mode 100644 crates/salvium-crypto/src/elligator2.rs diff --git a/crates/salvium-crypto/src/elligator2.rs b/crates/salvium-crypto/src/elligator2.rs new file mode 100644 index 0000000..493e961 --- /dev/null +++ b/crates/salvium-crypto/src/elligator2.rs @@ -0,0 +1,426 @@ +/// Elligator 2 map: 32-byte hash -> Ed25519 point +/// Ported from Salvium C++ crypto-ops.c ge_fromfe_frombytes_vartime +/// +/// This maps a field element (from a hash) to a curve point using the +/// Elligator 2 algorithm. The result is NOT cofactor-cleared — caller +/// must multiply by 8. + +use curve25519_dalek::edwards::EdwardsPoint; +use curve25519_dalek::edwards::CompressedEdwardsY; + +/// 256-bit unsigned integer for field arithmetic mod p = 2^255 - 19 +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +struct U256([u64; 4]); // little-endian + +impl U256 { + const ZERO: Self = U256([0, 0, 0, 0]); + const ONE: Self = U256([1, 0, 0, 0]); + const TWO: Self = U256([2, 0, 0, 0]); + + // p = 2^255 - 19 + const P: Self = U256([ + 0xFFFFFFFFFFFFFFED, + 0xFFFFFFFFFFFFFFFF, + 0xFFFFFFFFFFFFFFFF, + 0x7FFFFFFFFFFFFFFF, + ]); + + fn from_bytes_le(bytes: &[u8; 32]) -> Self { + let mut limbs = [0u64; 4]; + for i in 0..4 { + let offset = i * 8; + limbs[i] = u64::from_le_bytes([ + bytes[offset], bytes[offset+1], bytes[offset+2], bytes[offset+3], + bytes[offset+4], bytes[offset+5], bytes[offset+6], bytes[offset+7], + ]); + } + U256(limbs) + } + + fn to_bytes_le(&self) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..4 { + let bytes = self.0[i].to_le_bytes(); + out[i*8..i*8+8].copy_from_slice(&bytes); + } + out + } + + fn is_zero(&self) -> bool { + self.0[0] == 0 && self.0[1] == 0 && self.0[2] == 0 && self.0[3] == 0 + } + + /// Least significant bit + fn is_odd(&self) -> bool { + self.0[0] & 1 == 1 + } + + /// Compare: self >= other + fn ge(&self, other: &Self) -> bool { + for i in (0..4).rev() { + if self.0[i] > other.0[i] { return true; } + if self.0[i] < other.0[i] { return false; } + } + true // equal + } + + /// self + other (with carry, no reduction) + fn add_raw(&self, other: &Self) -> (Self, bool) { + let mut result = [0u64; 4]; + let mut carry = 0u64; + for i in 0..4 { + let sum = (self.0[i] as u128) + (other.0[i] as u128) + (carry as u128); + result[i] = sum as u64; + carry = (sum >> 64) as u64; + } + (U256(result), carry != 0) + } + + /// self - other (with borrow, no reduction) + fn sub_raw(&self, other: &Self) -> (Self, bool) { + let mut result = [0u64; 4]; + let mut borrow = 0i128; + for i in 0..4 { + let diff = (self.0[i] as i128) - (other.0[i] as i128) + borrow; + if diff < 0 { + result[i] = (diff + (1i128 << 64)) as u64; + borrow = -1; + } else { + result[i] = diff as u64; + borrow = 0; + } + } + (U256(result), borrow != 0) + } + + /// Reduce mod p + fn reduce(&self) -> Self { + let mut r = *self; + while r.ge(&Self::P) { + let (sub, _) = r.sub_raw(&Self::P); + r = sub; + } + r + } +} + +// Field operations mod p + +fn fe_add(a: &U256, b: &U256) -> U256 { + let (sum, _carry) = a.add_raw(b); + sum.reduce() +} + +fn fe_sub(a: &U256, b: &U256) -> U256 { + if a.ge(b) { + let (diff, _) = a.sub_raw(b); + diff + } else { + let (sum, _) = a.add_raw(&U256::P); + let (diff, _) = sum.sub_raw(b); + diff.reduce() + } +} + +/// Multiplication mod p using schoolbook with u128 intermediates +fn fe_mul(a: &U256, b: &U256) -> U256 { + // Full 512-bit product, then reduce mod p + let mut prod = [0u128; 8]; + + for i in 0..4 { + let mut carry = 0u128; + for j in 0..4 { + let v = (a.0[i] as u128) * (b.0[j] as u128) + prod[i+j] + carry; + prod[i+j] = v & 0xFFFFFFFFFFFFFFFF; + carry = v >> 64; + } + prod[i+4] += carry; + } + + // Convert to bytes and reduce mod p using Barrett reduction + // For simplicity, use repeated subtraction approach with 512-bit number + reduce_512(&prod) +} + +/// Reduce a 512-bit number mod p = 2^255 - 19 +fn reduce_512(prod: &[u128; 8]) -> U256 { + // Strategy: split into low 255 bits and high bits, use p = 2^255 - 19 + // so 2^255 ≡ 19 (mod p), meaning high * 19 + low + // We need to handle up to 512 bits. + + // Convert product limbs to a big number represented as bytes + let mut bytes = [0u8; 64]; + for i in 0..8 { + let b = (prod[i] as u64).to_le_bytes(); + bytes[i*8..i*8+8].copy_from_slice(&b); + } + + // Use iterative reduction: take top bits, multiply by 19, add to bottom + // 64 bytes = 512 bits. We reduce to 256 bits then to < p. + let mut val = [0u128; 5]; // 5 x 64-bit limbs for 320 bits headroom + for i in 0..8 { + val[i.min(4)] += prod[i] & 0xFFFFFFFFFFFFFFFF; + } + + // Actually, let's do this properly with a simple approach: + // Load as two U256 halves and use 2^256 ≡ 2*19 = 38 (mod p) + let lo = U256([prod[0] as u64, prod[1] as u64, prod[2] as u64, prod[3] as u64]); + let hi = U256([prod[4] as u64, prod[5] as u64, prod[6] as u64, prod[7] as u64]); + + // result = lo + hi * 2^256 mod p + // 2^256 = 2 * 2^255 = 2 * (p + 19) = 2p + 38 ≡ 38 (mod p) + let hi_times_38 = mul_u256_small(&hi, 38); + let (sum, carry) = lo.add_raw(&hi_times_38); + let mut result = sum; + if carry { + // carry means we exceeded 2^256, which is another *38 + let (r, _) = result.add_raw(&U256([38, 0, 0, 0])); + result = r; + } + result.reduce() +} + +/// Multiply U256 by a small constant +fn mul_u256_small(a: &U256, b: u64) -> U256 { + let mut result = [0u64; 4]; + let mut carry = 0u128; + for i in 0..4 { + let v = (a.0[i] as u128) * (b as u128) + carry; + result[i] = v as u64; + carry = v >> 64; + } + // If carry, we need to reduce: carry * 2^256 ≡ carry * 38 (mod p) + let mut r = U256(result); + if carry > 0 { + let extra = U256([carry as u64 * 38, 0, 0, 0]); + let (sum, _) = r.add_raw(&extra); + r = sum; + } + r.reduce() +} + +fn fe_sq(a: &U256) -> U256 { + fe_mul(a, a) +} + +fn fe_neg(a: &U256) -> U256 { + if a.is_zero() { + U256::ZERO + } else { + let (diff, _) = U256::P.sub_raw(a); + diff + } +} + +/// Modular exponentiation: base^exp mod p +fn fe_pow(base: &U256, exp: &U256) -> U256 { + let mut result = U256::ONE; + let mut b = *base; + + // Process all bits + for limb_idx in 0..4 { + let mut bits = exp.0[limb_idx]; + for _ in 0..64 { + if bits & 1 == 1 { + result = fe_mul(&result, &b); + } + b = fe_sq(&b); + bits >>= 1; + } + } + result +} + +/// Modular inverse: a^(p-2) mod p +fn fe_inv(a: &U256) -> U256 { + // p - 2 + let exp = U256([ + 0xFFFFFFFFFFFFFFEB, // ...ED - 2 + 0xFFFFFFFFFFFFFFFF, + 0xFFFFFFFFFFFFFFFF, + 0x7FFFFFFFFFFFFFFF, + ]); + fe_pow(a, &exp) +} + +/// Compute x^((p-5)/8) mod p +/// (p-5)/8 = (2^255 - 24)/8 = 2^252 - 3 +fn fe_pow_pm5d8(x: &U256) -> U256 { + let exp = U256([ + 0xFFFFFFFFFFFFFFFF - 2, // 2^252 - 3 in little-endian + 0xFFFFFFFFFFFFFFFF, + 0xFFFFFFFFFFFFFFFF, + 0x0FFFFFFFFFFFFFFF, + ]); + fe_pow(x, &exp) +} + +/// fe_divpowm1: compute (u/v)^((p+3)/8) using the formula: +/// (u/v)^((p+3)/8) = u * v^3 * (u * v^7)^((p-5)/8) +fn fe_divpowm1(u: &U256, v: &U256) -> U256 { + let v2 = fe_sq(v); + let v3 = fe_mul(&v2, v); + let v4 = fe_sq(&v2); + let v7 = fe_mul(&v4, &v3); + let uv7 = fe_mul(u, &v7); + let uv7_pow = fe_pow_pm5d8(&uv7); + fe_mul(&fe_mul(u, &v3), &uv7_pow) +} + +/// Square root mod p +fn fe_sqrt(a: &U256) -> Option { + if a.is_zero() { return Some(U256::ZERO); } + + // candidate = a^((p+3)/8) + let exp = U256([ + 0xFFFFFFFFFFFFFFFE, // (2^255 - 19 + 3)/8 = 2^252 - 2 + 0xFFFFFFFFFFFFFFFF, + 0xFFFFFFFFFFFFFFFF, + 0x0FFFFFFFFFFFFFFF, + ]); + let candidate = fe_pow(a, &exp); + + if fe_sq(&candidate) == *a { return Some(candidate); } + + // sqrt(-1) mod p + let sqrt_m1 = U256::from_bytes_le(&[ + 0xb0, 0xa0, 0x0e, 0x4a, 0x27, 0x1b, 0xee, 0xc4, + 0x78, 0xe4, 0x2f, 0xad, 0x06, 0x18, 0x43, 0x2f, + 0xa7, 0xd7, 0xfb, 0x3d, 0x99, 0x00, 0x4d, 0x2b, + 0x0b, 0xdf, 0xc1, 0x4f, 0x80, 0x24, 0x83, 0x2b, + ]); + let adjusted = fe_mul(&candidate, &sqrt_m1); + if fe_sq(&adjusted) == *a { return Some(adjusted); } + + None +} + +/// Precomputed constants +fn sqrt_m1() -> U256 { + U256::from_bytes_le(&[ + 0xb0, 0xa0, 0x0e, 0x4a, 0x27, 0x1b, 0xee, 0xc4, + 0x78, 0xe4, 0x2f, 0xad, 0x06, 0x18, 0x43, 0x2f, + 0xa7, 0xd7, 0xfb, 0x3d, 0x99, 0x00, 0x4d, 0x2b, + 0x0b, 0xdf, 0xc1, 0x4f, 0x80, 0x24, 0x83, 0x2b, + ]) +} + +const A_MONT: u64 = 486662; + +/// Main Elligator 2 map: 32-byte hash -> EdwardsPoint (NOT cofactor-cleared) +/// Ported from Salvium C++ crypto-ops.c ge_fromfe_frombytes_vartime +pub fn ge_fromfe_frombytes_vartime(hash: &[u8; 32]) -> EdwardsPoint { + // Load hash as field element u, reduced mod p + // The C++ fe_frombytes loads all 256 bits and reduces via carry chain. + // We load the full value and reduce mod p to match. + let u = U256::from_bytes_le(hash).reduce(); + + let neg_a = fe_neg(&U256([A_MONT, 0, 0, 0]).reduce()); + let neg_a_sq = fe_sq(&U256([A_MONT, 0, 0, 0]).reduce()); + let neg_a_sq = fe_neg(&neg_a_sq); + + // v = 2 * u^2 + let u2 = fe_sq(&u); + let v = fe_add(&u2, &u2); + + // w = 2*u^2 + 1 + let w = fe_add(&v, &U256::ONE); + + // x = w^2 - 2*A^2*u^2 = w^2 + 2*(-A^2)*u^2 + let w2 = fe_sq(&w); + let term = fe_mul(&fe_add(&neg_a_sq, &neg_a_sq), &u2); // 2*(-A^2)*u^2 + let mut x = fe_add(&w2, &term); + + // r_X = (w/x)^((p+3)/8) via fe_divpowm1 + let mut r_x = fe_divpowm1(&w, &x); + + // y = r_X^2 * x + let mut y = fe_mul(&fe_sq(&r_x), &x); + + let sqm1 = sqrt_m1(); + let mut z = neg_a; + let sign; + + // Check branches + let diff1 = fe_sub(&w, &y); + if diff1.is_zero() { + // y == w + // fffb2 = sqrt(2*A*(A+2)) + let a_val = U256([A_MONT, 0, 0, 0]).reduce(); + let a_plus_2 = fe_add(&a_val, &U256::TWO); + let two_a_ap2 = fe_mul(&fe_add(&a_val, &a_val), &a_plus_2); + if let Some(fffb2) = fe_sqrt(&two_a_ap2) { + r_x = fe_mul(&r_x, &fffb2); + } + r_x = fe_mul(&r_x, &u); + z = fe_mul(&z, &v); + sign = false; + } else { + let sum1 = fe_add(&w, &y); + if sum1.is_zero() { + // y == -w + // fffb1 = sqrt(-2*A*(A+2)) + let a_val = U256([A_MONT, 0, 0, 0]).reduce(); + let a_plus_2 = fe_add(&a_val, &U256::TWO); + let two_a_ap2 = fe_mul(&fe_add(&a_val, &a_val), &a_plus_2); + let neg_two_a_ap2 = fe_neg(&two_a_ap2); + if let Some(fffb1) = fe_sqrt(&neg_two_a_ap2) { + r_x = fe_mul(&r_x, &fffb1); + } + r_x = fe_mul(&r_x, &u); + z = fe_mul(&z, &v); + sign = false; + } else { + // Negative branch: multiply x by sqrt(-1) + x = fe_mul(&x, &sqm1); + y = fe_mul(&fe_sq(&r_x), &x); + + let diff2 = fe_sub(&w, &y); + if diff2.is_zero() { + // fffb4 = sqrt(sqrt(-1)*A*(A+2)) + let a_val = U256([A_MONT, 0, 0, 0]).reduce(); + let a_plus_2 = fe_add(&a_val, &U256::TWO); + let sqm1_a_ap2 = fe_mul(&sqm1, &fe_mul(&a_val, &a_plus_2)); + if let Some(fffb4) = fe_sqrt(&sqm1_a_ap2) { + r_x = fe_mul(&r_x, &fffb4); + } + } else { + // fffb3 = sqrt(-sqrt(-1)*A*(A+2)) + let a_val = U256([A_MONT, 0, 0, 0]).reduce(); + let a_plus_2 = fe_add(&a_val, &U256::TWO); + let neg_sqm1_a_ap2 = fe_neg(&fe_mul(&sqm1, &fe_mul(&a_val, &a_plus_2))); + if let Some(fffb3) = fe_sqrt(&neg_sqm1_a_ap2) { + r_x = fe_mul(&r_x, &fffb3); + } + } + // z remains as -A + sign = true; + } + } + + // Adjust sign of r_X + if r_x.is_odd() != sign { + r_x = fe_neg(&r_x); + } + + // Compute projective coordinates + // Z_coord = z + w + // Y_coord = z - w + // X_coord = r_X * Z_coord + let z_coord = fe_add(&z, &w); + let y_coord = fe_sub(&z, &w); + let x_coord = fe_mul(&r_x, &z_coord); + + // Convert to affine: x = X/Z, y = Y/Z + let z_inv = fe_inv(&z_coord); + let affine_x = fe_mul(&x_coord, &z_inv); + let affine_y = fe_mul(&y_coord, &z_inv); + + // Compress to Ed25519 format: y with sign bit of x in high bit + let mut compressed = affine_y.to_bytes_le(); + if affine_x.is_odd() { + compressed[31] |= 0x80; + } + + CompressedEdwardsY(compressed).decompress().expect("elligator2 produced invalid point") +} diff --git a/crates/salvium-crypto/src/lib.rs b/crates/salvium-crypto/src/lib.rs index 4a3d594..577c2e8 100644 --- a/crates/salvium-crypto/src/lib.rs +++ b/crates/salvium-crypto/src/lib.rs @@ -5,6 +5,8 @@ use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint}; use curve25519_dalek::constants::ED25519_BASEPOINT_TABLE; use curve25519_dalek::traits::VartimeMultiscalarMul; +mod elligator2; + /// Keccak-256 hash (CryptoNote variant with 0x01 padding, NOT SHA3) /// Matches Salvium C++ cn_fast_hash / keccak() #[wasm_bindgen] @@ -168,3 +170,139 @@ pub fn double_scalar_mult_base(a: &[u8], p: &[u8], b: &[u8]) -> Vec { &[pp, curve25519_dalek::constants::ED25519_BASEPOINT_POINT], ).compress().to_bytes().to_vec() } + +// ─── Phase 3: Hash-to-Point & Key Derivation ──────────────────────────────── + +fn keccak256_internal(data: &[u8]) -> [u8; 32] { + let mut keccak = Keccak::v256(); + let mut output = [0u8; 32]; + keccak.update(data); + keccak.finalize(&mut output); + output +} + +fn encode_varint(mut val: u32, buf: &mut Vec) { + loop { + let byte = (val & 0x7f) as u8; + val >>= 7; + if val == 0 { + buf.push(byte); + break; + } + buf.push(byte | 0x80); + } +} + +fn derivation_to_scalar(derivation: &[u8], output_index: u32) -> Scalar { + let mut buf = Vec::with_capacity(40); + buf.extend_from_slice(derivation); + encode_varint(output_index, &mut buf); + let hash = keccak256_internal(&buf); + Scalar::from_bytes_mod_order(hash) +} + +/// Hash-to-point: H_p(data) = cofactor * elligator2(keccak256(data)) +/// Matches Salvium C++ hash_to_ec / ge_fromfe_frombytes_vartime +#[wasm_bindgen] +pub fn hash_to_point(data: &[u8]) -> Vec { + let hash = keccak256_internal(data); + let point = elligator2::ge_fromfe_frombytes_vartime(&hash); + // Multiply by cofactor 8 + let cofactored = point + point; // 2P + let cofactored = cofactored + cofactored; // 4P + let cofactored = cofactored + cofactored; // 8P + cofactored.compress().to_bytes().to_vec() +} + +/// Generate key image: KI = sec * H_p(pub) +#[wasm_bindgen] +pub fn generate_key_image(pub_key: &[u8], sec_key: &[u8]) -> Vec { + let hash = keccak256_internal(pub_key); + let hp = elligator2::ge_fromfe_frombytes_vartime(&hash); + // Cofactor multiply + let hp8 = { + let t = hp + hp; + let t = t + t; + t + t + }; + let scalar = Scalar::from_bytes_mod_order(to32(sec_key)); + EdwardsPoint::vartime_multiscalar_mul(&[scalar], &[hp8]) + .compress().to_bytes().to_vec() +} + +/// Generate key derivation: D = 8 * (sec * pub) +#[wasm_bindgen] +pub fn generate_key_derivation(pub_key: &[u8], sec_key: &[u8]) -> Vec { + let point = CompressedEdwardsY(to32(pub_key)).decompress().expect("invalid pub key"); + let scalar = Scalar::from_bytes_mod_order(to32(sec_key)); + let shared = scalar * point; + // Cofactor multiply by 8 + let result = { + let t = shared + shared; + let t = t + t; + t + t + }; + result.compress().to_bytes().to_vec() +} + +/// Derive public key: base + H(derivation || index) * G +#[wasm_bindgen] +pub fn derive_public_key(derivation: &[u8], output_index: u32, base_pub: &[u8]) -> Vec { + let scalar = derivation_to_scalar(derivation, output_index); + let base = CompressedEdwardsY(to32(base_pub)).decompress().expect("invalid base pub key"); + let derived = ED25519_BASEPOINT_TABLE * &scalar + base; + derived.compress().to_bytes().to_vec() +} + +/// Derive secret key: base + H(derivation || index) mod L +#[wasm_bindgen] +pub fn derive_secret_key(derivation: &[u8], output_index: u32, base_sec: &[u8]) -> Vec { + let scalar = derivation_to_scalar(derivation, output_index); + let base = Scalar::from_bytes_mod_order(to32(base_sec)); + (base + scalar).to_bytes().to_vec() +} + +// ─── Phase 4: Pedersen Commitments ────────────────────────────────────────── + +/// H generator for Pedersen commitments: H = H_p(G) +/// Precomputed from Salvium/CryptoNote rctTypes.h +const H_POINT_BYTES: [u8; 32] = [ + 0x8b, 0x65, 0x59, 0x70, 0x15, 0x37, 0x99, 0xaf, + 0x2a, 0xea, 0xdc, 0x9f, 0xf1, 0xad, 0xd0, 0xea, + 0x6c, 0x72, 0x51, 0xd5, 0x41, 0x54, 0xcf, 0xa9, + 0x2c, 0x17, 0x3a, 0x0d, 0xd3, 0x9c, 0x1f, 0x94, +]; + +/// Pedersen commitment: C = mask*G + amount*H +#[wasm_bindgen] +pub fn pedersen_commit(amount: &[u8], mask: &[u8]) -> Vec { + let amount_scalar = Scalar::from_bytes_mod_order(to32(amount)); + let mask_scalar = Scalar::from_bytes_mod_order(to32(mask)); + let h = CompressedEdwardsY(H_POINT_BYTES).decompress().expect("invalid H"); + // mask*G + amount*H + EdwardsPoint::vartime_multiscalar_mul( + &[mask_scalar, amount_scalar], + &[curve25519_dalek::constants::ED25519_BASEPOINT_POINT, h], + ).compress().to_bytes().to_vec() +} + +/// Zero commitment: C = amount*H (no blinding factor) +#[wasm_bindgen] +pub fn zero_commit(amount: &[u8]) -> Vec { + let amount_scalar = Scalar::from_bytes_mod_order(to32(amount)); + let h = CompressedEdwardsY(H_POINT_BYTES).decompress().expect("invalid H"); + EdwardsPoint::vartime_multiscalar_mul(&[amount_scalar], &[h]) + .compress().to_bytes().to_vec() +} + +/// Generate commitment mask from shared secret +/// mask = scReduce32(keccak256("commitment_mask" || sharedSecret)) +#[wasm_bindgen] +pub fn gen_commitment_mask(shared_secret: &[u8]) -> Vec { + let prefix = b"commitment_mask"; + let mut data = Vec::with_capacity(prefix.len() + shared_secret.len()); + data.extend_from_slice(prefix); + data.extend_from_slice(shared_secret); + let hash = keccak256_internal(&data); + Scalar::from_bytes_mod_order(hash).to_bytes().to_vec() +} diff --git a/src/crypto/backend-js.js b/src/crypto/backend-js.js index e215b40..b92443e 100644 --- a/src/crypto/backend-js.js +++ b/src/crypto/backend-js.js @@ -17,6 +17,9 @@ import { scalarMultBase, scalarMultPoint, pointAddCompressed, pointSubCompressed, pointNegate, doubleScalarMultBase } from '../ed25519.js'; +import { hashToPoint, generateKeyImage } from '../keyimage.js'; +import { generateKeyDerivation, derivePublicKey, deriveSecretKey } from '../scanning.js'; +import { commit, zeroCommit, genCommitmentMask } from '../transaction/serialization.js'; export class JsCryptoBackend { constructor() { @@ -60,4 +63,16 @@ export class JsCryptoBackend { const bG = scalarMultBase(b); return pointAddCompressed(aP, bG); } + + // Hash-to-point & key derivation + hashToPoint(data) { return hashToPoint(data); } + generateKeyImage(pubKey, secKey) { return generateKeyImage(pubKey, secKey); } + generateKeyDerivation(pubKey, secKey) { return generateKeyDerivation(pubKey, secKey); } + derivePublicKey(derivation, outputIndex, basePub) { return derivePublicKey(derivation, outputIndex, basePub); } + deriveSecretKey(derivation, outputIndex, baseSec) { return deriveSecretKey(derivation, outputIndex, baseSec); } + + // Pedersen commitments + commit(amount, mask) { return commit(amount, mask); } + zeroCommit(amount) { return zeroCommit(amount); } + genCommitmentMask(sharedSecret) { return genCommitmentMask(sharedSecret); } } diff --git a/src/crypto/backend-wasm.js b/src/crypto/backend-wasm.js index 35e7ec0..fb70f45 100644 --- a/src/crypto/backend-wasm.js +++ b/src/crypto/backend-wasm.js @@ -76,4 +76,52 @@ export class WasmCryptoBackend { 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); } + + // Hash-to-point & key derivation + hashToPoint(data) { return this.wasm.hash_to_point(data); } + generateKeyImage(pubKey, secKey) { return this.wasm.generate_key_image(pubKey, secKey); } + generateKeyDerivation(pubKey, secKey) { return this.wasm.generate_key_derivation(pubKey, secKey); } + derivePublicKey(derivation, outputIndex, basePub) { return this.wasm.derive_public_key(derivation, outputIndex, basePub); } + deriveSecretKey(derivation, outputIndex, baseSec) { return this.wasm.derive_secret_key(derivation, outputIndex, baseSec); } + + // Pedersen commitments + commit(amount, mask) { + // Convert amount (BigInt/number) to 32-byte LE scalar + let amountBytes = amount; + if (typeof amount === 'bigint' || typeof amount === 'number') { + let n = BigInt(amount); + amountBytes = new Uint8Array(32); + for (let i = 0; i < 32 && n > 0n; i++) { + amountBytes[i] = Number(n & 0xffn); + n >>= 8n; + } + } + // Convert mask if hex string + if (typeof mask === 'string') { + const hex = mask; + mask = new Uint8Array(hex.length / 2); + for (let i = 0; i < mask.length; i++) mask[i] = parseInt(hex.substr(i*2, 2), 16); + } + return this.wasm.pedersen_commit(amountBytes, mask); + } + zeroCommit(amount) { + let amountBytes = amount; + if (typeof amount === 'bigint' || typeof amount === 'number') { + let n = BigInt(amount); + amountBytes = new Uint8Array(32); + for (let i = 0; i < 32 && n > 0n; i++) { + amountBytes[i] = Number(n & 0xffn); + n >>= 8n; + } + } + return this.wasm.zero_commit(amountBytes); + } + genCommitmentMask(sharedSecret) { + if (typeof sharedSecret === 'string') { + const hex = sharedSecret; + sharedSecret = new Uint8Array(hex.length / 2); + for (let i = 0; i < sharedSecret.length; i++) sharedSecret[i] = parseInt(hex.substr(i*2, 2), 16); + } + return this.wasm.gen_commitment_mask(sharedSecret); + } } diff --git a/src/crypto/index.js b/src/crypto/index.js index 6f9f706..ceda344 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -18,6 +18,9 @@ export { scReduce32, scReduce64, scInvert, scCheck, scIsZero, scalarMultBase, scalarMultPoint, pointAddCompressed, pointSubCompressed, pointNegate, doubleScalarMultBase, + hashToPoint, generateKeyImage, generateKeyDerivation, + derivePublicKey, deriveSecretKey, + commit, zeroCommit, genCommitmentMask, } from './provider.js'; // Backends (for direct access / testing) diff --git a/src/crypto/provider.js b/src/crypto/provider.js index a149a9d..62b36d6 100644 --- a/src/crypto/provider.js +++ b/src/crypto/provider.js @@ -88,3 +88,15 @@ export function pointAddCompressed(p, q) { return getCryptoBackend().pointAddCom 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); } + +// Hash-to-point & key derivation +export function hashToPoint(data) { return getCryptoBackend().hashToPoint(data); } +export function generateKeyImage(pubKey, secKey) { return getCryptoBackend().generateKeyImage(pubKey, secKey); } +export function generateKeyDerivation(pubKey, secKey) { return getCryptoBackend().generateKeyDerivation(pubKey, secKey); } +export function derivePublicKey(derivation, outputIndex, basePub) { return getCryptoBackend().derivePublicKey(derivation, outputIndex, basePub); } +export function deriveSecretKey(derivation, outputIndex, baseSec) { return getCryptoBackend().deriveSecretKey(derivation, outputIndex, baseSec); } + +// Pedersen commitments +export function commit(amount, mask) { return getCryptoBackend().commit(amount, mask); } +export function zeroCommit(amount) { return getCryptoBackend().zeroCommit(amount); } +export function genCommitmentMask(sharedSecret) { return getCryptoBackend().genCommitmentMask(sharedSecret); } diff --git a/test/crypto-provider.test.js b/test/crypto-provider.test.js index 8e0e3f0..9760d9c 100644 --- a/test/crypto-provider.test.js +++ b/test/crypto-provider.test.js @@ -15,6 +15,9 @@ import { scReduce32, scReduce64, scInvert, scCheck, scIsZero, scalarMultBase, scalarMultPoint, pointAddCompressed, pointSubCompressed, pointNegate, doubleScalarMultBase, + hashToPoint, generateKeyImage, generateKeyDerivation, + derivePublicKey, deriveSecretKey, + commit, zeroCommit, genCommitmentMask, } from '../src/crypto/index.js'; import { JsCryptoBackend } from '../src/crypto/backend-js.js'; import { hexToBytes, bytesToHex } from '../src/index.js'; @@ -343,6 +346,133 @@ await asyncTest('doubleScalarMultBase equivalence', async () => { assertEqual(jsResult, wasmResult, 'doubleScalarMultBase JS vs WASM'); }); +// ─── Hash-to-point & key derivation equivalence ───────────────────────────── + +console.log('\n=== Hash-to-Point & Key Derivation Equivalence ===\n'); + +// Generate a test key pair +const testSecKey = new Uint8Array(32); +testSecKey.set([0x01, 0x23, 0x45, 0x67, 0x89, 0xab, 0xcd, 0xef]); +testSecKey[31] &= 0x0f; +const testPubKey = getCryptoBackend().scalarMultBase(testSecKey); + +await asyncTest('hashToPoint equivalence', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.hashToPoint(testPubKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().hashToPoint(testPubKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'hashToPoint JS vs WASM'); +}); + +await asyncTest('hashToPoint equivalence (random key)', async () => { + const randomKey = getCryptoBackend().scalarMultBase(scalarA); + const js = new JsCryptoBackend(); + const jsResult = js.hashToPoint(randomKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().hashToPoint(randomKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'hashToPoint random JS vs WASM'); +}); + +await asyncTest('generateKeyImage equivalence', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.generateKeyImage(testPubKey, testSecKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().generateKeyImage(testPubKey, testSecKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'generateKeyImage JS vs WASM'); +}); + +await asyncTest('generateKeyDerivation equivalence', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.generateKeyDerivation(testPubKey, testSecKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().generateKeyDerivation(testPubKey, testSecKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'generateKeyDerivation JS vs WASM'); +}); + +await asyncTest('derivePublicKey equivalence', async () => { + const js = new JsCryptoBackend(); + const derivation = js.generateKeyDerivation(testPubKey, testSecKey); + const jsResult = js.derivePublicKey(derivation, 0, testPubKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().derivePublicKey(derivation, 0, testPubKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'derivePublicKey JS vs WASM'); +}); + +await asyncTest('deriveSecretKey equivalence', async () => { + const js = new JsCryptoBackend(); + const derivation = js.generateKeyDerivation(testPubKey, testSecKey); + const jsResult = js.deriveSecretKey(derivation, 0, testSecKey); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().deriveSecretKey(derivation, 0, testSecKey); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'deriveSecretKey JS vs WASM'); +}); + +await asyncTest('derivePublicKey matches scalarMultBase(deriveSecretKey)', async () => { + const js = new JsCryptoBackend(); + const derivation = js.generateKeyDerivation(testPubKey, testSecKey); + const derivedSec = js.deriveSecretKey(derivation, 0, testSecKey); + const derivedPubFromSec = js.scalarMultBase(derivedSec); + const derivedPubDirect = js.derivePublicKey(derivation, 0, testPubKey); + assertEqual(derivedPubFromSec, derivedPubDirect, 'derivePublicKey consistency'); +}); + +// ─── Pedersen commitment equivalence ───────────────────────────────────────── + +console.log('\n=== Pedersen Commitment Equivalence ===\n'); + +const H_HEX = '8b655970153799af2aeadc9ff1add0ea6c7251d54154cfa92c173a0dd39c1f94'; + +await asyncTest('zeroCommit(1) = H', async () => { + const js = new JsCryptoBackend(); + const result = js.zeroCommit(1n); + assertEqual(result, hexToBytes(H_HEX), 'zeroCommit(1) should be H'); +}); + +await asyncTest('commit equivalence (random)', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.commit(12345n, scalarA); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().commit(12345n, scalarA); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'commit JS vs WASM'); +}); + +await asyncTest('zeroCommit equivalence', async () => { + const js = new JsCryptoBackend(); + const jsResult = js.zeroCommit(99999n); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().zeroCommit(99999n); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'zeroCommit JS vs WASM'); +}); + +await asyncTest('genCommitmentMask equivalence', async () => { + const js = new JsCryptoBackend(); + const secret = crypto.getRandomValues(new Uint8Array(32)); + const jsResult = js.genCommitmentMask(secret); + await setCryptoBackend('wasm'); + const wasmResult = getCryptoBackend().genCommitmentMask(secret); + await setCryptoBackend('js'); + assertEqual(jsResult, wasmResult, 'genCommitmentMask JS vs WASM'); +}); + +await asyncTest('commit homomorphic: commit(a,m) - zeroCommit(a) = m*G', async () => { + const js = new JsCryptoBackend(); + const amount = 42n; + const mask = scalarA; + const c = js.commit(amount, mask); + const z = js.zeroCommit(amount); + const diff = js.pointSubCompressed(c, z); + const mG = js.scalarMultBase(mask); + assertEqual(diff, mG, 'homomorphic property'); +}); + // ─── Benchmark ────────────────────────────────────────────────────────────── console.log('\n=== Benchmark (10,000 iterations) ===\n'); @@ -393,6 +523,9 @@ const benchOps = [ ['scalarMultBase', BENCH_PT, () => scalarMultBase(benchScalar)], ['scalarMultPoint', BENCH_PT, () => scalarMultPoint(benchScalar, benchPoint)], ['pointAddCompressed', BENCH_PT, () => pointAddCompressed(benchPoint, benchPoint)], + ['hashToPoint', BENCH_PT, () => hashToPoint(benchPoint)], + ['generateKeyImage', BENCH_PT, () => generateKeyImage(benchPoint, benchScalar)], + ['generateKeyDerivation', BENCH_PT, () => generateKeyDerivation(benchPoint, benchScalar)], ]; for (const [name, iters, fn] of benchOps) {