From 947f64eba99674a50e96c56bd460314f2bbceac2 Mon Sep 17 00:00:00 2001 From: Matt Hess Date: Thu, 12 Feb 2026 19:24:18 +0000 Subject: [PATCH] =?UTF-8?q?=20=20Move=20CARROT=20output=20scanning=20to=20?= =?UTF-8?q?Rust=20FFI=20to=20eliminate=20JS=E2=86=94Rust=20hairpinning?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Route x25519ScalarMult through the crypto provider backend instead of using a pure-JS BigInt implementation inline in carrot-scanning.js. Add a full 7-step CARROT scanner in Rust (carrot_scan.rs) with two FFI entry points (standard X25519 ECDH and self-send paths), so wallet-sync can execute the entire scan in a single native call when the FFI backend is active, falling back to the existing JS pipeline otherwise. --- crates/salvium-crypto/src/carrot_scan.rs | 446 +++++++++++++++++++++++ crates/salvium-crypto/src/ffi.rs | 167 +++++++++ crates/salvium-crypto/src/lib.rs | 1 + src/carrot-scanning.js | 105 +----- src/crypto/backend-ffi.js | 110 +++++- src/crypto/backend-js.js | 70 ++++ src/crypto/backend-wasm.js | 3 + src/crypto/index.js | 2 + src/crypto/provider.js | 5 + src/wallet-sync.js | 84 ++++- 10 files changed, 873 insertions(+), 120 deletions(-) create mode 100644 crates/salvium-crypto/src/carrot_scan.rs diff --git a/crates/salvium-crypto/src/carrot_scan.rs b/crates/salvium-crypto/src/carrot_scan.rs new file mode 100644 index 0000000..e470e95 --- /dev/null +++ b/crates/salvium-crypto/src/carrot_scan.rs @@ -0,0 +1,446 @@ +//! CARROT output scanning — full 7-step pipeline in Rust. +//! +//! Moves the entire scan from JS (which hairpins every crypto op through FFI) +//! into a single native call. Two entry points: +//! +//! * `scan_carrot_output` — standard path: X25519 ECDH → core steps 2-7 +//! * `scan_carrot_internal_output` — self-send path: viewBalanceSecret used +//! directly as s_sr_unctx (skips X25519) + +use curve25519_dalek::edwards::{CompressedEdwardsY, EdwardsPoint}; +use curve25519_dalek::scalar::Scalar; +use curve25519_dalek::traits::VartimeMultiscalarMul; + +use crate::to32; + +// T generator — same bytes as tclsag.rs and JS side +pub const T_BYTES: [u8; 32] = [ + 0x96, 0x6f, 0xc6, 0x6b, 0x82, 0xcd, 0x56, 0xcf, + 0x85, 0xea, 0xec, 0x80, 0x1c, 0x42, 0x84, 0x5f, + 0x5f, 0x40, 0x88, 0x78, 0xd1, 0x56, 0x1e, 0x00, + 0xd3, 0xd7, 0xde, 0xd2, 0x79, 0x4d, 0x09, 0x4f, +]; + +/// H generator for Pedersen commitments (same as lib.rs) +const H_POINT_BYTES: [u8; 32] = crate::H_POINT_BYTES; + +// ─── Domain separators (matching config.h) ────────────────────────────────── + +const DOMAIN_VIEW_TAG: &[u8] = b"Carrot view tag"; +const DOMAIN_SENDER_RECEIVER_SECRET: &[u8] = b"Carrot sender-receiver secret"; +const DOMAIN_COMMITMENT_MASK: &[u8] = b"Carrot commitment mask"; +const DOMAIN_EXTENSION_G: &[u8] = b"Carrot key extension G"; +const DOMAIN_EXTENSION_T: &[u8] = b"Carrot key extension T"; +const DOMAIN_ENCRYPTION_MASK_AMOUNT: &[u8] = b"Carrot encryption mask a"; + +// ─── Result ───────────────────────────────────────────────────────────────── + +/// Result of a successful CARROT scan. +pub struct CarrotScanResult { + pub amount: u64, + pub mask: [u8; 32], + /// 0 = PAYMENT, 1 = CHANGE + pub enote_type: u8, + pub shared_secret: [u8; 32], + pub address_spend_pubkey: [u8; 32], + pub subaddress_major: u32, + pub subaddress_minor: u32, + pub is_main_address: bool, +} + +/// Serialize result to JSON with hex-encoded byte arrays. +#[cfg(not(target_arch = "wasm32"))] +impl CarrotScanResult { + pub fn to_json(&self) -> Vec { + let json = serde_json::json!({ + "amount": self.amount, + "mask": hex::encode(self.mask), + "enote_type": self.enote_type, + "shared_secret": hex::encode(self.shared_secret), + "address_spend_pubkey": hex::encode(self.address_spend_pubkey), + "subaddress_major": self.subaddress_major, + "subaddress_minor": self.subaddress_minor, + "is_main_address": self.is_main_address, + }); + serde_json::to_vec(&json).unwrap() + } +} + +// ─── Transcript builder (SpFixedTranscript) ───────────────────────────────── + +/// Build `[domain_len_byte] + domain + data...` +fn build_transcript(domain: &[u8], data: &[&[u8]]) -> Vec { + let total: usize = 1 + domain.len() + data.iter().map(|d| d.len()).sum::(); + let mut buf = Vec::with_capacity(total); + buf.push(domain.len() as u8); + buf.extend_from_slice(domain); + for d in data { + buf.extend_from_slice(d); + } + buf +} + +/// Keyed blake2b with given output length. +fn blake2b_keyed(transcript: &[u8], out_len: usize, key: &[u8]) -> Vec { + blake2b_simd::Params::new() + .hash_length(out_len) + .key(key) + .hash(transcript) + .as_bytes() + .to_vec() +} + +/// H_n: blake2b 64 bytes keyed, then sc_reduce to 32-byte scalar. +fn derive_scalar(key: &[u8], domain: &[u8], data: &[&[u8]]) -> Scalar { + let transcript = build_transcript(domain, data); + let hash64 = blake2b_keyed(&transcript, 64, key); + let mut wide = [0u8; 64]; + wide.copy_from_slice(&hash64); + Scalar::from_bytes_mod_order_wide(&wide) +} + +/// H_32: blake2b 32 bytes keyed. +fn derive_bytes_32(key: &[u8], domain: &[u8], data: &[&[u8]]) -> [u8; 32] { + let transcript = build_transcript(domain, data); + let hash = blake2b_keyed(&transcript, 32, key); + to32(&hash) +} + +/// H_8: blake2b 8 bytes keyed. +fn derive_bytes_8(key: &[u8], domain: &[u8], data: &[&[u8]]) -> [u8; 8] { + let transcript = build_transcript(domain, data); + let hash = blake2b_keyed(&transcript, 8, key); + let mut out = [0u8; 8]; + out.copy_from_slice(&hash[..8]); + out +} + +// ─── Core scanning steps ──────────────────────────────────────────────────── + +/// Step 2: View tag test (3-byte fast filter). +fn compute_view_tag(s_sr_unctx: &[u8; 32], input_context: &[u8], ko: &[u8; 32]) -> [u8; 3] { + let transcript = build_transcript(DOMAIN_VIEW_TAG, &[input_context, ko]); + let hash = blake2b_keyed(&transcript, 3, s_sr_unctx); + [hash[0], hash[1], hash[2]] +} + +/// Step 3: Contextualized shared secret. +fn make_sender_receiver_secret( + s_sr_unctx: &[u8; 32], + d_e: &[u8; 32], + input_context: &[u8], +) -> [u8; 32] { + derive_bytes_32(s_sr_unctx, DOMAIN_SENDER_RECEIVER_SECRET, &[d_e, input_context]) +} + +/// Step 4a: k^o_g = H_n[s_sr_ctx]("Carrot key extension G", C_a) +fn derive_extension_g(s_sr_ctx: &[u8; 32], commitment: &[u8; 32]) -> Scalar { + derive_scalar(s_sr_ctx, DOMAIN_EXTENSION_G, &[commitment]) +} + +/// Step 4b: k^o_t = H_n[s_sr_ctx]("Carrot key extension T", C_a) +fn derive_extension_t(s_sr_ctx: &[u8; 32], commitment: &[u8; 32]) -> Scalar { + derive_scalar(s_sr_ctx, DOMAIN_EXTENSION_T, &[commitment]) +} + +/// Step 4: Recover address spend pubkey. +/// K^j_s = Ko - (k^o_g * G + k^o_t * T) +fn recover_address_spend_pubkey( + ko: &[u8; 32], + s_sr_ctx: &[u8; 32], + commitment: &[u8; 32], +) -> Option<[u8; 32]> { + let k_g = derive_extension_g(s_sr_ctx, commitment); + let k_t = derive_extension_t(s_sr_ctx, commitment); + + let t_point = CompressedEdwardsY(T_BYTES).decompress()?; + let ko_point = CompressedEdwardsY(*ko).decompress()?; + + // K^o_ext = k_g * G + k_t * T + let ext = EdwardsPoint::vartime_multiscalar_mul( + &[k_g, k_t], + &[curve25519_dalek::constants::ED25519_BASEPOINT_POINT, t_point], + ); + + // K^j_s = Ko - ext + let recovered = ko_point - ext; + Some(recovered.compress().to_bytes()) +} + +/// Step 6: Decrypt amount. +fn decrypt_amount( + enc_amount: &[u8; 8], + s_sr_ctx: &[u8; 32], + ko: &[u8; 32], +) -> u64 { + let mask = derive_bytes_8(s_sr_ctx, DOMAIN_ENCRYPTION_MASK_AMOUNT, &[ko]); + let mut decrypted = [0u8; 8]; + for i in 0..8 { + decrypted[i] = enc_amount[i] ^ mask[i]; + } + u64::from_le_bytes(decrypted) +} + +/// Step 7a: Derive commitment mask. +fn derive_commitment_mask( + s_sr_ctx: &[u8; 32], + amount: u64, + address_spend_pubkey: &[u8; 32], + enote_type: u8, +) -> Scalar { + let amount_bytes = amount.to_le_bytes(); + let type_byte = [enote_type]; + derive_scalar( + s_sr_ctx, + DOMAIN_COMMITMENT_MASK, + &[&amount_bytes, address_spend_pubkey, &type_byte], + ) +} + +/// Step 7b: Compute Pedersen commitment and compare. +fn pedersen_commit(amount: u64, mask: &Scalar) -> [u8; 32] { + let h = CompressedEdwardsY(H_POINT_BYTES).decompress().expect("invalid H"); + let mut amount_bytes = [0u8; 32]; + let le = amount.to_le_bytes(); + amount_bytes[..8].copy_from_slice(&le); + let amount_scalar = Scalar::from_bytes_mod_order(amount_bytes); + EdwardsPoint::vartime_multiscalar_mul( + &[*mask, amount_scalar], + &[curve25519_dalek::constants::ED25519_BASEPOINT_POINT, h], + ) + .compress() + .to_bytes() +} + +// ─── Core scan (steps 2-7) ────────────────────────────────────────────────── + +/// Run the core CARROT scan steps 2-7 given an uncontextualized shared secret. +/// +/// `subaddress_map`: slice of (32-byte spend pubkey, major, minor) tuples. +/// `account_spend_pubkey`: the main account K_s. +/// `clear_text_amount`: if Some, use this instead of decrypting (coinbase). +fn scan_core( + s_sr_unctx: &[u8; 32], + ko: &[u8; 32], + view_tag: &[u8; 3], + d_e: &[u8; 32], + enc_amount: &[u8; 8], + commitment: Option<&[u8; 32]>, + account_spend_pubkey: &[u8; 32], + input_context: &[u8], + subaddress_map: &[([u8; 32], u32, u32)], + clear_text_amount: Option, +) -> Option { + // Step 2: View tag test + let expected_vt = compute_view_tag(s_sr_unctx, input_context, ko); + if expected_vt != *view_tag { + return None; + } + + // Step 3: Contextualized shared secret + let s_sr_ctx = make_sender_receiver_secret(s_sr_unctx, d_e, input_context); + + // Step 4: Recover address spend pubkey + let commit_bytes = commitment.copied().unwrap_or([0u8; 32]); + let recovered = recover_address_spend_pubkey(ko, &s_sr_ctx, &commit_bytes)?; + + // Step 5: Address matching + let mut is_main_address = false; + let mut major = 0u32; + let mut minor = 0u32; + + if recovered == *account_spend_pubkey { + is_main_address = true; + } else { + let mut found = false; + for (pubkey, maj, min) in subaddress_map { + if recovered == *pubkey { + major = *maj; + minor = *min; + found = true; + break; + } + } + if !found { + return None; + } + } + + // Step 6: Decrypt amount + let amount = if let Some(ct) = clear_text_amount { + ct + } else { + decrypt_amount(enc_amount, &s_sr_ctx, ko) + }; + + // Step 7: Derive commitment mask, try PAYMENT(0) then CHANGE(1) + let mask_payment = derive_commitment_mask(&s_sr_ctx, amount, &recovered, 0); + let (mask, enote_type) = if let Some(c) = commitment { + let computed_payment = pedersen_commit(amount, &mask_payment); + if computed_payment == *c { + (mask_payment, 0u8) + } else { + let mask_change = derive_commitment_mask(&s_sr_ctx, amount, &recovered, 1); + let computed_change = pedersen_commit(amount, &mask_change); + if computed_change == *c { + (mask_change, 1u8) + } else { + // Neither matched — fallback to PAYMENT (coinbase-like) + (mask_payment, 0u8) + } + } + } else { + // No commitment to verify (coinbase) — PAYMENT + (mask_payment, 0u8) + }; + + Some(CarrotScanResult { + amount, + mask: mask.to_bytes(), + enote_type, + shared_secret: s_sr_ctx, + address_spend_pubkey: recovered, + subaddress_major: major, + subaddress_minor: minor, + is_main_address, + }) +} + +// ─── Public entry points ──────────────────────────────────────────────────── + +/// Standard CARROT scan: X25519 ECDH then core steps 2-7. +pub fn scan_carrot_output( + ko: &[u8; 32], + view_tag: &[u8; 3], + d_e: &[u8; 32], + enc_amount: &[u8; 8], + commitment: Option<&[u8; 32]>, + k_vi: &[u8; 32], + account_spend_pubkey: &[u8; 32], + input_context: &[u8], + subaddress_map: &[([u8; 32], u32, u32)], + clear_text_amount: Option, +) -> Option { + // Step 1: X25519 ECDH — s_sr_unctx = k_vi * D_e + let mut clamped = *k_vi; + clamped[31] &= 0x7F; + let s_sr_unctx = to32(&crate::x25519::montgomery_ladder(&clamped, d_e)); + + scan_core( + &s_sr_unctx, + ko, view_tag, d_e, enc_amount, commitment, + account_spend_pubkey, input_context, subaddress_map, + clear_text_amount, + ) +} + +/// Self-send CARROT scan: viewBalanceSecret used directly as s_sr_unctx. +pub fn scan_carrot_internal_output( + ko: &[u8; 32], + view_tag: &[u8; 3], + d_e: &[u8; 32], + enc_amount: &[u8; 8], + commitment: Option<&[u8; 32]>, + view_balance_secret: &[u8; 32], + account_spend_pubkey: &[u8; 32], + input_context: &[u8], + subaddress_map: &[([u8; 32], u32, u32)], + clear_text_amount: Option, +) -> Option { + scan_core( + view_balance_secret, + ko, view_tag, d_e, enc_amount, commitment, + account_spend_pubkey, input_context, subaddress_map, + clear_text_amount, + ) +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_view_tag_deterministic() { + let s_sr = [0x42u8; 32]; + let input_ctx = [0x52u8; 33]; // 'R' + 32 zero bytes + let ko = [0x58u8; 32]; // G point + let vt1 = compute_view_tag(&s_sr, &input_ctx, &ko); + let vt2 = compute_view_tag(&s_sr, &input_ctx, &ko); + assert_eq!(vt1, vt2); + assert_ne!(vt1, [0, 0, 0]); // extremely unlikely to be all-zero + } + + #[test] + fn test_sender_receiver_secret_deterministic() { + let s_sr_unctx = [0x01u8; 32]; + let d_e = [0x09u8; 32]; + let input_ctx = [0x43u8; 33]; + let s1 = make_sender_receiver_secret(&s_sr_unctx, &d_e, &input_ctx); + let s2 = make_sender_receiver_secret(&s_sr_unctx, &d_e, &input_ctx); + assert_eq!(s1, s2); + } + + #[test] + fn test_decrypt_amount_roundtrip() { + let s_sr_ctx = [0x55u8; 32]; + let ko = [0x58u8; 32]; + let amount: u64 = 123456789; + // Encrypt + let mask = derive_bytes_8(&s_sr_ctx, DOMAIN_ENCRYPTION_MASK_AMOUNT, &[&ko]); + let amount_le = amount.to_le_bytes(); + let mut enc = [0u8; 8]; + for i in 0..8 { + enc[i] = amount_le[i] ^ mask[i]; + } + // Decrypt + let decrypted = decrypt_amount(&enc, &s_sr_ctx, &ko); + assert_eq!(decrypted, amount); + } + + #[test] + fn test_commitment_mask_differs_by_enote_type() { + let s_sr_ctx = [0x33u8; 32]; + let amount = 1000u64; + let addr = [0x58u8; 32]; + let mask0 = derive_commitment_mask(&s_sr_ctx, amount, &addr, 0); + let mask1 = derive_commitment_mask(&s_sr_ctx, amount, &addr, 1); + assert_ne!(mask0.to_bytes(), mask1.to_bytes()); + } + + #[test] + fn test_pedersen_commit_deterministic() { + let mask = Scalar::from(42u64); + let c1 = pedersen_commit(100, &mask); + let c2 = pedersen_commit(100, &mask); + assert_eq!(c1, c2); + } + + #[test] + fn test_view_tag_mismatch_returns_none() { + let s_sr = [0x42u8; 32]; + let ko = [0x58u8; 32]; // G + let view_tag = [0xff, 0xff, 0xff]; // wrong + let d_e = [9u8; 32]; + let enc_amount = [0u8; 8]; + let ks = [0x58u8; 32]; + let input_ctx = [0x52u8; 33]; + + let result = scan_carrot_output( + &ko, &view_tag, &d_e, &enc_amount, None, + &s_sr, &ks, &input_ctx, &[], None, + ); + assert!(result.is_none()); + } + + #[test] + fn test_transcript_format() { + let t = build_transcript(b"test", &[&[1, 2], &[3, 4, 5]]); + assert_eq!(t.len(), 1 + 4 + 2 + 3); + assert_eq!(t[0], 4); // domain length + assert_eq!(&t[1..5], b"test"); + assert_eq!(&t[5..7], &[1, 2]); + assert_eq!(&t[7..10], &[3, 4, 5]); + } +} diff --git a/crates/salvium-crypto/src/ffi.rs b/crates/salvium-crypto/src/ffi.rs index 2e94482..eb7268d 100644 --- a/crates/salvium-crypto/src/ffi.rs +++ b/crates/salvium-crypto/src/ffi.rs @@ -854,6 +854,173 @@ pub unsafe extern "C" fn salvium_bulletproof_plus_verify( }) } +// ─── CARROT Output Scanning ───────────────────────────────────────────────── + +/// CARROT output scan — standard (X25519 ECDH) path. +/// +/// Fixed-size inputs: +/// ko: 32, view_tag: 3, d_e: 32, enc_amount: 8, commitment: 32 (or null), +/// k_vi: 32, account_spend_pubkey: 32, input_context_len (usize), +/// clear_text_amount: u64 (u64::MAX means "not provided") +/// Variable inputs: +/// input_context: input_context_len bytes +/// subaddr_data: n_sub * 40 bytes (32-byte key + u32 major LE + u32 minor LE) +/// Output: +/// JSON result via Rust-allocated buffer (freed with salvium_storage_free_buf). +/// +/// Returns: 1 = owned, 0 = not owned, -1 = error +#[no_mangle] +pub unsafe extern "C" fn salvium_carrot_scan_output( + ko: *const u8, + view_tag: *const u8, + d_e: *const u8, + enc_amount: *const u8, + commitment: *const u8, // nullable + k_vi: *const u8, + account_spend_pubkey: *const u8, + input_context: *const u8, + input_context_len: usize, + clear_text_amount: u64, // u64::MAX = not provided + subaddr_data: *const u8, // n * 40 bytes + n_sub: u32, + out_ptr: *mut *mut u8, + out_len: *mut usize, +) -> i32 { + catch_ffi(|| { + let ko_arr = crate::to32(slice::from_raw_parts(ko, 32)); + let vt: [u8; 3] = { + let s = slice::from_raw_parts(view_tag, 3); + [s[0], s[1], s[2]] + }; + let d_e_arr = crate::to32(slice::from_raw_parts(d_e, 32)); + let enc_amt: [u8; 8] = { + let s = slice::from_raw_parts(enc_amount, 8); + let mut a = [0u8; 8]; + a.copy_from_slice(s); + a + }; + let commit_opt = if commitment.is_null() { + None + } else { + Some(crate::to32(slice::from_raw_parts(commitment, 32))) + }; + let k_vi_arr = crate::to32(slice::from_raw_parts(k_vi, 32)); + let ks_arr = crate::to32(slice::from_raw_parts(account_spend_pubkey, 32)); + let ic = slice::from_raw_parts(input_context, input_context_len); + let ct_amount = if clear_text_amount == u64::MAX { None } else { Some(clear_text_amount) }; + + // Parse subaddress map: each entry is 40 bytes (32 key + 4 major + 4 minor) + let n = n_sub as usize; + let sub_slice = if n > 0 { slice::from_raw_parts(subaddr_data, n * 40) } else { &[] }; + let subaddrs: Vec<([u8; 32], u32, u32)> = (0..n).map(|i| { + let base = i * 40; + let key = crate::to32(&sub_slice[base..base + 32]); + let major = u32::from_le_bytes([ + sub_slice[base + 32], sub_slice[base + 33], + sub_slice[base + 34], sub_slice[base + 35], + ]); + let minor = u32::from_le_bytes([ + sub_slice[base + 36], sub_slice[base + 37], + sub_slice[base + 38], sub_slice[base + 39], + ]); + (key, major, minor) + }).collect(); + + match crate::carrot_scan::scan_carrot_output( + &ko_arr, &vt, &d_e_arr, &enc_amt, commit_opt.as_ref(), + &k_vi_arr, &ks_arr, ic, &subaddrs, ct_amount, + ) { + Some(result) => { + let json = result.to_json(); + let len = json.len(); + let buf = json.into_boxed_slice(); + let raw = Box::into_raw(buf); + *out_ptr = (*raw).as_mut_ptr(); + *out_len = len; + 1 + } + None => 0, + } + }) +} + +/// CARROT output scan — self-send (internal) path. +/// Same as salvium_carrot_scan_output but takes view_balance_secret +/// instead of k_vi (view incoming key). +#[no_mangle] +pub unsafe extern "C" fn salvium_carrot_scan_internal( + ko: *const u8, + view_tag: *const u8, + d_e: *const u8, + enc_amount: *const u8, + commitment: *const u8, // nullable + view_balance_secret: *const u8, + account_spend_pubkey: *const u8, + input_context: *const u8, + input_context_len: usize, + clear_text_amount: u64, // u64::MAX = not provided + subaddr_data: *const u8, // n * 40 bytes + n_sub: u32, + out_ptr: *mut *mut u8, + out_len: *mut usize, +) -> i32 { + catch_ffi(|| { + let ko_arr = crate::to32(slice::from_raw_parts(ko, 32)); + let vt: [u8; 3] = { + let s = slice::from_raw_parts(view_tag, 3); + [s[0], s[1], s[2]] + }; + let d_e_arr = crate::to32(slice::from_raw_parts(d_e, 32)); + let enc_amt: [u8; 8] = { + let s = slice::from_raw_parts(enc_amount, 8); + let mut a = [0u8; 8]; + a.copy_from_slice(s); + a + }; + let commit_opt = if commitment.is_null() { + None + } else { + Some(crate::to32(slice::from_raw_parts(commitment, 32))) + }; + let vbs_arr = crate::to32(slice::from_raw_parts(view_balance_secret, 32)); + let ks_arr = crate::to32(slice::from_raw_parts(account_spend_pubkey, 32)); + let ic = slice::from_raw_parts(input_context, input_context_len); + let ct_amount = if clear_text_amount == u64::MAX { None } else { Some(clear_text_amount) }; + + let n = n_sub as usize; + let sub_slice = if n > 0 { slice::from_raw_parts(subaddr_data, n * 40) } else { &[] }; + let subaddrs: Vec<([u8; 32], u32, u32)> = (0..n).map(|i| { + let base = i * 40; + let key = crate::to32(&sub_slice[base..base + 32]); + let major = u32::from_le_bytes([ + sub_slice[base + 32], sub_slice[base + 33], + sub_slice[base + 34], sub_slice[base + 35], + ]); + let minor = u32::from_le_bytes([ + sub_slice[base + 36], sub_slice[base + 37], + sub_slice[base + 38], sub_slice[base + 39], + ]); + (key, major, minor) + }).collect(); + + match crate::carrot_scan::scan_carrot_internal_output( + &ko_arr, &vt, &d_e_arr, &enc_amt, commit_opt.as_ref(), + &vbs_arr, &ks_arr, ic, &subaddrs, ct_amount, + ) { + Some(result) => { + let json = result.to_json(); + let len = json.len(); + let buf = json.into_boxed_slice(); + let raw = Box::into_raw(buf); + *out_ptr = (*raw).as_mut_ptr(); + *out_len = len; + 1 + } + None => 0, + } + }) +} + // ─── Storage (SQLCipher) ──────────────────────────────────────────────────── /// Open/create an encrypted SQLite database. diff --git a/crates/salvium-crypto/src/lib.rs b/crates/salvium-crypto/src/lib.rs index 9f6dd79..a069ee0 100644 --- a/crates/salvium-crypto/src/lib.rs +++ b/crates/salvium-crypto/src/lib.rs @@ -7,6 +7,7 @@ use curve25519_dalek::traits::VartimeMultiscalarMul; use sha2::{Sha256, Digest}; mod x25519; +pub mod carrot_scan; pub(crate) mod elligator2; pub mod clsag; diff --git a/src/carrot-scanning.js b/src/carrot-scanning.js index ee8733a..77c43e8 100644 --- a/src/carrot-scanning.js +++ b/src/carrot-scanning.js @@ -16,7 +16,7 @@ */ import { hexToBytes, bytesToHex } from './address.js'; -import { blake2b, keccak256, scalarMultBase, scalarMultPoint, pointAddCompressed, hashToPoint, commit as pedersenCommit } from './crypto/index.js'; +import { blake2b, keccak256, scalarMultBase, scalarMultPoint, pointAddCompressed, hashToPoint, commit as pedersenCommit, x25519ScalarMult } from './crypto/index.js'; // Group order L for scalar reduction const L = (1n << 252n) + 27742317777372353535851937790883648493n; @@ -82,105 +82,10 @@ function modPow(base, exp, mod) { return result; } -/** - * X25519 scalar multiplication on Montgomery curve - * Computes scalar * u where u is a Montgomery u-coordinate - * - * This implements Salvium's mx25519, which differs from RFC 7748: - * - Does NOT clear bits 0-2 of scalar (caller must do this if needed) - * - Only clears bit 255 - * - Does NOT set bit 254 - * - Uses formula z2 = E * (BB + a24 * E) with a24 = 121666 - * - * @param {Uint8Array} scalar - 32-byte scalar - * @param {Uint8Array} u - 32-byte Montgomery u-coordinate - * @returns {Uint8Array} 32-byte result u-coordinate - */ -export function x25519ScalarMult(scalar, u) { - const p = 2n ** 255n - 19n; - const a24 = 121666n; // Salvium uses 121666, not 121665 - - // Clamp the scalar as per Salvium's mx25519: - // - Do NOT clear bits 0-2 (unlike standard X25519) - // - Clear bit 255 - // - Do NOT set bit 254 (unlike standard X25519) - const k = new Uint8Array(scalar); - // k[0] &= 248; // Salvium does NOT clear bits 0-2 - k[31] &= 127; // Only clear bit 255 - - // Convert inputs to BigInt - let kVal = 0n; - let uVal = 0n; - for (let i = 0; i < 32; i++) { - kVal |= BigInt(k[i]) << (8n * BigInt(i)); - uVal |= BigInt(u[i]) << (8n * BigInt(i)); - } - uVal &= (1n << 255n) - 1n; // Clear top bit - - // Montgomery ladder - let x1 = uVal; - let x2 = 1n; - let z2 = 0n; - let x3 = uVal; - let z3 = 1n; - let swap = 0n; - - // Process bits from 254 down to 0 - for (let t = 254; t >= 0; t--) { - const kt = (kVal >> BigInt(t)) & 1n; - swap ^= kt; - - // Conditional swap - if (swap) { - [x2, x3] = [x3, x2]; - [z2, z3] = [z3, z2]; - } - swap = kt; - - // Montgomery ladder step (matching Salvium's mx25519 portable implementation) - const D = (p + x3 - z3) % p; // tmp0 = x3 - z3 - const B = (p + x2 - z2) % p; // tmp1 = x2 - z2 - const A = (x2 + z2) % p; // x2 = x2 + z2 (reusing as A) - const C = (x3 + z3) % p; // z2 = x3 + z3 (reusing as C) - const DA = (D * A) % p; // z3 = D * A - const CB = (C * B) % p; // z2 = C * B - const BB = (B * B) % p; // tmp0 = B^2 - const AA = (A * A) % p; // tmp1 = A^2 - const x3_new = ((DA + CB) % p) ** 2n % p; // x3 = (DA + CB)^2 - const diff = (p + DA - CB) % p; - const z2_diff = (diff * diff) % p; // z2 = (DA - CB)^2 - const x2_new = (AA * BB) % p; // x2 = AA * BB - const E = (p + AA - BB) % p; // tmp1 = AA - BB = E - const z3_new = (x1 * z2_diff) % p; // z3 = x1 * (DA - CB)^2 - const a24E = (a24 * E) % p; // z3 = a24 * E - const z2_new = (E * ((BB + a24E) % p)) % p; // z2 = E * (BB + a24 * E) - - x2 = x2_new; - z2 = z2_new; - x3 = x3_new; - z3 = z3_new; - } - - // Final conditional swap - if (swap) { - [x2, x3] = [x3, x2]; - [z2, z3] = [z3, z2]; - } - - // Compute result = x2 / z2 - const z2Inv = modPow(z2, p - 2n, p); - const result = (x2 * z2Inv) % p; - - // Convert to bytes - const out = new Uint8Array(32); - let val = result; - for (let i = 0; i < 32; i++) { - out[i] = Number(val & 0xffn); - val >>= 8n; - } - - return out; -} +// x25519ScalarMult is now imported from ./crypto/index.js (routed through the crypto provider). +// The JS backend has a pure-JS BigInt fallback; WASM/FFI backends use native Rust. +// Re-export for consumers that import from this module. +export { x25519ScalarMult }; /** * Perform X25519 ECDH key exchange for CARROT scanning diff --git a/src/crypto/backend-ffi.js b/src/crypto/backend-ffi.js index f58c05e..1f2bd64 100644 --- a/src/crypto/backend-ffi.js +++ b/src/crypto/backend-ffi.js @@ -13,7 +13,7 @@ * @module crypto/backend-ffi */ -import { dlopen, FFIType } from 'bun:ffi'; +import { dlopen, FFIType, CString } from 'bun:ffi'; import { sha256 as nobleSha256 } from '@noble/hashes/sha2.js'; const { ptr, i32, u32, usize } = FFIType; @@ -157,6 +157,13 @@ const FFI_SYMBOLS = { // AES-256-GCM salvium_aes256gcm_encrypt: { args: [ptr, ptr, usize, ptr, ptr], returns: i32 }, salvium_aes256gcm_decrypt: { args: [ptr, ptr, usize, ptr, ptr], returns: i32 }, + + // CARROT scanning + salvium_carrot_scan_output: { args: [ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, usize, FFIType.u64, ptr, u32, ptr, ptr], returns: i32 }, + salvium_carrot_scan_internal: { args: [ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr, usize, FFIType.u64, ptr, u32, ptr, ptr], returns: i32 }, + + // Buffer management + salvium_storage_free_buf: { args: [ptr, usize], returns: FFIType.void }, }; // ─── Backend class ────────────────────────────────────────────────────────── @@ -611,4 +618,105 @@ export class FfiCryptoBackend { const actualLen = Number(outLenBuf.readBigUInt64LE(0)); return new Uint8Array(out.slice(0, actualLen)); } + + // ─── CARROT Output Scanning ────────────────────────────────────────────── + + /** + * Scan a CARROT output using the native Rust pipeline (single FFI call). + * Returns scan result object or null if not owned. + */ + scanCarrotOutput(ko, viewTag, dE, encAmount, commitment, kVi, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) { + return this._carrotScan( + 'salvium_carrot_scan_output', + ko, viewTag, dE, encAmount, commitment, + kVi, accountSpendPubkey, inputContext, + subaddressMap, clearTextAmount + ); + } + + /** + * Scan a CARROT output using the self-send (internal) path. + * viewBalanceSecret is used directly as s_sr_unctx (no X25519 ECDH). + */ + scanCarrotInternalOutput(ko, viewTag, dE, encAmount, commitment, viewBalanceSecret, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) { + return this._carrotScan( + 'salvium_carrot_scan_internal', + ko, viewTag, dE, encAmount, commitment, + viewBalanceSecret, accountSpendPubkey, inputContext, + subaddressMap, clearTextAmount + ); + } + + /** @private Shared implementation for both scan paths. */ + _carrotScan(symbolName, ko, viewTag, dE, encAmount, commitment, secretKey, accountSpendPubkey, inputContext, subaddressMap, clearTextAmount) { + const bKo = ensureBuffer(ko); + const bVt = ensureBuffer(viewTag); + const bDe = ensureBuffer(dE); + const bEnc = ensureBuffer(encAmount || new Uint8Array(8)); + const bCommit = commitment ? ensureBuffer(commitment) : null; + const bSk = ensureBuffer(secretKey); + const bKs = ensureBuffer(accountSpendPubkey); + const bIc = ensureBuffer(inputContext); + + // Encode clear text amount: u64::MAX (0xFFFFFFFFFFFFFFFF) means "not provided" + const ctAmount = (clearTextAmount !== undefined && clearTextAmount !== null) + ? BigInt(clearTextAmount) + : 0xFFFFFFFFFFFFFFFFn; + + // Marshal subaddress map: each entry = 32-byte key + 4-byte major LE + 4-byte minor LE + let subBuf, nSub; + if (subaddressMap && subaddressMap.size > 0) { + nSub = subaddressMap.size; + subBuf = Buffer.alloc(nSub * 40); + let offset = 0; + for (const [hexKey, { major, minor }] of subaddressMap) { + const keyBytes = hexToBytes(hexKey); + subBuf.set(keyBytes, offset); offset += 32; + subBuf.writeUInt32LE(major, offset); offset += 4; + subBuf.writeUInt32LE(minor, offset); offset += 4; + } + } else { + nSub = 0; + subBuf = Buffer.alloc(0); + } + + // Rust-allocated output pointers + const outPtrBuf = Buffer.alloc(8); // *mut u8 + const outLenBuf = Buffer.alloc(8); // usize + + const rc = this.lib.symbols[symbolName]( + bKo, bVt, bDe, bEnc, + bCommit, // nullable + bSk, bKs, bIc, bIc.length, + ctAmount, + subBuf, nSub, + outPtrBuf, outLenBuf + ); + + if (rc === 0) return null; // Not owned + if (rc < 0) throw new Error(`${symbolName} failed`); + + // Read JSON from Rust-allocated buffer using CString (safe before free) + const resultPtr = Number(outPtrBuf.readBigUInt64LE(0)); + const resultLen = Number(outLenBuf.readBigUInt64LE(0)); + const jsonStr = new CString(resultPtr, 0, resultLen).toString(); + + // Free Rust-allocated buffer + this.lib.symbols.salvium_storage_free_buf(resultPtr, resultLen); + + const result = JSON.parse(jsonStr); + + return { + owned: true, + onetimeAddress: bytesToHex(ko), + addressSpendPubkey: result.address_spend_pubkey, + sharedSecret: result.shared_secret, + amount: BigInt(result.amount), + mask: hexToBytes(result.mask), + enoteType: result.enote_type, + subaddressIndex: { major: result.subaddress_major, minor: result.subaddress_minor }, + isMainAddress: result.is_main_address, + isCarrot: true, + }; + } } diff --git a/src/crypto/backend-js.js b/src/crypto/backend-js.js index 13105e8..e4f9d69 100644 --- a/src/crypto/backend-js.js +++ b/src/crypto/backend-js.js @@ -52,6 +52,11 @@ export class JsCryptoBackend { scCheck(s) { return scCheck(s); } scIsZero(s) { return scIsZero(s); } + // X25519 (pure JS fallback — Montgomery ladder with BigInt) + x25519ScalarMult(scalar, uCoord) { + return jsX25519ScalarMult(scalar, uCoord); + } + // Point ops scalarMultBase(s) { return scalarMultBase(s); } scalarMultPoint(s, p) { return scalarMultPoint(s, p); } @@ -197,6 +202,71 @@ function derSignatureToRaw(der, componentLen) { return raw; } +/** + * Pure JS X25519 scalar multiplication (Montgomery ladder with BigInt). + * Implements Salvium's mx25519 variant: only clears bit 255, does NOT + * clear bits 0-2 or set bit 254 (unlike RFC 7748). + */ +function jsX25519ScalarMult(scalar, u) { + const p = 2n ** 255n - 19n; + const a24 = 121666n; + + const k = new Uint8Array(scalar); + k[31] &= 127; // Only clear bit 255 + + let kVal = 0n; + let uVal = 0n; + for (let i = 0; i < 32; i++) { + kVal |= BigInt(k[i]) << (8n * BigInt(i)); + uVal |= BigInt(u[i]) << (8n * BigInt(i)); + } + uVal &= (1n << 255n) - 1n; + + let x1 = uVal; + let x2 = 1n, z2 = 0n, x3 = uVal, z3 = 1n; + let swap = 0n; + + for (let t = 254; t >= 0; t--) { + const kt = (kVal >> BigInt(t)) & 1n; + swap ^= kt; + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + swap = kt; + + const D = (p + x3 - z3) % p; + const B = (p + x2 - z2) % p; + const A = (x2 + z2) % p; + const C = (x3 + z3) % p; + const DA = (D * A) % p; + const CB = (C * B) % p; + const BB = (B * B) % p; + const AA = (A * A) % p; + x3 = ((DA + CB) % p) ** 2n % p; + const diff = (p + DA - CB) % p; + const z2_diff = (diff * diff) % p; + x2 = (AA * BB) % p; + const E = (p + AA - BB) % p; + z3 = (x1 * z2_diff) % p; + const a24E = (a24 * E) % p; + z2 = (E * ((BB + a24E) % p)) % p; + } + + if (swap) { [x2, x3] = [x3, x2]; [z2, z3] = [z3, z2]; } + + // modPow for inversion + let base = z2 % p, exp = p - 2n, result = 1n; + while (exp > 0n) { + if (exp % 2n === 1n) result = (result * base) % p; + exp >>= 1n; + base = (base * base) % p; + } + const finalResult = (x2 * result) % p; + + const out = new Uint8Array(32); + let val = finalResult; + for (let i = 0; i < 32; i++) { out[i] = Number(val & 0xffn); val >>= 8n; } + return out; +} + /** Check if haystack contains needle bytes at any offset */ function containsBytes(haystack, needle) { outer: for (let i = 0; i <= haystack.length - needle.length; i++) { diff --git a/src/crypto/backend-wasm.js b/src/crypto/backend-wasm.js index 723d14b..52a0e84 100644 --- a/src/crypto/backend-wasm.js +++ b/src/crypto/backend-wasm.js @@ -108,6 +108,9 @@ export class WasmCryptoBackend { pointNegate(p) { return nullIfEmpty(this.wasm.point_negate(p)); } doubleScalarMultBase(a, p, b) { return nullIfEmpty(this.wasm.double_scalar_mult_base(a, p, b)); } + // X25519 + x25519ScalarMult(scalar, uCoord) { return this.wasm.x25519_scalar_mult(scalar, uCoord); } + // Hash-to-point & key derivation hashToPoint(data) { return this.wasm.hash_to_point(data); } generateKeyImage(pubKey, secKey) { diff --git a/src/crypto/index.js b/src/crypto/index.js index a374978..794ff8d 100644 --- a/src/crypto/index.js +++ b/src/crypto/index.js @@ -41,6 +41,8 @@ export { sha256, verifySignature, // Key derivation argon2id, + // X25519 + x25519ScalarMult, // CARROT key derivation computeCarrotSpendPubkey, computeCarrotAccountViewPubkey, computeCarrotMainAddressViewPubkey, diff --git a/src/crypto/provider.js b/src/crypto/provider.js index 03029e6..427cf9b 100644 --- a/src/crypto/provider.js +++ b/src/crypto/provider.js @@ -287,5 +287,10 @@ export function computeCarrotMainAddressViewPubkey(k_vi) { return scalarMultBase(k_vi); } +// X25519 +export function x25519ScalarMult(scalar, uCoord) { + return getCryptoBackend().x25519ScalarMult(scalar, uCoord); +} + // Scalar add (simple wrapper around scAdd) export function scalarAdd(a, b) { return scAdd(a, b); } diff --git a/src/wallet-sync.js b/src/wallet-sync.js index b118296..852dd5f 100644 --- a/src/wallet-sync.js +++ b/src/wallet-sync.js @@ -17,6 +17,7 @@ import { parseTransaction, parseBlock, extractTxPubKey, extractPaymentId, extrac import { bytesToHex, hexToBytes } from './address.js'; import { TX_TYPE } from './wallet.js'; import { + getCryptoBackend, generateKeyDerivation, derivePublicKey, deriveSecretKey, generateKeyImage, commit as pedersonCommit, deriveViewTag, computeSharedSecret, ecdhDecodeFull, @@ -1533,19 +1534,44 @@ export class WalletSync { : BigInt(output.amount || 0); } - // Scan with CARROT algorithm + // Scan with CARROT algorithm — prefer native Rust scanner (single FFI call) + // over JS scanner (many individual crypto ops). + const backend = getCryptoBackend(); + const hasNativeScanner = typeof backend.scanCarrotOutput === 'function'; let result; let isReturnOutput = false; try { - result = scanCarrotOutput( - outputForScan, - this.carrotKeys.viewIncomingKey, - this.carrotKeys.accountSpendPubkey, - inputContext, - this.carrotSubaddresses, - amountCommitment, - scanOptions - ); + if (hasNativeScanner) { + // Native Rust scanner: extract raw fields and pass directly + const Ko = typeof outputForScan.key === 'string' ? hexToBytes(outputForScan.key) : outputForScan.key; + const vtBytes = typeof outputForScan.viewTag === 'string' ? hexToBytes(outputForScan.viewTag) : outputForScan.viewTag; + const De = typeof outputForScan.enoteEphemeralPubkey === 'string' + ? hexToBytes(outputForScan.enoteEphemeralPubkey) : outputForScan.enoteEphemeralPubkey; + const encAmt = outputForScan.encryptedAmount + ? (typeof outputForScan.encryptedAmount === 'string' + ? hexToBytes(outputForScan.encryptedAmount) : outputForScan.encryptedAmount) + : null; + result = backend.scanCarrotOutput( + Ko, vtBytes, De, encAmt, + amountCommitment, + this.carrotKeys.viewIncomingKey, + this.carrotKeys.accountSpendPubkey, + inputContext, + this.carrotSubaddresses, + scanOptions.clearTextAmount + ); + } else { + // JS fallback scanner + result = scanCarrotOutput( + outputForScan, + this.carrotKeys.viewIncomingKey, + this.carrotKeys.accountSpendPubkey, + inputContext, + this.carrotSubaddresses, + amountCommitment, + scanOptions + ); + } } catch (e) { // Missing required fields in CARROT output - skip this output return null; @@ -1556,15 +1582,35 @@ export class WalletSync { // Only for regular transactions with key images (not coinbase/protocol_tx). if (!result && this.carrotKeys.viewBalanceSecret && firstKi) { try { - result = scanCarrotInternalOutput( - outputForScan, - this.carrotKeys.viewBalanceSecret, - this.carrotKeys.accountSpendPubkey, - inputContext, - this.carrotSubaddresses, - amountCommitment, - scanOptions - ); + if (hasNativeScanner) { + const Ko = typeof outputForScan.key === 'string' ? hexToBytes(outputForScan.key) : outputForScan.key; + const vtBytes = typeof outputForScan.viewTag === 'string' ? hexToBytes(outputForScan.viewTag) : outputForScan.viewTag; + const De = typeof outputForScan.enoteEphemeralPubkey === 'string' + ? hexToBytes(outputForScan.enoteEphemeralPubkey) : outputForScan.enoteEphemeralPubkey; + const encAmt = outputForScan.encryptedAmount + ? (typeof outputForScan.encryptedAmount === 'string' + ? hexToBytes(outputForScan.encryptedAmount) : outputForScan.encryptedAmount) + : null; + result = backend.scanCarrotInternalOutput( + Ko, vtBytes, De, encAmt, + amountCommitment, + this.carrotKeys.viewBalanceSecret, + this.carrotKeys.accountSpendPubkey, + inputContext, + this.carrotSubaddresses, + scanOptions.clearTextAmount + ); + } else { + result = scanCarrotInternalOutput( + outputForScan, + this.carrotKeys.viewBalanceSecret, + this.carrotKeys.accountSpendPubkey, + inputContext, + this.carrotSubaddresses, + amountCommitment, + scanOptions + ); + } // If we detected a self-send, compute the expected return address. // When this wallet's STAKE tx unlocks, the return output will appear