Skip to main content

curvy_core/
stealth.rs

1//! Domain A - stealth addressing core. Native Rust port of `curvy-core` (Go/gnark).
2//!
3//! Dual-curve & pairing-based:
4//! - **secp256k1** spending keys: `s` (priv), `S = s·G` (pub).
5//! - **BN254** viewing keys + ephemerals: `v`/`V`, `r`/`R`, with a pairing.
6//!
7//! Sender: `R = r·G_bn`, `secret = e(r·V, G2)`, `b = secret.c0.c0.c0 (mod secp order)`,
8//! `spendingPubKey = b·S`, `viewTag = hex(rV.x)[:2]`.
9//! Recipient: for each `(R_i, viewTag_i)`, compute `v·R_i`, match the view tag, then
10//! derive `b`, `spendingPubKey = b·S`, `spendingPrivKey = s·b`.
11//!
12//! Points cross the boundary as `"X.Y"` big-endian **decimal** strings; private keys
13//! as big-endian hex.
14//!
15//! Parity hazard (validated by golden vectors): gnark's GT field
16//! tower `C0.B0.A0` must equal arkworks' `Fq12.c0.c0.c0`, and the BN254 G1/G2 + the
17//! secp256k1 generators must match gnark's.
18
19use core::str::FromStr;
20
21use ark_bn254::{Bn254, Fq as BnFq, Fq12, Fr as BnFr, G1Affine as BnG1, G2Affine as BnG2};
22use ark_ec::pairing::Pairing;
23use ark_ec::{AffineRepr, CurveGroup};
24use ark_ff::{BigInteger, PrimeField, Zero};
25use ark_secp256k1::{Affine as SecpG1, Fq as SecpFq, Fr as SecpFr};
26use num_bigint::BigUint;
27#[cfg(feature = "parallel")]
28use rayon::prelude::*;
29
30use crate::encoding::from_hex;
31
32// Map announcements → the SPARSE list of matches (the closure returns
33// `Option<Match>`), in input order. With the `parallel` feature the work fans
34// out over rayon (each item is an independent G1 mul +, on a tag match, one
35// pairing - embarrassingly parallel); rayon's `collect` preserves the input
36// order even through `filter_map`, so both arms are output-identical.
37macro_rules! map_announcements {
38    ($rs:expr, $tags:expr, $f:expr) => {{
39        #[cfg(feature = "parallel")]
40        {
41            $rs.par_iter()
42                .zip($tags.par_iter())
43                .enumerate()
44                .filter_map($f)
45                .collect::<Vec<_>>()
46        }
47        #[cfg(not(feature = "parallel"))]
48        {
49            $rs.iter()
50                .zip($tags.iter())
51                .enumerate()
52                .filter_map($f)
53                .collect::<Vec<_>>()
54        }
55    }};
56}
57
58/// Boundary-validation failure: malformed, off-curve, or degenerate input to the
59/// stealth core. Own-key problems are hard errors; per-announcement problems in
60/// [`scan`]/[`viewer_scan`] are treated as non-matches instead (see there).
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct StealthError(String);
63
64impl core::fmt::Display for StealthError {
65    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
66        write!(f, "stealth core: {}", self.0)
67    }
68}
69impl std::error::Error for StealthError {}
70
71fn err(msg: impl Into<String>) -> StealthError {
72    StealthError(msg.into())
73}
74
75fn fp_to_biguint<F: PrimeField>(x: F) -> BigUint {
76    BigUint::from_bytes_be(&x.into_bigint().to_bytes_be())
77}
78fn fp_dec<F: PrimeField>(x: F) -> String {
79    fp_to_biguint(x).to_str_radix(10)
80}
81
82fn xy_bn(p: &BnG1) -> String {
83    format!("{}.{}", fp_dec(p.x().unwrap()), fp_dec(p.y().unwrap()))
84}
85fn xy_secp(p: &SecpG1) -> String {
86    format!("{}.{}", fp_dec(p.x().unwrap()), fp_dec(p.y().unwrap()))
87}
88
89fn parse_xy<F: PrimeField>(s: &str) -> Result<(F, F), StealthError> {
90    let (x, y) = s
91        .split_once('.')
92        .ok_or_else(|| err(format!("point must be \"X.Y\", got {s:?}")))?;
93    Ok((
94        F::from_str(x).map_err(|_| err(format!("bad point X: {x:?}")))?,
95        F::from_str(y).map_err(|_| err(format!("bad point Y: {y:?}")))?,
96    ))
97}
98
99// Both BN254 G1 and secp256k1 have cofactor 1, so on-curve already implies the
100// prime-order subgroup - no separate subgroup check is needed. The check also
101// excludes (0, 0) (off-curve for both), so a parsed point is never the identity
102// and downstream `x()/y().unwrap()` on it cannot fire.
103fn parse_bn(s: &str, what: &str) -> Result<BnG1, StealthError> {
104    let (x, y) = parse_xy::<BnFq>(s)?;
105    let p = BnG1::new_unchecked(x, y);
106    if !p.is_on_curve() {
107        return Err(err(format!("{what} is not on BN254 G1: {s:?}")));
108    }
109    Ok(p)
110}
111fn parse_secp(s: &str, what: &str) -> Result<SecpG1, StealthError> {
112    let (x, y) = parse_xy::<SecpFq>(s)?;
113    let p = SecpG1::new_unchecked(x, y);
114    if !p.is_on_curve() {
115        return Err(err(format!("{what} is not on secp256k1: {s:?}")));
116    }
117    Ok(p)
118}
119
120/// Private scalar from big-endian hex, rejecting a zero reduction (a zero spend or
121/// view key would put every derived point at the identity).
122fn parse_secp_scalar(hex: &str, what: &str) -> Result<SecpFr, StealthError> {
123    let s = SecpFr::from_be_bytes_mod_order(&from_hex(hex));
124    if s.is_zero() {
125        return Err(err(format!("{what} reduces to zero")));
126    }
127    Ok(s)
128}
129fn parse_bn_scalar(hex: &str, what: &str) -> Result<BnFr, StealthError> {
130    let v = BnFr::from_be_bytes_mod_order(&from_hex(hex));
131    if v.is_zero() {
132        return Err(err(format!("{what} reduces to zero")));
133    }
134    Ok(v)
135}
136
137fn bn_mul(p: BnG1, scalar: BnFr) -> BnG1 {
138    (p.into_group() * scalar).into_affine()
139}
140fn secp_mul(p: SecpG1, scalar: SecpFr) -> SecpG1 {
141    (p.into_group() * scalar).into_affine()
142}
143
144/// `compute_b_asElement`: `e(rV, G2).c0.c0.c0` reduced into the secp256k1 scalar field.
145fn compute_b(secret: &Fq12) -> SecpFr {
146    let a0: BnFq = secret.c0.c0.c0; // gnark GT.C0.B0.A0
147    SecpFr::from_le_bytes_mod_order(&a0.into_bigint().to_bytes_le())
148}
149
150/// `viewTag` ("v1-1byte"): first 2 hex chars of the point's X coordinate.
151fn view_tag(p: &BnG1) -> String {
152    fp_to_biguint(p.x().unwrap())
153        .to_str_radix(16)
154        .chars()
155        .take(2)
156        .collect()
157}
158
159/// Compare a computed `v·R` tag against an announcement's tag. Matching means the
160/// tag's first 2 chars equal the computed tag exactly (a computed 1-char tag - a
161/// tiny X coordinate - never matches a 2-char one, same as before). A malformed
162/// tag (shorter than 2 chars, or a non-char-boundary prefix) is a NON-MATCH, not
163/// a panic - the Go core panicked here on 1-char tags, which turned one bad
164/// announcement into a dead scan.
165fn tag_matches(vri: &BnG1, vt: &str) -> bool {
166    vt.get(..2).is_some_and(|prefix| view_tag(vri) == prefix)
167}
168
169/// `get_meta`: derive the public meta-keys `(K, V)` from the private `(k, v)` hex.
170pub fn get_meta(k_hex: &str, v_hex: &str) -> Result<(String, String), StealthError> {
171    let s = parse_secp_scalar(k_hex, "spend private key")?;
172    let big_s = secp_mul(SecpG1::generator(), s);
173    let v = parse_bn_scalar(v_hex, "view private key")?;
174    let big_v = bn_mul(BnG1::generator(), v);
175    Ok((xy_secp(&big_s), xy_bn(&big_v)))
176}
177
178/// `send` output `{R, viewTag, spendingPubKey}` for a **given** ephemeral `r`
179/// (decimal). The Go `send` picks `r` randomly; pass the recorded `r` to reproduce.
180pub struct SendOutput {
181    pub big_r: String,
182    pub view_tag: String,
183    pub spending_pub_key: String,
184}
185
186pub fn send_with_r(r_dec: &str, big_k: &str, big_v: &str) -> Result<SendOutput, StealthError> {
187    let r = BnFr::from_str(r_dec).map_err(|_| err(format!("bad ephemeral r: {r_dec:?}")))?;
188    if r.is_zero() {
189        return Err(err("ephemeral r must be nonzero"));
190    }
191    // The recipient meta-keys come from the registry - validate hard. A send
192    // computed from an off-curve K/V would announce a garbage spendingPubKey:
193    // funds committed to an address nobody can ever derive the key for.
194    let big_v_pt = parse_bn(big_v, "recipient view key V")?;
195    let big_k_pt = parse_secp(big_k, "recipient spend key K")?;
196    let big_r = bn_mul(BnG1::generator(), r);
197    let rv = bn_mul(big_v_pt, r);
198    let secret = Bn254::pairing(rv, BnG2::generator());
199    let b = compute_b(&secret.0);
200    let spk = secp_mul(big_k_pt, b);
201    Ok(SendOutput {
202        big_r: xy_bn(&big_r),
203        view_tag: view_tag(&rv),
204        spending_pub_key: xy_secp(&spk),
205    })
206}
207
208/// One matched announcement: `index` into the input `rs`/`view_tags` arrays,
209/// plus the derived one-time keys. A tag match is a CANDIDATE, not proof of
210/// ownership - the 1-byte viewTag false-positives at ~1/256, and the caller's
211/// note-commitment recompute (`discoverOwnedNotes`) is what confirms.
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub struct ScanMatch {
214    pub index: u32,
215    pub spending_pub_key: String,
216    pub spending_priv_key: String,
217}
218
219/// A viewer-scan candidate: derived spending PUBLIC key only (no spend key).
220#[derive(Debug, Clone, PartialEq, Eq)]
221pub struct ViewerMatch {
222    pub index: u32,
223    pub spending_pub_key: String,
224}
225
226/// Returns the SPARSE, input-ordered list of tag-matching announcements.
227/// Announcements (`R_i`, `viewTag_i`) come off the network, so a malformed or
228/// off-curve `R_i` (or malformed tag) is simply not a match - one hostile or
229/// corrupt announcement must not abort a whole wallet scan. Errors are reserved
230/// for the caller's own inputs (keys, mismatched array lengths).
231pub fn scan(
232    k_hex: &str,
233    v_hex: &str,
234    rs: &[String],
235    view_tags: &[String],
236) -> Result<Vec<ScanMatch>, StealthError> {
237    if rs.len() != view_tags.len() {
238        return Err(err(format!(
239            "Rs.len ({}) != viewTags.len ({})",
240            rs.len(),
241            view_tags.len()
242        )));
243    }
244    let s = parse_secp_scalar(k_hex, "spend private key")?;
245    let big_s = secp_mul(SecpG1::generator(), s);
246    let v = parse_bn_scalar(v_hex, "view private key")?;
247
248    Ok(map_announcements!(rs, view_tags, |(i, (ri_str, vt)): (
249        usize,
250        (&String, &String)
251    )| {
252        let ri = parse_bn(ri_str, "announcement R").ok()?;
253        // v ≠ 0 and R is a valid affine point of the prime-order G1, so v·R is
254        // never the identity - view_tag/xy on it cannot panic.
255        let vri = bn_mul(ri, v);
256        if !tag_matches(&vri, vt) {
257            return None;
258        }
259        let b = compute_b(&Bn254::pairing(vri, BnG2::generator()).0);
260        let sb = s * b;
261        Some(ScanMatch {
262            index: i as u32,
263            spending_pub_key: xy_secp(&secp_mul(big_s, b)),
264            spending_priv_key: format!("0x{}", fp_to_biguint(sb).to_str_radix(16)),
265        })
266    }))
267}
268
269/// `viewerScan`: like [`scan`] but the viewer holds only `v` + the spend pubkey `S`
270/// (no `k`), so it recovers spending PUBLIC keys only. Same sparse shape and
271/// skip semantics for per-announcement inputs; own inputs (`v`, `S`) error hard.
272pub fn viewer_scan(
273    v_hex: &str,
274    big_s: &str,
275    rs: &[String],
276    view_tags: &[String],
277) -> Result<Vec<ViewerMatch>, StealthError> {
278    if rs.len() != view_tags.len() {
279        return Err(err(format!(
280            "Rs.len ({}) != viewTags.len ({})",
281            rs.len(),
282            view_tags.len()
283        )));
284    }
285    let v = parse_bn_scalar(v_hex, "view private key")?;
286    let s = parse_secp(big_s, "spend public key S")?;
287    Ok(map_announcements!(rs, view_tags, |(i, (ri_str, vt)): (
288        usize,
289        (&String, &String)
290    )| {
291        let ri = parse_bn(ri_str, "announcement R").ok()?;
292        let vri = bn_mul(ri, v);
293        if !tag_matches(&vri, vt) {
294            return None;
295        }
296        let b = compute_b(&Bn254::pairing(vri, BnG2::generator()).0);
297        Some(ViewerMatch {
298            index: i as u32,
299            spending_pub_key: xy_secp(&secp_mul(s, b)),
300        })
301    }))
302}
303
304fn random_scalar_bytes() -> Result<[u8; 32], StealthError> {
305    let mut b = [0u8; 32];
306    getrandom::getrandom(&mut b)
307        .map_err(|error| err(format!("secure randomness unavailable: {error}")))?;
308    Ok(b)
309}
310
311fn pad_even(s: &str) -> String {
312    if s.len().is_multiple_of(2) {
313        s.to_string()
314    } else {
315        format!("0{s}")
316    }
317}
318
319fn random_nonzero<F: PrimeField>() -> Result<F, StealthError> {
320    // A zero draw has probability ~2⁻²⁵⁴; redraw rather than emit a degenerate key.
321    loop {
322        let x = F::from_le_bytes_mod_order(&random_scalar_bytes()?);
323        if !x.is_zero() {
324            return Ok(x);
325        }
326    }
327}
328
329/// `new_meta`: generate a fresh random meta-key pair. Returns `(k, v, K, V)` -
330/// private keys as big-endian hex, public keys as `"X.Y"` decimal.
331pub fn new_meta() -> Result<(String, String, String, String), StealthError> {
332    let s = random_nonzero::<SecpFr>()?;
333    let v = random_nonzero::<BnFr>()?;
334    let k_hex = pad_even(&fp_to_biguint(s).to_str_radix(16));
335    let v_hex = pad_even(&fp_to_biguint(v).to_str_radix(16));
336    Ok((
337        k_hex,
338        v_hex,
339        xy_secp(&secp_mul(SecpG1::generator(), s)),
340        xy_bn(&bn_mul(BnG1::generator(), v)),
341    ))
342}
343
344/// `send`: pick a fresh ephemeral `r` and produce the announcement.
345/// Returns `(r_dec, output)`. Errors on malformed / off-curve recipient keys.
346pub fn send(big_k: &str, big_v: &str) -> Result<(String, SendOutput), StealthError> {
347    let r = random_nonzero::<BnFr>()?;
348    let r_dec = fp_to_biguint(r).to_str_radix(10);
349    let out = send_with_r(&r_dec, big_k, big_v)?;
350    Ok((r_dec, out))
351}
352
353/// `dbg_isValidBN254Point`: is `"X.Y"` a valid point on BN254 G1?
354pub fn is_valid_bn254_point(point: &str) -> bool {
355    parse_bn(point, "point").is_ok()
356}
357
358/// `dbg_isValidSECP256k1Point`: is `"X.Y"` a valid point on secp256k1?
359pub fn is_valid_secp256k1_point(point: &str) -> bool {
360    parse_secp(point, "point").is_ok()
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn new_meta_round_trips_through_get_meta() {
369        // The derived publics must match what get_meta recomputes from the privates,
370        // and a self-send must be discoverable by a self-scan.
371        let (k, v, big_k, big_v) = new_meta().unwrap();
372        let (rk, rv) = get_meta(&k, &v).unwrap();
373        assert_eq!((rk, rv), (big_k.clone(), big_v.clone()));
374
375        let (_r, sent) = send(&big_k, &big_v).unwrap();
376        let found = scan(&k, &v, &[sent.big_r], &[sent.view_tag]).unwrap();
377        assert_eq!(found.len(), 1);
378        assert_eq!(found[0].index, 0);
379        assert_eq!(found[0].spending_pub_key, sent.spending_pub_key);
380        assert!(found[0].spending_priv_key.starts_with("0x"));
381    }
382
383    // (1, 2) is the BN254 G1 generator; (1, 3) is on neither curve.
384    const OFF_CURVE: &str = "1.3";
385
386    #[test]
387    fn scan_skips_bad_announcements_without_aborting() {
388        let (k, v, big_k, big_v) = new_meta().unwrap();
389        let (_r, sent) = send(&big_k, &big_v).unwrap();
390
391        let rs = vec![
392            OFF_CURVE.to_string(),     // off-curve point
393            "not-a-point".to_string(), // unparseable
394            sent.big_r.clone(),        // real match
395            sent.big_r.clone(),        // real point, malformed 1-char tag
396        ];
397        let tags = vec!["ab".into(), "cd".into(), sent.view_tag.clone(), "a".into()];
398
399        let found = scan(&k, &v, &rs, &tags).unwrap();
400        assert_eq!(found.len(), 1, "only the real announcement matches");
401        assert_eq!(found[0].index, 2);
402        assert_eq!(found[0].spending_pub_key, sent.spending_pub_key);
403
404        let seen = viewer_scan(&v, &big_k, &rs, &tags).unwrap();
405        assert_eq!(seen.len(), 1);
406        assert_eq!(
407            (seen[0].index, seen[0].spending_pub_key.as_str()),
408            (2, sent.spending_pub_key.as_str())
409        );
410    }
411
412    #[test]
413    fn send_rejects_malformed_recipient_keys() {
414        let (_k, _v, big_k, big_v) = new_meta().unwrap();
415        assert!(
416            send(OFF_CURVE, &big_v).is_err(),
417            "off-curve K must be rejected"
418        );
419        assert!(
420            send(&big_k, OFF_CURVE).is_err(),
421            "off-curve V must be rejected"
422        );
423        assert!(send("garbage", &big_v).is_err());
424        assert!(
425            send_with_r("0", &big_k, &big_v).is_err(),
426            "zero ephemeral r must be rejected"
427        );
428    }
429
430    #[test]
431    fn own_key_and_shape_errors_are_hard() {
432        let (k, v, _big_k, _big_v) = new_meta().unwrap();
433        assert!(get_meta("00", &v).is_err(), "zero spend key");
434        assert!(get_meta(&k, "00").is_err(), "zero view key");
435        assert!(
436            scan(&k, &v, &["1.2".into()], &[]).is_err(),
437            "length mismatch"
438        );
439        assert!(viewer_scan(&v, OFF_CURVE, &[], &[]).is_err(), "off-curve S");
440    }
441
442    #[test]
443    fn point_validators_reject_off_curve_and_garbage() {
444        assert!(is_valid_bn254_point("1.2")); // the BN254 G1 generator
445        assert!(!is_valid_bn254_point(OFF_CURVE));
446        assert!(!is_valid_bn254_point("1.2.3"));
447        assert!(!is_valid_secp256k1_point("1.3"));
448        assert!(!is_valid_secp256k1_point(""));
449    }
450}