Move CARROT output scanning to Rust FFI to eliminate JS↔Rust hairpinning

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.
This commit is contained in:
Matt Hess
2026-02-12 19:24:18 +00:00
parent 54a3847308
commit 947f64eba9
10 changed files with 873 additions and 120 deletions
+446
View File
@@ -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<u8> {
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<u8> {
let total: usize = 1 + domain.len() + data.iter().map(|d| d.len()).sum::<usize>();
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<u8> {
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<u64>,
) -> Option<CarrotScanResult> {
// 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<u64>,
) -> Option<CarrotScanResult> {
// 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<u64>,
) -> Option<CarrotScanResult> {
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]);
}
}
+167
View File
@@ -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.
+1
View File
@@ -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;
+5 -100
View File
@@ -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
+109 -1
View File
@@ -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,
};
}
}
+70
View File
@@ -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++) {
+3
View File
@@ -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) {
+2
View File
@@ -41,6 +41,8 @@ export {
sha256, verifySignature,
// Key derivation
argon2id,
// X25519
x25519ScalarMult,
// CARROT key derivation
computeCarrotSpendPubkey, computeCarrotAccountViewPubkey,
computeCarrotMainAddressViewPubkey,
+5
View File
@@ -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); }
+65 -19
View File
@@ -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