diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index c5c9f5714..f1499dd32 100644 --- a/crypto/ecsm/src/curve.rs +++ b/crypto/ecsm/src/curve.rs @@ -149,15 +149,20 @@ fn schedule(k: &BigUint) -> Vec<(u8, u8, u8)> { sched } -/// Executor fast path: the x-coordinate of `k·g`, via k256's optimized scalar +/// Executor fast path: `k·g` in affine coordinates, via k256's optimized scalar /// multiplication. Needs no step list or slopes, so it skips all witness work. /// `k` must be in `[1, N)` (guaranteed by `prepare`). -pub fn scalar_mul_affine_x(k: &BigUint, g: &AffinePoint) -> BigUint { +pub fn scalar_mul_affine(k: &BigUint, g: &AffinePoint) -> AffinePoint { let scalar = Option::::from(Scalar::from_repr(be32(k).into())) .expect("ECSM: scalar k must be < N"); let g_proj = ProjectivePoint::from(to_k256_affine(g)); let r = (g_proj * scalar).to_affine(); - from_k256_affine(&r).x + from_k256_affine(&r) +} + +/// The x-coordinate of `k·g`. Thin wrapper over [`scalar_mul_affine`]. +pub fn scalar_mul_affine_x(k: &BigUint, g: &AffinePoint) -> BigUint { + scalar_mul_affine(k, g).x } /// Jacobian doubling (dbl-2009-l) for `y² = x³ + 7`: on `(X:Y:Z)` with diff --git a/crypto/ecsm/src/lib.rs b/crypto/ecsm/src/lib.rs index e3a5e3a33..e27bbd6e6 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -120,9 +120,28 @@ pub(crate) fn prepare( } /// Computes the x-coordinate of `k·G` over secp256k1, given `k` and `xG` as little-endian -/// 32-byte values. This is the executor's entry point — it writes the returned bytes back -/// to guest memory at `addr_xR`. +/// 32-byte values. pub fn scalar_mul_x(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result<[u8; 32], EcsmError> { + Ok(scalar_mul_full(k_le, xg_le)?.0) +} + +/// The ECSM ecall's memory image: `(xR, yR, yG)`, three little-endian 32-byte values written +/// back as one contiguous 96-byte buffer. +pub type EcsmOutput = ([u8; 32], [u8; 32], [u8; 32]); + +/// The executor's entry point: `(xR, yR, yG)` as little-endian 32-byte values, written back +/// to guest memory as one contiguous 96-byte buffer at `addr_xR`. +/// +/// `yG` is echoed because the chip is free to witness *either* root of `xG` — the AIR only +/// binds `yG² ≡ xG³ + b`, so nothing pins the sign (see `spec/ecsm.typ`, "Two options for +/// `y_G`"). Returning `yR` alone would therefore be ambiguous: it is the y of `k·(xG, yG)` +/// for whichever root the prover chose, which is `±y(k·P)` for the caller's own point `P`. +/// Echoing `yG` resolves it caller-side at no cost: the caller checks `yG < p` (free — it +/// is the field-element parse) and compares `yG`'s parity against its own base point's, so +/// a flipped root just flips the sign it applies to `yR`. That keeps the root a free choice +/// for the prover, exactly as the spec's aside argues, while still handing back a usable y. +pub fn scalar_mul_full(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result { let (k, g) = prepare(k_le, xg_le)?; - Ok(to_le_32(&curve::scalar_mul_affine_x(&k, &g))) + let r = curve::scalar_mul_affine(&k, &g); + Ok((to_le_32(&r.x), to_le_32(&r.y), to_le_32(&g.y))) } diff --git a/crypto/ethrex-crypto/src/lib.rs b/crypto/ethrex-crypto/src/lib.rs index ec36b0831..7a4632d1b 100644 --- a/crypto/ethrex-crypto/src/lib.rs +++ b/crypto/ethrex-crypto/src/lib.rs @@ -9,9 +9,10 @@ //! - `keccak256`: a sponge over the `keccak_permute` precompile (riscv64; on //! host it falls back to software keccak for tests). //! - `secp256k1_ecrecover`: the ECDSA recovery's 2-term linear combination is -//! evaluated through the ECSM `ecsm_mul` precompile (riscv64), reconstructing -//! the full point from x-only queries; on host / degenerate inputs it falls -//! back to the pure-Rust `ProjectivePoint::lincomb`. +//! evaluated through the ECSM `ecsm_mul` precompile (riscv64), which returns +//! each `k·P` in full together with the base-point root it used — so the two +//! products cost one query each and are combined with a single chord addition. +//! On host / degenerate inputs it falls back to `ProjectivePoint::lincomb`. //! //! Every other `Crypto` method inherits the trait default (vetted pure-Rust //! crates: `ark-bn254`, `bls12_381`, `p256`, `sha2`, `ripemd`, …). @@ -29,7 +30,7 @@ use k256::elliptic_curve::sec1::ToEncodedPoint; use k256::elliptic_curve::PrimeField; use k256::{AffinePoint, FieldBytes, ProjectivePoint, Scalar, U256}; -// Used only by the x-only point reconstruction (riscv accelerated path + the +// Used only by the point reconstruction (riscv accelerated path + the // host unit tests); unused on a non-test host build. #[cfg(any(target_arch = "riscv64", test))] use k256::elliptic_curve::sec1::FromEncodedPoint; @@ -75,17 +76,19 @@ impl Crypto for LambdaVmEcsmCrypto { /// [`scalar_inv`] / [`decompress_r`] for the fallback that closes that hole. #[cfg(target_arch = "riscv64")] fn get_hint(hint_id: usize, x_be: &[u8; 32]) -> [u8; 32] { - // 8-byte-aligned output buffer so the HINT table's four 8-byte writes land on the - // aligned memory path (MEMW_A) instead of the general MEMW path. An `[u8; 32]` on - // the stack is only 1-aligned, which forces the four writes onto the unaligned - // path and inflates the trace. - #[repr(C, align(8))] - struct Aligned32([u8; 32]); - let mut out = Aligned32([0u8; 32]); + let mut out = Align8([0u8; 32]); lambda_vm_syscalls::syscalls::hint(hint_id, &mut out.0, x_be); out.0 } +/// 8-byte-aligned wrapper for an ecall operand buffer, so the table's 8-byte accesses land +/// on the aligned memory path (MEMW_A, 29 columns + 1 range check) instead of the general +/// one (49 + 8). A bare `[u8; N]` on the stack is only 1-aligned, which forces every access +/// onto the unaligned path and inflates the trace. +#[cfg(target_arch = "riscv64")] +#[repr(C, align(8))] +struct Align8([u8; N]); + /// Scalar-field inverse `x⁻¹ mod n`. /// /// On riscv64 the inverse is first requested from the untrusted `hint` ecall and @@ -288,10 +291,11 @@ fn ecsm_ecrecover(sig: &[u8; 64], recid: u8, msg: &[u8; 32]) -> Result<[u8; 64], /// ECSM-accelerated 2-term linear combination `k1·P1 + k2·P2`. /// -/// On riscv64 this reconstructs the full affine result from four x-only ECSM -/// queries (see [`lincomb2_with_oracle`]); on other targets, and whenever a -/// guard trips (degenerate input or oracle inconsistency), it returns `None` -/// so the caller uses the pure-Rust `ProjectivePoint::lincomb`. +/// On riscv64 this uses two ECSM queries (the precompile returns the full `k·P` plus +/// the root it used, see [`lincomb2_with_oracle`]) instead of four x-only queries plus +/// chord-law y-reconstruction; on other targets, and whenever a guard trips (degenerate +/// input or an unusable oracle result), it returns `None` so the caller uses the +/// pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( a1: &AffinePoint, @@ -312,27 +316,41 @@ fn ecsm_lincomb2( None } -/// x-only scalar-mul oracle backed by the ECSM precompile: computes `x(k·P)` -/// for the curve point P whose x-coordinate is passed in. `x` must be the -/// x-coordinate of a curve point and `k` in `(0, N)` (N = curve order) — -/// guaranteed by the guards in [`lincomb2_with_oracle`]. Values cross the ABI -/// as 32-byte little-endian; `x_le` and `k_le` are distinct stack arrays so -/// the executor's `|addr_x_le − addr_k_le| ≥ 32` assumption holds by -/// construction. +/// Scalar-mul oracle backed by the ECSM precompile: for the curve point `P` whose +/// x-coordinate is passed in, returns `(x(k·P̂), y(k·P̂), ŷ)`, where `P̂ = (x, ŷ)` is the root +/// of `x` the chip actually witnessed. The chip is free to pick either root — the AIR binds +/// only `ŷ² ≡ x³ + b` — so the caller resolves the sign from `ŷ` (see +/// [`lincomb2_with_oracle`]). `x` must be the x-coordinate of a curve point and `k` in +/// `(0, N)` (N = curve order), guaranteed by the guards there. +/// +/// Values cross the ABI as 32-byte little-endian; `x_le` and `k_le` are distinct stack +/// arrays so the executor's `|addr_x_le − addr_k_le| ≥ 32` assumption holds by construction. +/// +/// `None` on any coordinate that is not a canonical field element. That parse is load-bearing +/// for `ŷ`, not just hygiene: `p` is odd, so `y` and `p − y` differ in parity, but a value +/// `y + p` (a second 256-bit representative of `y`, possible when `y < 2^256 − p ≈ 2^32`) +/// would carry the *opposite* parity. Rejecting `≥ p` here is what pins `ŷ` to exactly one +/// of the two true roots, and it costs nothing — it is the field-element parse. #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { +fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)> { let x_be = x.to_bytes(); let k_be = k.to_bytes(); - let mut x_le = [0u8; 32]; - let mut k_le = [0u8; 32]; + let mut x_le = Align8([0u8; 32]); + let mut k_le = Align8([0u8; 32]); for i in 0..32 { - x_le[i] = x_be[31 - i]; - k_le[i] = k_be[31 - i]; + x_le.0[i] = x_be[31 - i]; + k_le.0[i] = k_be[31 - i]; } - let mut xr_le = [0u8; 32]; - lambda_vm_syscalls::syscalls::ecsm_mul(&mut xr_le, &x_le, &k_le); - xr_le.reverse(); - Option::from(FieldElement::from_bytes(&xr_le.into())) + let mut out = Align8([0u8; 96]); + lambda_vm_syscalls::syscalls::ecsm_mul(&mut out.0, &x_le.0, &k_le.0); + let load = |chunk: usize| -> Option { + let mut be = [0u8; 32]; + for i in 0..32 { + be[i] = out.0[chunk * 32 + 31 - i]; + } + Option::from(FieldElement::from_bytes(&be.into())) + }; + Some((load(0)?, load(1)?, load(2)?)) } /// Base-field inverse `x⁻¹ mod p`. @@ -384,18 +402,20 @@ where Option::from(x.invert()) } -/// Computes `k1·P1 + k2·P2` from four x-only oracle queries, or `None` if any -/// degenerate-configuration guard trips. +/// Computes `k1·P1 + k2·P2` from two oracle queries, or `None` if a degenerate +/// configuration or an unusable oracle result trips a guard. /// -/// The lambda-vm ECSM precompile returns only `x(k·P)`. For `A = k1·P1` with -/// `P1 = (xp, yp)` fully known, query `xa = x(k1·P1)` and `xc = x((k1+1)·P1)`. -/// The chord-addition law gives `λ² = xc + xa + xp =: t` and `ya = yp + λ·dx` -/// with `dx = xa − xp`; substituting into `ya² = xa³ + b` makes λ *linear*: -/// `λ = (xa³ − xp³ − t·dx²) / (2·yp·dx)`. The wrong sign `−ya` would force -/// `x((k1−1)·P1) = xc`, i.e. `k1 ≡ 0` or `2·k1 ≡ 0 (mod n)`, excluded by the -/// scalar guards. x-only queries are parity-invariant (`x(k·P) = x(k·(−P))`), -/// so the precompile's canonical-y lift never matters. Same for `B = k2·P2`, -/// then `Q = A + B` is one affine addition. All three inversions are batched. +/// The ECSM ecall returns the full point `k·P̂` together with the root `ŷ` it used, and the +/// chip may pick either root of `x(P)` — the AIR binds only `ŷ² ≡ x³ + b`. So `k·P̂ = ±(k·P)`: +/// comparing `ŷ` against the caller's own `y` says which, and one conditional negation +/// recovers `k·P`. `Q = A + B` is then a single chord addition with a single field inversion. +/// +/// The x-only predecessor needed a second query `x((k+1)·P)` per point plus the chord-law +/// y-reconstruction, which is what made `k1 = 1` and `k1 = N−1` degenerate; with `y` in hand +/// those scalars are ordinary. secp256k1 has cofactor 1 and prime `N`, so `k·P ≠ O` for every +/// `k ∈ (0, N)` and no further scalar guard is needed. `dx = 0` still covers both remaining +/// degenerate cases at once (two curve points share an x only when they are equal or +/// negatives), and the caller falls back to the software `lincomb` there. /// /// Generic over the oracle so unit tests can substitute a software stand-in. #[cfg(any(target_arch = "riscv64", test))] @@ -407,45 +427,30 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option, + O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)>, { // Inputs are affine already (the ecrecover path lifts them from known Z=1 // points), so no projective→affine inversion is needed here. if bool::from(a1.is_identity()) || bool::from(a2.is_identity()) { return None; } - if scalar_near_edge(k1) || scalar_near_edge(k2) { + if bool::from(k1.is_zero()) || bool::from(k2.is_zero()) { return None; } let (x1, y1) = affine_xy(a1)?; let (x2, y2) = affine_xy(a2)?; - let xa = oracle(&x1, k1)?; - let xc1 = oracle(&x1, &(*k1 + Scalar::ONE))?; - let xb = oracle(&x2, k2)?; - let xc2 = oracle(&x2, &(*k2 + Scalar::ONE))?; + let (xa, ya) = oracle_point(&x1, &y1, k1, &oracle)?; + let (xb, yb) = oracle_point(&x2, &y2, k2, &oracle)?; - let dx1 = (xa - x1).normalize(); - let dx2 = (xb - x2).normalize(); + // Q = A + B via one chord addition (A ≠ ±B ⇒ dxq ≠ 0). One field inversion. let dxq = (xb - xa).normalize(); - if bool::from(dx1.is_zero()) || bool::from(dx2.is_zero()) || bool::from(dxq.is_zero()) { + if bool::from(dxq.is_zero()) { return None; } - - // One shared inversion for the two λ denominators and the final chord. - let den1 = y1.double() * dx1; - let den2 = y2.double() * dx2; - let inv = field_inv(&(den1 * den2 * dxq))?; - let inv_den1 = inv * den2 * dxq; - let inv_den2 = inv * den1 * dxq; - let inv_dxq = inv * den1 * den2; - - let ya = solve_y(&x1, &y1, &xa, &xc1, &dx1, &inv_den1)?; - let yb = solve_y(&x2, &y2, &xb, &xc2, &dx2, &inv_den2)?; - - // Q = A + B, with A ≠ ±B ensured by dxq ≠ 0. - let lq = (yb - ya) * inv_dxq; + let inv_dxq = field_inv(&dxq)?; + let lq = ((yb - ya) * inv_dxq).normalize(); let xq = (lq.square() - xa - xb).normalize(); let yq = (lq * (xa - xq) - ya).normalize(); @@ -456,36 +461,37 @@ where point_from_xy(&xq, &yq) } -/// Recovers `y(k·P)` from `xa = x(k·P)` and `xc = x((k+1)·P)`. -/// Returns `None` if `xc` is inconsistent with the computed `lambda` -/// (oracle misbehavior); degeneracy guards are in [`lincomb2_with_oracle`]. +/// One oracle query plus the root fix-up: `k·(xp, yp)` in affine coordinates. +/// +/// The oracle multiplied `(xp, ŷ)` for whichever root `ŷ` the chip witnessed, so the result +/// is `k·(xp, yp)` when `ŷ = yp` and `−k·(xp, yp)` when `ŷ = −yp`. Since `ŷ` is canonical +/// (the oracle's field-element parse rejected `≥ p`) and satisfies `ŷ² ≡ xp³ + b`, those are +/// the only two cases; anything else means the oracle did not multiply *this* point, so we +/// return `None` and the caller falls back to software. +/// +/// Compared by value rather than `ct_eq`: k256 compares raw limbs *and* the magnitude and +/// `normalized` tags, so a subtraction result never compares equal to a normalized constant +/// whatever its value. Both operands are `from_bytes` outputs (magnitude 1), which keeps +/// `Sub`'s internal `negate(1)` within its contract; the negated `yr` is re-normalized so +/// the caller's later subtraction stays within it too. #[cfg(any(target_arch = "riscv64", test))] -fn solve_y( +fn oracle_point( xp: &FieldElement, yp: &FieldElement, - xa: &FieldElement, - xc: &FieldElement, - dx: &FieldElement, - inv_den: &FieldElement, -) -> Option { - let t = *xc + xa + xp; - let xa3 = xa.square() * xa; - let xp3 = xp.square() * xp; - let lambda = (xa3 - xp3 - t * dx.square()) * inv_den; - if lambda.square().normalize() != t.normalize() { - return None; + k: &Scalar, + oracle: &O, +) -> Option<(FieldElement, FieldElement)> +where + O: Fn(&FieldElement, &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)>, +{ + let (xr, yr, yg) = oracle(xp, k)?; + if bool::from((*yp - yg).normalizes_to_zero()) { + return Some((xr, yr)); } - Some((*yp + lambda * dx).normalize()) -} - -/// `k ∈ {0, 1, n−1}`: fast early-exit before oracle calls. -/// k=0: invalid ecall scalar. k=1: dx=0. k=n-1: k+1 wraps to 0 mod n. -#[cfg(any(target_arch = "riscv64", test))] -fn scalar_near_edge(k: &Scalar) -> bool { - use k256::elliptic_curve::subtle::ConstantTimeEq; - bool::from(k.is_zero()) - || bool::from(k.ct_eq(&Scalar::ONE)) - || bool::from(k.ct_eq(&(-Scalar::ONE))) + if bool::from((*yp + yg).normalizes_to_zero()) { + return Some((xr, (-yr).normalize())); + } + None } /// Affine `(x, y)` of a non-identity point as field elements, via its SEC1 diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 42e80224b..f884de8e6 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -1,6 +1,6 @@ -//! Tests for the x-only ECSM linear-combination reconstruction +//! Tests for the ECSM linear-combination reconstruction //! (`lincomb2_with_oracle`) against the software `ProjectivePoint::lincomb`, -//! plus the degenerate-configuration fallback guards. +//! plus the root fix-up and the degenerate-configuration fallback guards. use crate::*; @@ -11,15 +11,45 @@ fn curve_b() -> FieldElement { FieldElement::from_bytes(&bytes.into()).unwrap() } -/// Software stand-in for the ECSM precompile: lift `x` to a curve point and -/// return `x(k·P)` (parity-invariant, like the real ecall). -fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option { +fn is_odd(y: &FieldElement) -> bool { + y.normalize().to_bytes()[31] & 1 == 1 +} + +/// Software stand-in for the ECSM precompile, parameterised by which root of `x` the chip +/// witnesses. The real chip is free to pick either (the AIR binds only `yG² ≡ xG³ + b`), so +/// both settings are legal traces and the caller must handle them identically. +/// Returns `(x(k·P̂), y(k·P̂), ŷ)` with `P̂ = (x, ŷ)`. +fn soft_oracle_with_root( + x: &FieldElement, + k: &Scalar, + want_odd_root: bool, +) -> Option<(FieldElement, FieldElement, FieldElement)> { let xn = x.normalize(); let y2 = (xn.square() * xn + curve_b()).normalize(); - let y = Option::::from(y2.sqrt())?; - let p = point_from_xy(&xn, &y.normalize())?; + let y = Option::::from(y2.sqrt())?.normalize(); + let yg = if is_odd(&y) == want_odd_root { + y + } else { + (-y).normalize() + }; + let p = point_from_xy(&xn, &yg)?; let prod = (p * k).to_affine(); - Some(affine_xy(&prod)?.0) + let (xr, yr) = affine_xy(&prod)?; + Some((xr, yr, yg)) +} + +/// The canonical (even-root) lift, matching what `ecsm::recover_y_canonical` produces. +fn soft_oracle(x: &FieldElement, k: &Scalar) -> Option<(FieldElement, FieldElement, FieldElement)> { + soft_oracle_with_root(x, k, false) +} + +/// The other legal choice: the odd root. `k·P̂ = −(k·P)` here, so the caller's sign fix-up +/// is what keeps the answer right. +fn soft_oracle_odd( + x: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement, FieldElement)> { + soft_oracle_with_root(x, k, true) } fn g_times(n: u64) -> ProjectivePoint { @@ -55,23 +85,96 @@ fn matches_software_lincomb_on_recovery_shape() { assert_eq!(got, expected.to_affine()); } +/// The property the echoed root buys: whichever root the chip picks, the reconstruction is +/// the same point. With the odd root every `k·P̂` comes back negated, and only the caller's +/// fix-up puts it right — so an unfixed implementation fails this and passes the one above. +#[test] +fn either_witnessed_root_gives_the_same_result() { + let cases = [ + (g_times(3), 123_456_789u64, g_times(7), 987_654_321u64), + ( + ProjectivePoint::GENERATOR, + 0xdead_beefu64, + g_times(0x1234), + 0x0bad_f00du64, + ), + (g_times(11), 2u64.pow(20) + 5, g_times(2), 42u64), + ]; + for (p1, k1, p2, k2) in cases { + let (k1, k2) = (Scalar::from(k1), Scalar::from(k2)); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2).to_affine(); + let even = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle) + .expect("even-root oracle must reconstruct"); + let odd = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle_odd) + .expect("odd-root oracle must reconstruct"); + assert_eq!(even, expected); + assert_eq!( + odd, expected, + "the root fix-up must absorb the chip's choice" + ); + } +} + +/// `ŷ` that is neither `y` nor `−y` means the oracle did not multiply the caller's point. +/// The caller must decline rather than use the result. #[test] -fn edge_scalars_fall_back() { +fn foreign_root_falls_back() { + let bogus = |x: &FieldElement, k: &Scalar| { + let (xr, yr, _) = soft_oracle(x, k)?; + // A valid field element, but not a root of this x. + let mut bytes = [0u8; 32]; + bytes[31] = 9; + Some((xr, yr, FieldElement::from_bytes(&bytes.into()).unwrap())) + }; let p1 = g_times(3); - let p2 = g_times(5); + let p2 = g_times(7); + let k = Scalar::from(12345u64); + assert!( + lincomb2_with_oracle(&p1.to_affine(), &k, &p2.to_affine(), &k, bogus).is_none(), + "a yG that is neither root must be rejected" + ); +} + +/// `k = 1` and `k = N−1` were degenerate for the x-only predecessor (which needed a second +/// `(k+1)·P` query); with `y` returned they are ordinary scalars. +#[test] +fn former_edge_scalars_now_reconstruct() { + let p1 = g_times(3); + let p2 = g_times(7); let ok = Scalar::from(12345u64); - for bad in [Scalar::ZERO, Scalar::ONE, -Scalar::ONE] { - assert!( - lincomb2_with_oracle(&p1.to_affine(), &bad, &p2.to_affine(), &ok, soft_oracle) - .is_none() - ); - assert!( - lincomb2_with_oracle(&p1.to_affine(), &ok, &p2.to_affine(), &bad, soft_oracle) - .is_none() - ); + for k in [Scalar::ONE, -Scalar::ONE] { + for (a, ka, b, kb) in [(p1, k, p2, ok), (p1, ok, p2, k)] { + let expected = ProjectivePoint::lincomb(&a, &ka, &b, &kb); + let got = lincomb2_with_oracle(&a.to_affine(), &ka, &b.to_affine(), &kb, soft_oracle) + .expect("k = 1 / N−1 are ordinary scalars now"); + assert_eq!(got, expected.to_affine()); + } } } +#[test] +fn zero_scalars_fall_back() { + let p1 = g_times(3); + let p2 = g_times(5); + let ok = Scalar::from(12345u64); + assert!(lincomb2_with_oracle( + &p1.to_affine(), + &Scalar::ZERO, + &p2.to_affine(), + &ok, + soft_oracle + ) + .is_none()); + assert!(lincomb2_with_oracle( + &p1.to_affine(), + &ok, + &p2.to_affine(), + &Scalar::ZERO, + soft_oracle + ) + .is_none()); +} + #[test] fn identity_points_fall_back() { let p = g_times(3); @@ -92,10 +195,8 @@ fn cancelling_and_doubling_terms_fall_back() { #[test] fn k_half_n_minus_1_reconstructs_correctly() { - // k = (n-1)/2 satisfies k·P = -(k+1)·P for any P, so the oracle returns - // the same x-coordinate for both the k and k+1 calls (xa = xc). The - // solve_y algebra still holds: lambda² = 2·xa + xp = t, so the check - // passes and the correct ya is recovered. + // k = (n-1)/2 satisfies k·P = -(k+1)·P for any P. It broke nothing before and it + // breaks nothing now; kept as a regression pin on a scalar with structure. let two_inv = Scalar::from(2u64) .invert_vartime() .expect("2 is invertible mod n"); @@ -107,7 +208,7 @@ fn k_half_n_minus_1_reconstructs_correctly() { let expected = ProjectivePoint::lincomb(&p1, &k_half, &p2, &k2); let got = lincomb2_with_oracle(&p1.to_affine(), &k_half, &p2.to_affine(), &k2, soft_oracle) - .expect("k=(n-1)/2 is not near-edge and must reconstruct correctly"); + .expect("k=(n-1)/2 must reconstruct correctly"); assert_eq!(got, expected.to_affine()); } @@ -129,55 +230,28 @@ fn cross_point_cancellation_falls_back() { ); } -#[test] -fn solve_y_rejects_inconsistent_oracle_xc() { - // Directly test that solve_y's lambda² == t check fires when xc is wrong. - // This is the oracle-misbehavior guard: it cannot easily be reached via - // lincomb2_with_oracle because the oracle is Fn (no mutable state to - // return xa correct and xc wrong in separate calls). - let (xp, yp) = affine_xy(&g_times(3).to_affine()).unwrap(); - let k = Scalar::from(12345u64); - - let xa = soft_oracle(&xp, &k).unwrap(); - let xc_correct = soft_oracle(&xp, &(k + Scalar::ONE)).unwrap(); - // xc from k+100 is inconsistent with xa from k — lambda²=t must reject it. - let xc_wrong = soft_oracle(&xp, &(k + Scalar::from(100u64))).unwrap(); - - let dx = (xa - xp).normalize(); - let inv_den = Option::::from((yp.double() * dx).invert()) - .expect("dx is nonzero for k=12345"); - - assert!( - solve_y(&xp, &yp, &xa, &xc_correct, &dx, &inv_den).is_some(), - "correct xc must pass the lambda² check" - ); - assert!( - solve_y(&xp, &yp, &xa, &xc_wrong, &dx, &inv_den).is_none(), - "inconsistent xc (oracle misbehavior) must be rejected by the lambda² check" - ); -} - #[test] fn odd_y_base_point_reconstructs_correctly() { - // Validates the solve_y sign-selection argument: when P1 has odd y the - // reconstruction must still match ProjectivePoint::lincomb. - let (p1, _k_gen) = (2u64..200) + // A base point with odd y exercises the fix-up from the other side: the caller's own y + // is the odd root, so the canonical-lift oracle is the one that comes back negated. + let p1 = (2u64..200) .find_map(|n| { let p = g_times(n); let (_, y) = affine_xy(&p.to_affine())?; - if y.normalize().to_bytes()[31] & 1 == 1 { - Some((p, n)) - } else { - None - } + is_odd(&y).then_some(p) }) .expect("at least one of the first 200 multiples of G has odd y"); let p2 = g_times(13); let k1 = Scalar::from(54321u64); let k2 = Scalar::from(11111u64); - let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2); - let got = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, soft_oracle) - .expect("odd-y base point is non-degenerate and must reconstruct correctly"); - assert_eq!(got, expected.to_affine()); + let expected = ProjectivePoint::lincomb(&p1, &k1, &p2, &k2).to_affine(); + for oracle in [ + soft_oracle as fn(&FieldElement, &Scalar) -> _, + soft_oracle_odd as fn(&FieldElement, &Scalar) -> _, + ] { + let got = lincomb2_with_oracle(&p1.to_affine(), &k1, &p2.to_affine(), &k2, oracle) + .expect("odd-y base point is non-degenerate and must reconstruct correctly"); + assert_eq!(got, expected); + } } diff --git a/executor/programs/asm/test_ecsm.s b/executor/programs/asm/test_ecsm.s index 67298f810..13968dfb1 100644 --- a/executor/programs/asm/test_ecsm.s +++ b/executor/programs/asm/test_ecsm.s @@ -1,8 +1,9 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Stack layout (96 bytes): xG at sp+0, k at sp+32, xR at sp+64. - addi sp, sp, -96 + # Stack layout (160 bytes): xG at sp+0, k at sp+32, and the ECSM output buffer + # [xR ‖ yR ‖ yG] at sp+64..sp+160. Only xR is committed. + addi sp, sp, -160 # xG = secp256k1 Gx, little-endian (4 doublewords). li t0, 0x59F2815B16F81798 @@ -21,7 +22,7 @@ main: sd zero, 48(sp) sd zero, 56(sp) - # ECSM ecall: a0 = &xR, a1 = &xG, a2 = &k, a7 = -11. + # ECSM ecall: a0 = &out (96 bytes), a1 = &xG, a2 = &k, a7 = -11. addi a0, sp, 64 addi a1, sp, 0 addi a2, sp, 32 @@ -37,7 +38,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/asm/test_ecsm_multi.s b/executor/programs/asm/test_ecsm_multi.s index bc0fcfd23..4885b4aff 100644 --- a/executor/programs/asm/test_ecsm_multi.s +++ b/executor/programs/asm/test_ecsm_multi.s @@ -1,8 +1,9 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Stack layout (96 bytes): xG at sp+0, k at sp+32, xR at sp+64. - addi sp, sp, -96 + # Stack layout (160 bytes): xG at sp+0, k at sp+32, and the ECSM output buffer + # [xR ‖ yR ‖ yG] at sp+64..sp+160. Only xR is committed. + addi sp, sp, -160 # xG = secp256k1 Gx, little-endian (written once; reused by all calls). li t0, 0x59F2815B16F81798 @@ -62,7 +63,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/asm/test_ecsm_split.s b/executor/programs/asm/test_ecsm_split.s index e0e1666ae..4df68155b 100644 --- a/executor/programs/asm/test_ecsm_split.s +++ b/executor/programs/asm/test_ecsm_split.s @@ -1,12 +1,12 @@ .attribute 5, "rv64i2p1_m2p0_zmmul1p0" .globl main main: - # Like test_ecsm.s, but the ECSM pointer registers (a0=&xR, a1=&xG, a2=&k) + # Like test_ecsm.s, but the ECSM pointer registers (a0=&out, a1=&xG, a2=&k) # are set at the very START and never rewritten before the ecall. With a small # continuation epoch size the ecall lands in a LATER epoch than the one that set # the pointers, so the per-epoch touched-cell pass must carry registers across # the boundary to compute the right addresses. - addi sp, sp, -96 + addi sp, sp, -160 addi a0, sp, 64 addi a1, sp, 0 addi a2, sp, 32 @@ -41,7 +41,7 @@ main: ecall # Restore stack and halt. - addi sp, sp, 96 + addi sp, sp, 160 li a0, 0 li a7, 93 ecall diff --git a/executor/programs/bench/ecsm/src/main.rs b/executor/programs/bench/ecsm/src/main.rs index 78549d35b..4d311d05b 100644 --- a/executor/programs/bench/ecsm/src/main.rs +++ b/executor/programs/bench/ecsm/src/main.rs @@ -22,10 +22,13 @@ pub fn main() { ]; k.reverse(); - let mut xr = [0u8; 32]; + // The precompile writes [xR ‖ yR ‖ yG]; the chain feeds xR back as the next base point. + #[repr(C, align(8))] + struct Align8([u8; N]); + let mut out = Align8([0u8; 96]); for _ in 0..ITERATIONS { - syscalls::syscalls::ecsm_mul(&mut xr, &xg, &k); - xg = xr; + syscalls::syscalls::ecsm_mul(&mut out.0, &xg, &k); + xg.copy_from_slice(&out.0[..32]); } - syscalls::syscalls::commit(&xr); + syscalls::syscalls::commit(&out.0[..32]); } diff --git a/executor/programs/rust/ecsm/src/main.rs b/executor/programs/rust/ecsm/src/main.rs index 709d4a4ae..ea553c6d9 100644 --- a/executor/programs/rust/ecsm/src/main.rs +++ b/executor/programs/rust/ecsm/src/main.rs @@ -14,7 +14,11 @@ pub fn main() { let mut k = [0u8; 32]; k[0] = 5; - let mut xr = [0u8; 32]; - syscalls::syscalls::ecsm_mul(&mut xr, &xg, &k); - syscalls::syscalls::commit(&xr); + // The precompile writes [xR ‖ yR ‖ yG]; only xR is committed. 8-byte aligned so the + // twelve doubleword accesses take the aligned memory path (MEMW_A). + #[repr(C, align(8))] + struct Align8([u8; N]); + let mut out = Align8([0u8; 96]); + syscalls::syscalls::ecsm_mul(&mut out.0, &xg, &k); + syscalls::syscalls::commit(&out.0[..32]); } diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 0fa240a8e..ce4e3c11b 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -174,3 +174,78 @@ fn ecsm_syscall_rejects_address_overflow() { ); } } + +/// Runs the ECSM syscall and returns the whole 96-byte output buffer as +/// `(xR, yR, yG)`, all little-endian. +fn run_ecsm_full(k_bytes: &[u8; 32], xg_le: &[u8; 32]) -> Result { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + + let addr_out = 0x1000u64; + let addr_xg = 0x2000u64; + let addr_k = 0x3000u64; + write_u256_le(&mut memory, addr_xg, xg_le); + write_u256_le(&mut memory, addr_k, k_bytes); + + registers.write(17, ECSM_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_out).unwrap(); + registers.write(11, addr_xg).unwrap(); + registers.write(12, addr_k).unwrap(); + + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(( + read_u256_le(&memory, addr_out), + read_u256_le(&memory, addr_out + 32), + read_u256_le(&memory, addr_out + 64), + )) +} + +/// `y² ≡ x³ + 7 (mod p)` for little-endian 32-byte coordinates. +fn on_curve(x_le: &[u8; 32], y_le: &[u8; 32]) -> bool { + let fe = |le: &[u8; 32]| { + let mut be = *le; + be.reverse(); + Option::::from(k256::FieldElement::from_bytes(&be.into())) + }; + let (Some(x), Some(y)) = (fe(x_le), fe(y_le)) else { + return false; + }; + let mut seven = [0u8; 32]; + seven[31] = 7; + let b = k256::FieldElement::from_bytes(&seven.into()).unwrap(); + // Negate `y²` (magnitude 1), not the RHS: the RHS is a sum carrying magnitude 2, and + // k256's `negate(1)` asserts its operand's magnitude is <= 1 in debug builds. + ((x.square() * x + b) + y.square().negate(1)) + .normalizes_to_zero() + .into() +} + +#[test] +fn ecsm_syscall_writes_the_full_96_byte_output() { + let xg = gx_le(); + for v in [1u64, 2, 5, 0xFFFF, 1_000_003] { + let k = k_le(v); + let got = run_ecsm_full(&k, &xg).unwrap(); + assert_eq!(got, ecsm::scalar_mul_full(&k, &xg).unwrap()); + let (xr, yr, yg) = got; + // yG is the root of xG the chip used, and the executor lifts to the even one. + assert!(on_curve(&xg, &yg), "yG must satisfy yG² = xG³ + 7"); + assert_eq!(yg[0] & 1, 0, "the executor lifts xG to its even root"); + // yR is the y of k·(xG, yG), so the result is a curve point too. + assert!(on_curve(&xr, &yr), "yR must satisfy yR² = xR³ + 7"); + } +} + +#[test] +fn ecsm_syscall_output_bound_covers_all_96_bytes() { + // The output spans +0..+95, so its low limb must stay under 2^32 - 95. One past the + // last accepted base is where the 96th byte would cross the limb boundary. + let last_ok = 0x1_0000_0000u64 - 96; + run_ecsm_at(last_ok, 0x2000, 0x3000).expect("+95 lands on the last byte of the limb"); + let err = run_ecsm_at(last_ok + 1, 0x2000, 0x3000).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmAddressOverflow), + "an output whose 96th byte crosses the limb must be rejected" + ); +} diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 592af95e8..33026f6ee 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -519,14 +519,16 @@ impl Instruction { } SyscallNumbers::Ecsm => { // ECSM(-11): k×G on secp256k1. - // x10 = addr to write xR, x11 = addr of xG, x12 = addr of k. - // xG, k, xR are 32-byte little-endian values; xG and xR must be + // x10 = addr of the 96-byte output buffer [xR ‖ yR ‖ yG], + // x11 = addr of xG, x12 = addr of k. + // All six values are 32-byte little-endian; xG and xR must be // canonical field elements and k must be in [1, N). let addr_xr = registers.read(10)?; let addr_xg = registers.read(11)?; let addr_k = registers.read(12)?; + // The output spans +0..+95, so its bound is 95, not 31. if !addr_limb_ok(addr_xg, 31) - || !addr_limb_ok(addr_xr, 31) + || !addr_limb_ok(addr_xr, 95) || !addr_limb_ok(addr_k, 31) { return Err(ExecutionError::EcsmAddressOverflow); @@ -537,14 +539,18 @@ impl Instruction { // both timestamps and the MEMW consistency argument can't prove the // access chain. The loaded values would still be well-defined — this // guard is about trace provability, not correctness of the multiply. - // xR may alias either: its accesses are at a later timestamp. + // The output may alias either (even though it now spans 96 bytes and + // so can cover both): its accesses are at T+2 and T+3, strictly after + // both reads, so every per-address chain stays monotone. if addr_xg.abs_diff(addr_k) < 32 { return Err(ExecutionError::EcsmOperandOverlap); } let xg = load_u256_le(memory, addr_xg)?; let k = load_u256_le(memory, addr_k)?; - let xr = ecsm::scalar_mul_x(&k, &xg)?; + let (xr, yr, yg) = ecsm::scalar_mul_full(&k, &xg)?; store_u256_le(memory, addr_xr, &xr)?; + store_u256_le(memory, addr_xr + 32, &yr)?; + store_u256_le(memory, addr_xr + 64, &yg)?; // Carry addr_xG/addr_k in the CPU log; addr_xR is recovered from x10 // by the ECSM register-read path in the trace builder. src2_val = addr_xg; diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index 746bef91c..84603c09c 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -420,27 +420,46 @@ pub fn bus_interactions() -> Vec { 0, ), )); - // write xR: 4 doublewords at addr_xR + 8i (ts + 2). - for i in 0..4 { - let base_lo = BusValue::linear(vec![ - LinearTerm::Column { - coefficient: 1, - column: cols::ADDR_XR_0, - }, - LinearTerm::Constant((8 * i) as i64), - ]); - out.push(BusInteraction::sender( - BusId::Memw, - mu(), - memw_write( - dword_bytes(cols::XR, i), - base_lo, - packed(cols::ADDR_XR_1), - ts_lo_plus(2), - ts_hi(), - 1, - ), - )); + // Write the 96-byte output buffer [xR ‖ yR ‖ yG] as 12 doublewords at addr_xR + off + 8i. + // + // `yG` is echoed because the chip may witness EITHER root of xG — the AIR binds only + // `yG² ≡ xG³ + b`, so nothing here pins the sign (see the "Two options for y_G" aside in + // `spec/ecsm.typ`). `yR` alone would therefore be ambiguous: it is `±y(k·P)` for the + // caller's own point P. Handing back the root the chip used lets the guest resolve it + // with one comparison, which keeps the root a free choice exactly as the aside argues + // while still exposing a usable y — and costs no column, since `YR` and `YG` are already + // witnessed (YR arrives on the ECDAS bus, YG is proved by the yG convolution). + // + // xR keeps ts + 2 (grouped with the x10 register read); yR and yG take ts + 3, the free + // fourth sub-timestamp of the instruction's stride-4 window. Several doubleword accesses + // may share a timestamp as long as their addresses differ, which they do — the three + // chunks are disjoint 32-byte ranges of one buffer. + for (col, off, ts) in [ + (cols::XR, 0i64, ts_lo_plus(2)), + (cols::YR, 32, ts_lo_plus(3)), + (cols::YG, 64, ts_lo_plus(3)), + ] { + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XR_0, + }, + LinearTerm::Constant(off + (8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + mu(), + memw_write( + dword_bytes(col, i), + base_lo, + packed(cols::ADDR_XR_1), + ts.clone(), + ts_hi(), + 1, + ), + )); + } } // IS_BYTE range checks (single byte → AreBytes[x, 0]). diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index 29874caef..9ab202144 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -862,7 +862,7 @@ fn collect_ecsm_ops( let witness = ::ecsm::compute_witness(&k, &xg) .expect("ECSM witness: executor validates 0 < k < N and xG on curve"); - let mut memw_ops = Vec::with_capacity(15); + let mut memw_ops = Vec::with_capacity(23); // x11 -> addr_xG (register read at T), x12 -> addr_k (register read at T+1). { @@ -926,20 +926,30 @@ fn collect_ecsm_ops( register_state.write(10, val, t + 2); } - // xR writes at T + 2 (4 doublewords). - for i in 0..4 { - let addr = addr_xr.wrapping_add((8 * i) as u64); - let mut value = [0u32; 8]; - let mut dword = 0u64; - for j in 0..8 { - value[j] = witness.x_r[8 * i + j] as u32; - dword |= (witness.x_r[8 * i + j] as u64) << (8 * j); + // Output buffer [xR ‖ yR ‖ yG] — 12 doubleword writes at addr_xR + off + 8i. + // xR at T + 2 (grouped with the x10 register read), yR and yG at T + 3, the free fourth + // sub-timestamp of the stride-4 window. The three chunks are disjoint 32-byte ranges, so + // sharing T + 3 between the last two never touches an address twice. `yG` is echoed so + // the guest can tell which root of xG the chip witnessed; see `ecsm::bus_interactions`. + for (bytes, off, ts) in [ + (&witness.x_r, 0u64, t + 2), + (&witness.y_r, 32, t + 3), + (&witness.y_g, 64, t + 3), + ] { + for i in 0..4 { + let addr = addr_xr.wrapping_add(off).wrapping_add((8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = bytes[8 * i + j] as u32; + dword |= (bytes[8 * i + j] as u64) << (8 * j); + } + let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops.push( + MemwOperation::new(false, addr, value, ts, 8, false).with_old(old_vals, old_ts), + ); + memory_state.write_bytes(addr, dword, 8, ts); } - let (old_vals, old_ts) = memory_state.read_bytes(addr, 8); - memw_ops.push( - MemwOperation::new(false, addr, value, t + 2, 8, false).with_old(old_vals, old_ts), - ); - memory_state.write_bytes(addr, dword, 8, t + 2); } let ecdas_ops = witness diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 5228455ea..3b3592b08 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -176,24 +176,34 @@ pub fn keccak_permute(_state: &mut [u64; 25]) { } #[cfg(target_arch = "riscv64")] -/// Compute `xR = (k·G)_x` on secp256k1 via the ECSM accelerator. All values are 32-byte -/// little-endian. Requires `0 < k < N` and a canonical valid `xG` curve coordinate. -/// `xG` and `k` must not overlap; `xR` may alias either input. -pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { +/// Compute `k·G` on secp256k1 via the ECSM accelerator, writing `[xR ‖ yR ‖ yG]` as three +/// contiguous 32-byte little-endian values into `out`. Requires `0 < k < N` and a canonical +/// valid `xG` curve coordinate. `xG` and `k` must not overlap; `out` may alias either input. +/// +/// `yG` is the root of `xG` the chip actually used, and the chip is free to pick either one +/// (the AIR binds only `yG² ≡ xG³ + b`). So `yR` is the y of `k·(xG, yG)`, which is `±y(k·P)` +/// for the caller's point `P`. Compare `yG` against your own base point's y and negate `yR` +/// when they differ; that also validates `yG`, since a value that is neither root means the +/// output is unusable and the caller should fall back. +/// +/// `out` should be 8-byte aligned so the twelve doubleword accesses land on the aligned +/// memory path (MEMW_A) instead of the general one; the same goes for `xg` and `k`. +pub fn ecsm_mul(out: &mut [u8; 96], xg: &[u8; 32], k: &[u8; 32]) { unsafe { asm!( "ecall", - in("a0") xr.as_mut_ptr(), // x10 = address to write xR - in("a1") xg.as_ptr(), // x11 = address of xG - in("a2") k.as_ptr(), // x12 = address of k + in("a0") out.as_mut_ptr(), // x10 = address to write [xR ‖ yR ‖ yG] + in("a1") xg.as_ptr(), // x11 = address of xG + in("a2") k.as_ptr(), // x12 = address of k in("a7") ECSM_SYSCALL_NUMBER, ) } } #[cfg(not(target_arch = "riscv64"))] -/// Compute `xR = (k·G)_x` on secp256k1 via the ECSM accelerator (32-byte little-endian values). -pub fn ecsm_mul(_xr: &mut [u8; 32], _xg: &[u8; 32], _k: &[u8; 32]) { +/// Compute `k·G` on secp256k1 via the ECSM accelerator, writing `[xR ‖ yR ‖ yG]` +/// (three 32-byte little-endian values) into `out`. +pub fn ecsm_mul(_out: &mut [u8; 96], _xg: &[u8; 32], _k: &[u8; 32]) { unimplemented!("syscalls are only implemented for riscv64 targets"); }