diff --git a/crypto/ecsm/src/curve.rs b/crypto/ecsm/src/curve.rs index c5c9f5714..1ef5954c2 100644 --- a/crypto/ecsm/src/curve.rs +++ b/crypto/ecsm/src/curve.rs @@ -153,11 +153,19 @@ fn schedule(k: &BigUint) -> Vec<(u8, u8, u8)> { /// 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 { + scalar_mul_affine(k, g).x +} + +/// Executor fast path: the full affine point `k·g`, so the `ecsm_mul_affine` syscall can +/// hand `y` back to the guest. `g` is whatever point the caller prepared — the even-`y` lift +/// of `xG` on the x-only path, the caller's own input point on the affine one — and `k·g`'s +/// y matches the ECDAS-constrained `y_r` either way. +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) } /// 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..d69340e5c 100644 --- a/crypto/ecsm/src/lib.rs +++ b/crypto/ecsm/src/lib.rs @@ -24,7 +24,7 @@ mod tests; use num_bigint::BigUint; pub use curve::{AffinePoint, recover_y_canonical, replay_double_and_add}; -pub use witness::{EcdasStep, EcsmWitness, compute_witness}; +pub use witness::{EcdasStep, EcsmWitness, compute_witness, compute_witness_with_y}; /// secp256k1 curve coefficient `b`. pub const B: u64 = 7; @@ -65,11 +65,14 @@ pub enum EcsmError { ScalarIsZero, /// `k >= N`: outside the valid scalar range `[1, N)`. ScalarOutOfRange, - /// `x^3 + b` is not a quadratic residue, so `xG` is not a valid x-coordinate. + /// The input point is not on the curve: on the x-only path `x³ + b` is not a quadratic + /// residue, so `xG` is not a valid x-coordinate; on the affine path the caller's own + /// `yG` fails `yG² ≡ xG³ + b`. NotOnCurve, - /// `xG >= p`: not a canonical field element. Reducing it silently would - /// diverge from the prover, whose `xR < p` range check makes a non-canonical - /// input unprovable (with `k = 1` the input is echoed back as `xR`). + /// A coordinate is `>= p`, so it is not a canonical field element — `xG` on either path, + /// `yG` on the affine one. Reducing it silently would diverge from the prover, whose + /// `xR < p` / `yR < p` range checks make a non-canonical input unprovable (with `k = 1` + /// the x-only input is echoed back as `xR`). CoordinateOutOfRange, } @@ -78,8 +81,8 @@ impl core::fmt::Display for EcsmError { match self { EcsmError::ScalarIsZero => write!(f, "ECSM scalar k must be non-zero"), EcsmError::ScalarOutOfRange => write!(f, "ECSM scalar k must be < N"), - EcsmError::NotOnCurve => write!(f, "ECSM xG is not a valid curve x-coordinate"), - EcsmError::CoordinateOutOfRange => write!(f, "ECSM xG must be < p"), + EcsmError::NotOnCurve => write!(f, "ECSM input point is not on the curve"), + EcsmError::CoordinateOutOfRange => write!(f, "ECSM coordinates must be < p"), } } } @@ -119,6 +122,50 @@ pub(crate) fn prepare( Ok((k, AffinePoint { x: xg, y: yg })) } +/// Like [`prepare`] but takes an explicit `yG` (the caller's full input point) instead of +/// lifting `xG` to the canonical even root. Validates `0 < k < N`, `xG < p`, `yG < p`, and +/// that `(xG, yG)` is on the curve (`yG² ≡ xG³ + b mod p`). Used by the affine path so the +/// returned `yR` matches the caller's actual point (no parity convention / guest-side sign +/// flip). `yG`'s value is pinned in the prover by a memory read of the caller's input. +pub(crate) fn prepare_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<(BigUint, AffinePoint), EcsmError> { + let k = BigUint::from_bytes_le(k_le); + if k == BigUint::from(0u8) { + return Err(EcsmError::ScalarIsZero); + } + if k >= n() { + return Err(EcsmError::ScalarOutOfRange); + } + let p = p(); + let xg = BigUint::from_bytes_le(xg_le); + let yg = BigUint::from_bytes_le(yg_le); + if xg >= p || yg >= p { + return Err(EcsmError::CoordinateOutOfRange); + } + // On-curve: yG² ≡ xG³ + b (mod p). + let lhs = (&yg * &yg) % &p; + let rhs = (&xg * &xg % &p * &xg + BigUint::from(B)) % &p; + if lhs != rhs { + return Err(EcsmError::NotOnCurve); + } + Ok((k, AffinePoint { x: xg, y: yg })) +} + +/// Affine entry point with an explicit input `yG`: both coordinates of `k·(xG, yG)` as +/// little-endian 32-byte values. The executor writes `xR` then `yR` back (64-byte output). +pub fn scalar_mul_xy_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<([u8; 32], [u8; 32]), EcsmError> { + let (k, g) = prepare_with_y(k_le, xg_le, yg_le)?; + let r = curve::scalar_mul_affine(&k, &g); + Ok((to_le_32(&r.x), to_le_32(&r.y))) +} + /// 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`. diff --git a/crypto/ecsm/src/witness.rs b/crypto/ecsm/src/witness.rs index 28b971383..d412ab8aa 100644 --- a/crypto/ecsm/src/witness.rs +++ b/crypto/ecsm/src/witness.rs @@ -23,7 +23,7 @@ use num_traits::{Signed, Zero}; use rayon::prelude::*; use crate::curve::{StepPts, replay_double_and_add}; -use crate::{B, EcsmError, P_BYTES, R_BYTES, n, p, prepare, to_le_32}; +use crate::{B, EcsmError, P_BYTES, R_BYTES, n, p, prepare, prepare_with_y, to_le_32}; /// Full ECSM-chip witness for one scalar multiplication (one ECSM row). #[derive(Debug, Clone)] @@ -47,6 +47,11 @@ pub struct EcsmWitness { pub k_sub_n: [u8; 32], /// `(xR - p) mod 2^256` pub x_r_sub_p: [u8; 32], + /// `(yR - p) mod 2^256`. Forces `yR < p`: without it the byte range checks only + /// bound `yR < 2^256`, and the quotient columns absorb a multiple of `p`, so a + /// witness could publish `yR + p` for any `yR < 2^256 - p` (~2^32) — points with + /// such a tiny `y` are constructible, since `3 | p-1` makes cubing 3-to-1. + pub y_r_sub_p: [u8; 32], /// position of the most significant set bit of `k` pub len_k: u8, pub x_r: [u8; 32], @@ -280,7 +285,26 @@ fn shifted_quotient(relation: &str, numerator: &BigInt, p_big: &BigInt, r_big: & /// little-endian 32-byte values. This is the prover's entry point. pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result { let (k, g) = prepare(k_le, xg_le)?; + compute_witness_inner(k_le, k, g) +} + +/// Like [`compute_witness`] but with an explicit input `yG` (the caller's full point), +/// validated on-curve by [`prepare_with_y`]. The affine path uses this so the witnessed +/// `yG`/`yR` match the caller's actual point rather than the canonical even lift. +pub fn compute_witness_with_y( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result { + let (k, g) = prepare_with_y(k_le, xg_le, yg_le)?; + compute_witness_inner(k_le, k, g) +} +fn compute_witness_inner( + k_le: &[u8; 32], + k: BigUint, + g: crate::curve::AffinePoint, +) -> Result { let p_big = BigInt::from(p()); let r_big = BigInt::from(BigUint::from_bytes_le(&R_BYTES)); // r = 3p @@ -328,6 +352,7 @@ pub fn compute_witness(k_le: &[u8; 32], xg_le: &[u8; 32]) -> Result Result 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 affine ECSM queries (the precompile returns `(x, y)`, +/// see [`lincomb2_with_oracle`]) instead of four x-only queries plus chord-law +/// y-reconstruction; on other targets, and whenever a guard trips, it returns +/// `None` so the caller uses the pure-Rust `ProjectivePoint::lincomb`. #[cfg(target_arch = "riscv64")] fn ecsm_lincomb2( a1: &AffinePoint, @@ -309,27 +309,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. +/// AFFINE oracle backed by the ECSM precompile: computes the full point `k·(x, y)` for the +/// caller's actual input point `(x, y)`. Returns `(xR, yR)` as normalized field elements — +/// no parity convention or sign flip, because the precompile receives the real `y` and the +/// prover pins it by a memory read. `(x, y)` must be a curve point and `k` in `(0, N)`. +/// Values cross the ABI as 32-byte little-endian; `input` is a 64-byte `[xG‖yG]` buffer, +/// `out` a 64-byte `[xR‖yR]` buffer and `k_le` a distinct 32-byte array, so the two +/// operand ranges are disjoint as the executor requires (it tests the ranges, not their +/// distance, so either stack layout is fine). #[cfg(target_arch = "riscv64")] -fn ecsm_oracle(x: &FieldElement, k: &Scalar) -> Option { +fn ecsm_oracle( + x: &FieldElement, + y: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement)> { let x_be = x.to_bytes(); + let y_be = y.to_bytes(); let k_be = k.to_bytes(); - let mut x_le = [0u8; 32]; + let mut input = [0u8; 64]; let mut k_le = [0u8; 32]; for i in 0..32 { - x_le[i] = x_be[31 - i]; + input[i] = x_be[31 - i]; + input[32 + i] = y_be[31 - i]; k_le[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 = [0u8; 64]; + lambda_vm_syscalls::syscalls::ecsm_mul_affine(&mut out, &input, &k_le); + let mut xr_be = [0u8; 32]; + let mut yr_be = [0u8; 32]; + for i in 0..32 { + xr_be[i] = out[31 - i]; + yr_be[i] = out[32 + 31 - i]; + } + let xr = Option::::from(FieldElement::from_bytes(&xr_be.into()))?; + let yr = Option::::from(FieldElement::from_bytes(&yr_be.into()))?; + Some((xr.normalize(), yr.normalize())) } /// Base-field inverse `x⁻¹ mod p`. @@ -381,18 +395,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 affine oracle queries, or `None` if a +/// degenerate configuration 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 affine ECSM ecall returns the full point, so `A = k1·P1` and `B = k2·P2` +/// each cost one query and `Q = A + B` is a single chord addition — one field +/// inversion, for `1/(xb − xa)`. `dx = 0` covers both degenerate cases at once +/// (two curve points share an x only when they are equal or negatives), so the +/// caller falls back to the software `lincomb` there. +/// +/// The x-only predecessor needed a second query `x((k+1)·P)` per point to solve +/// for `y` through the chord-addition law, which is what made `k1 = 1` and +/// `k1 = N−1` degenerate; with `y` supplied by the chip 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. /// /// Generic over the oracle so unit tests can substitute a software stand-in. #[cfg(any(target_arch = "riscv64", test))] @@ -404,45 +420,33 @@ fn lincomb2_with_oracle( oracle: O, ) -> Option where - O: Fn(&FieldElement, &Scalar) -> Option, + O: Fn(&FieldElement, &FieldElement, &Scalar) -> Option<(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))?; + // The oracle receives the full point (x, y) and returns k·(x, y) directly — no parity + // convention or sign flip, since the precompile gets the real y (pinned in the prover + // by a memory read). + let (xa, ya) = oracle(&x1, &y1, k1)?; + let (xb, yb) = oracle(&x2, &y2, k2)?; - 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(); @@ -453,38 +457,6 @@ 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`]. -#[cfg(any(target_arch = "riscv64", test))] -fn solve_y( - 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; - } - 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))) -} - /// Affine `(x, y)` of a non-identity point as field elements, via its SEC1 /// uncompressed encoding (k256 keeps `AffinePoint`'s coordinate fields private). #[cfg(any(target_arch = "riscv64", test))] diff --git a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs index 42e80224b..f10259f50 100644 --- a/crypto/ethrex-crypto/src/tests/ecsm_tests.rs +++ b/crypto/ethrex-crypto/src/tests/ecsm_tests.rs @@ -1,25 +1,21 @@ -//! Tests for the x-only ECSM linear-combination reconstruction -//! (`lincomb2_with_oracle`) against the software `ProjectivePoint::lincomb`, -//! plus the degenerate-configuration fallback guards. +//! Tests for the ECSM linear combination (`lincomb2_with_oracle`) against the +//! software `ProjectivePoint::lincomb`, plus the degenerate-configuration +//! fallback guards. use crate::*; -/// secp256k1 curve constant `b = 7`. -fn curve_b() -> FieldElement { - let mut bytes = [0u8; 32]; - bytes[31] = 7; - 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 { - 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())?; +/// Software stand-in for the affine ECSM precompile: form the curve point `(x, y)` from +/// the caller's actual coordinates and return `(xR, yR)` of `k·(x, y)`. No parity +/// convention — the real ecall receives the full input point too. +fn soft_oracle( + x: &FieldElement, + y: &FieldElement, + k: &Scalar, +) -> Option<(FieldElement, FieldElement)> { + let p = point_from_xy(&x.normalize(), &y.normalize())?; let prod = (p * k).to_affine(); - Some(affine_xy(&prod)?.0) + let (xr, yr) = affine_xy(&prod)?; + Some((xr.normalize(), yr.normalize())) } fn g_times(n: u64) -> ProjectivePoint { @@ -57,18 +53,25 @@ fn matches_software_lincomb_on_recovery_shape() { #[test] fn edge_scalars_fall_back() { + // Only k=0 falls back. The old x-only path also rejected k=1 and k=n−1 (the + // (k+1)·P query wrapped); the affine oracle makes no such query, so those + // scalars reconstruct normally. let p1 = g_times(3); let p2 = g_times(5); 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() - ); + let bad = Scalar::ZERO; + 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() + ); + // k=1 and k=n−1 now reconstruct correctly. + for good in [Scalar::ONE, -Scalar::ONE] { + let expected = ProjectivePoint::lincomb(&p1, &good, &p2, &ok); + let got = lincomb2_with_oracle(&p1.to_affine(), &good, &p2.to_affine(), &ok, soft_oracle) + .expect("k=1 / k=n−1 are valid for the affine oracle"); + assert_eq!(got, expected.to_affine()); } } @@ -92,10 +95,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 was a special case for the old x-only path; with the affine + // oracle it is an ordinary scalar and must reconstruct correctly. let two_inv = Scalar::from(2u64) .invert_vartime() .expect("2 is invertible mod n"); @@ -129,38 +130,11 @@ 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. + // A base point with odd y needs no special handling: the affine oracle receives the + // caller's actual y and returns the actual k·P, so there is no even-y convention to + // undo. Pins that, by checking the result against ProjectivePoint::lincomb. let (p1, _k_gen) = (2u64..200) .find_map(|n| { let p = g_times(n); diff --git a/executor/programs/rust/ecsm_affine/.cargo/config.toml b/executor/programs/rust/ecsm_affine/.cargo/config.toml new file mode 100644 index 000000000..ca99a3f45 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/.cargo/config.toml @@ -0,0 +1,5 @@ +[target.riscv64im-lambda-vm-elf] +rustflags = [ + "--cfg", "getrandom_backend=\"custom\"", + "-C", "passes=lower-atomic" +] diff --git a/executor/programs/rust/ecsm_affine/Cargo.lock b/executor/programs/rust/ecsm_affine/Cargo.lock new file mode 100644 index 000000000..dc4399287 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/Cargo.lock @@ -0,0 +1,331 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "const-default" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b396d1f76d455557e1218ec8066ae14bba60b4b36ecd55577ba979f5db7ecaa" + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "ecsm_affine" +version = "0.1.0" +dependencies = [ + "lambda-vm-syscalls", +] + +[[package]] +name = "embedded-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f2de9133f68db0d4627ad69db767726c99ff8585272716708227008d3f1bddd" +dependencies = [ + "const-default", + "critical-section", + "linked_list_allocator", + "rlsf", +] + +[[package]] +name = "embedded-hal" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "361a90feb7004eca4019fb28352a9465666b24f840f5c3cddf0ff13920590b89" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", +] + +[[package]] +name = "lambda-vm-syscalls" +version = "0.1.0" +dependencies = [ + "embedded-alloc", + "getrandom 0.2.17", + "getrandom 0.3.4", + "lazy_static", + "rand", + "riscv", + "thiserror", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linked_list_allocator" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b23ac50abb8261cb38c6e2a7192d3302e0836dac1628f6a93b82b4fad185897" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "riscv" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b05cfa3f7b30c84536a9025150d44d26b8e1cc20ddf436448d74cd9591eefb25" +dependencies = [ + "critical-section", + "embedded-hal", + "paste", + "riscv-macros", + "riscv-pac", +] + +[[package]] +name = "riscv-macros" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d323d13972c1b104aa036bc692cd08b822c8bbf23d79a27c526095856499799" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "riscv-pac" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8188909339ccc0c68cfb5a04648313f09621e8b87dc03095454f1a11f6c5d436" + +[[package]] +name = "rlsf" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1646a59a9734b8b7a0ac51689388a60fe1625d4b956348e9de07591a1478457a" +dependencies = [ + "cfg-if", + "const-default", + "libc", + "rustversion", + "svgbobdoc", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "svgbobdoc" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2c04b93fc15d79b39c63218f15e3fdffaa4c227830686e3b7c5f41244eb3e50" +dependencies = [ + "base64", + "proc-macro2", + "quote", + "syn 1.0.109", + "unicode-width", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e5361301a1d9e5dd94c524eb99365fbaed5b237e831d7f45e2ddea11ffe8627" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.51" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "422033a2245cb4b6ff8def11b2dfaf184a2ab2573f5af28082a163a68889af0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] diff --git a/executor/programs/rust/ecsm_affine/Cargo.toml b/executor/programs/rust/ecsm_affine/Cargo.toml new file mode 100644 index 000000000..3bd8703a0 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/Cargo.toml @@ -0,0 +1,9 @@ +[workspace] + +[package] +name = "ecsm_affine" +version = "0.1.0" +edition = "2024" + +[dependencies] +lambda-vm-syscalls = { path = "../../../../syscalls" } diff --git a/executor/programs/rust/ecsm_affine/src/main.rs b/executor/programs/rust/ecsm_affine/src/main.rs new file mode 100644 index 000000000..46aa857a4 --- /dev/null +++ b/executor/programs/rust/ecsm_affine/src/main.rs @@ -0,0 +1,32 @@ +use lambda_vm_syscalls as syscalls; + +/// Computes 5·G on secp256k1 via the **affine** ECSM precompile (`ecsm_mul_affine`) and +/// commits the 64-byte result point `xR‖yR` as public output. Exercises the affine ecall +/// end-to-end (IS_AFFINE=1: yG read from memory, yR written back). +pub fn main() { + // secp256k1 generator (Gx, Gy), big-endian then reversed to little-endian. + let mut gx: [u8; 32] = [ + 0x79, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87, 0x0B, + 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, + 0x17, 0x98, + ]; + gx.reverse(); + let mut gy: [u8; 32] = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + gy.reverse(); + + // Full input point as a contiguous 64-byte buffer: xG at [0..32], yG at [32..64]. + let mut input = [0u8; 64]; + input[..32].copy_from_slice(&gx); + input[32..].copy_from_slice(&gy); + + let mut k = [0u8; 32]; + k[0] = 5; + + let mut out = [0u8; 64]; + syscalls::syscalls::ecsm_mul_affine(&mut out, &input, &k); + syscalls::syscalls::commit(&out); +} diff --git a/executor/src/tests/ecsm_tests.rs b/executor/src/tests/ecsm_tests.rs index 0fa240a8e..3a80abc08 100644 --- a/executor/src/tests/ecsm_tests.rs +++ b/executor/src/tests/ecsm_tests.rs @@ -1,7 +1,9 @@ //! Tests for the ECSM (elliptic-curve scalar multiplication) syscall. use crate::vm::instruction::decoding::Instruction; -use crate::vm::instruction::execution::{ECSM_SYSCALL_NUMBER, ExecutionError}; +use crate::vm::instruction::execution::{ + ECSM_AFFINE_SYSCALL_NUMBER, ECSM_SYSCALL_NUMBER, ExecutionError, +}; use crate::vm::memory::Memory; use crate::vm::registers::Registers; @@ -16,6 +18,17 @@ fn gx_le() -> [u8; 32] { be } +/// secp256k1 generator y-coordinate, little-endian. +fn gy_le() -> [u8; 32] { + let mut be = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + be.reverse(); + be +} + fn write_u256_le(memory: &mut Memory, addr: u64, bytes: &[u8; 32]) { for i in 0..4 { let mut dw = [0u8; 8]; @@ -63,6 +76,60 @@ fn k_le(v: u64) -> [u8; 32] { k } +/// Runs the AFFINE ECSM syscall (full point in/out) with the given scalar, `xG` and `yG`, +/// returning the `(xR, yR)` written back to the contiguous 64-byte output buffer. +fn run_ecsm_affine( + k_le: &[u8; 32], + xg_le: &[u8; 32], + yg_le: &[u8; 32], +) -> Result<([u8; 32], [u8; 32]), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + + let addr_xr = 0x1000u64; // output xR‖yR (64 bytes) + let addr_xg = 0x2000u64; // input xG‖yG (64 bytes) + let addr_k = 0x3000u64; + write_u256_le(&mut memory, addr_xg, xg_le); + write_u256_le(&mut memory, addr_xg + 32, yg_le); + write_u256_le(&mut memory, addr_k, k_le); + + registers.write(17, ECSM_AFFINE_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_xr).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_xr), + read_u256_le(&memory, addr_xr + 32), + )) +} + +#[test] +fn ecsm_affine_syscall_writes_both_coords() { + let (xg, yg) = (gx_le(), gy_le()); + for v in [1u64, 2, 3, 5, 0xFFFF, 1_000_003] { + let (xr, yr) = run_ecsm_affine(&k_le(v), &xg, &yg).unwrap(); + let (exr, eyr) = ecsm::scalar_mul_xy_with_y(&k_le(v), &xg, &yg).unwrap(); + assert_eq!(xr, exr, "xR mismatch for k = {v}"); + assert_eq!(yr, eyr, "yR mismatch for k = {v}"); + } +} + +#[test] +fn ecsm_affine_syscall_rejects_point_not_on_curve() { + // A valid xG paired with the wrong yG (here yG = Gy of a different point) must be + // rejected on-curve, unlike the x-only variant which lifts its own canonical y. + let mut yg = gy_le(); + yg[0] ^= 1; // perturb so (xG, yG) is no longer on the curve + let err = run_ecsm_affine(&k_le(5), &gx_le(), &yg).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::NotOnCurve) + )); +} + #[test] fn ecsm_syscall_writes_correct_result() { let xg = gx_le(); @@ -122,6 +189,83 @@ fn ecsm_syscall_rejects_xg_not_on_curve() { )); } +#[test] +fn ecsm_affine_syscall_rejects_non_canonical_yg() { + // yG = p is a non-canonical zero. The prover reads yG straight out of the caller's + // buffer, so nothing downstream would reduce it — the executor must reject it. + let err = run_ecsm_affine(&k_le(5), &gx_le(), &ecsm::P_BYTES).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::CoordinateOutOfRange) + )); +} + +#[test] +fn ecsm_affine_syscall_rejects_zero_scalar() { + let err = run_ecsm_affine(&k_le(0), &gx_le(), &gy_le()).unwrap_err(); + assert!(matches!( + err, + ExecutionError::Ecsm(ecsm::EcsmError::ScalarIsZero) + )); +} + +/// Runs the affine ECSM syscall with caller-chosen operand addresses, input point `G` +/// and `k = 5`. +fn run_ecsm_affine_at(addr_xr: u64, addr_xg: u64, addr_k: u64) -> Result<(), ExecutionError> { + let mut pc = 0; + let mut registers = Registers::default(); + let mut memory = Memory::default(); + write_u256_le(&mut memory, addr_xg, &gx_le()); + write_u256_le(&mut memory, addr_xg.wrapping_add(32), &gy_le()); + write_u256_le(&mut memory, addr_k, &k_le(5)); + registers.write(17, ECSM_AFFINE_SYSCALL_NUMBER).unwrap(); + registers.write(10, addr_xr).unwrap(); + registers.write(11, addr_xg).unwrap(); + registers.write(12, addr_k).unwrap(); + Instruction::EcallEbreak.run(&mut pc, &mut registers, &mut memory)?; + Ok(()) +} + +#[test] +fn ecsm_affine_syscall_rejects_overlapping_point_k() { + // The input point spans 64 bytes, so any k landing inside [xG, xG + 64) is read at + // both T and T+1 and makes the trace unprovable. + for addr_k in [0x2000u64, 0x2008, 0x2020, 0x2038] { + let err = run_ecsm_affine_at(0x1000, 0x2000, addr_k).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmOperandOverlap), + "addr_k = {addr_k:#x} overlaps the 64-byte input point and must be rejected" + ); + } + // Disjoint is disjoint regardless of distance: k directly below the point is 32 bytes + // away, which a `|diff| >= 64` bound would have rejected. Guest stack layouts produce + // exactly this case, so it must run. + run_ecsm_affine_at(0x1000, 0x2000, 0x1FE0).expect("k immediately below the point must run"); + run_ecsm_affine_at(0x1000, 0x2000, 0x2040).expect("k immediately above the point must run"); + // The output may alias either operand: its accesses are at later timestamps. + run_ecsm_affine_at(0x2000, 0x2000, 0x3000).expect("xR aliasing the input point is allowed"); + run_ecsm_affine_at(0x3000, 0x2000, 0x3000).expect("xR aliasing k is allowed"); +} + +#[test] +fn ecsm_affine_syscall_rejects_address_overflow() { + // Point and output span offset 63 (not 31), so their last accessed byte must stay in + // the limb: 0xFFFF_FFE8 fits a 32-byte operand but not a 64-byte one. + for (addr_xr, addr_xg, addr_k) in [ + (0xFFFF_FFE8, 0x2000, 0x3000), + (0x1000, 0xFFFF_FFE8, 0x3000), + (0xFFFF_FFC8, 0x2000, 0x3000), + (0x1000, 0xFFFF_FFC8, 0x3000), + (0x1000, 0x2000, 0xFFFF_FFF0), + ] { + let err = run_ecsm_affine_at(addr_xr, addr_xg, addr_k).unwrap_err(); + assert!( + matches!(err, ExecutionError::EcsmAddressOverflow), + "expected address overflow for xR={addr_xr:#x}, point={addr_xg:#x}, k={addr_k:#x}" + ); + } +} + /// Runs the ECSM syscall with caller-chosen operand addresses, `xG = Gx` and `k = 5`. fn run_ecsm_at(addr_xr: u64, addr_xg: u64, addr_k: u64) -> Result<(), ExecutionError> { let mut pc = 0; diff --git a/executor/src/vm/instruction/execution.rs b/executor/src/vm/instruction/execution.rs index 647dd0079..6253f6bcd 100644 --- a/executor/src/vm/instruction/execution.rs +++ b/executor/src/vm/instruction/execution.rs @@ -19,6 +19,8 @@ pub enum SyscallNumbers { // Placeholder discriminant. The actual syscall value is HINT_SYSCALL_NUMBER. // Non-constraining hint (host computes modular inverse/sqrt, guest verifies). Hint = 95, + // Placeholder discriminant. The actual syscall value is ECSM_AFFINE_SYSCALL_NUMBER. + EcsmAffine = 96, } /// Syscall number for KeccakPermute (u64::MAX - 1 = 0xFFFF_FFFF_FFFF_FFFE). @@ -32,8 +34,19 @@ const KECCAK_STATE_BYTES: u64 = 25 * 8; /// The spec uses ECALL number `-11`; interpreted as an unsigned 64-bit value that is /// `u64::MAX - 10 = 0xFFFF_FFFF_FFFF_FFF5`, which the ECSM core table puts on the `Ecall` /// bus as `[lo32, hi32] = [2^32 - 11, 2^32 - 1]`. +/// +/// This is the **x-only** variant: the guest passes only `xG` (32 bytes) and gets back only +/// the x-coordinate `xR` (32 bytes). See [`ECSM_AFFINE_SYSCALL_NUMBER`] for the affine variant. pub const ECSM_SYSCALL_NUMBER: u64 = u64::MAX - 10; +/// Syscall number for the **affine** ECSM accelerator variant. +/// +/// `u64::MAX - 11 = 0xFFFF_FFFF_FFFF_FFF4`. The guest passes the full point `xG‖yG` +/// (contiguous 64 bytes) and gets back both coordinates `xR‖yR` (contiguous 64 bytes), so +/// ECDSA recovery skips the x-only `(k+1)·P` y-reconstruction. The ECSM core table selects +/// the mode with an `IS_AFFINE` column pinned to this number via the `Ecall` bus. +pub const ECSM_AFFINE_SYSCALL_NUMBER: u64 = u64::MAX - 11; + /// Syscall number for the non-constraining `Hint` ecall. /// /// The host computes a modular inverse or square root and writes it back to the @@ -62,6 +75,7 @@ impl TryFrom for SyscallNumbers { 93 => Ok(SyscallNumbers::Halt), v if v == KECCAK_SYSCALL_NUMBER => Ok(SyscallNumbers::KeccakPermute), v if v == ECSM_SYSCALL_NUMBER => Ok(SyscallNumbers::Ecsm), + v if v == ECSM_AFFINE_SYSCALL_NUMBER => Ok(SyscallNumbers::EcsmAffine), v if v == HINT_SYSCALL_NUMBER => Ok(SyscallNumbers::Hint), _ => Err(()), } @@ -82,7 +96,7 @@ impl SyscallNumbers { pub fn accelerator(self) -> Option { match self { SyscallNumbers::KeccakPermute => Some(Accelerator::Keccak), - SyscallNumbers::Ecsm => Some(Accelerator::Ecsm), + SyscallNumbers::Ecsm | SyscallNumbers::EcsmAffine => Some(Accelerator::Ecsm), SyscallNumbers::Print | SyscallNumbers::Panic | SyscallNumbers::Commit @@ -492,7 +506,7 @@ impl Instruction { src2_val = state_addr; } SyscallNumbers::Ecsm => { - // ECSM(-11): k×G on secp256k1. + // ECSM(-11), x-only: (k·G)_x 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 // canonical field elements and k must be in [1, N). @@ -509,8 +523,7 @@ impl Instruction { // reads each operand as unaligned doubleword MEMW accesses (xG at T, // k at T+1); if the regions overlap, the same address is touched at // 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. + // access chain. The guard is about trace provability, not correctness. // xR may alias either: its accesses are at a later timestamp. if addr_xg.abs_diff(addr_k) < 32 { return Err(ExecutionError::EcsmOperandOverlap); @@ -524,6 +537,52 @@ impl Instruction { src2_val = addr_xg; dst_val = addr_k; } + SyscallNumbers::EcsmAffine => { + // ECSM affine: both coordinates of k·(xG, yG) on secp256k1. + // x10 = addr to write xR‖yR, x11 = addr of xG‖yG, x12 = addr of k. + // Input and output are contiguous 64-byte buffers; k is 32B. xG/yG/xR + // must be canonical field elements, (xG, yG) on curve, k in [1, N). + let addr_xr = registers.read(10)?; + let addr_xg = registers.read(11)?; + let addr_k = registers.read(12)?; + // Both the input (xG‖yG) and the output (xR‖yR) are contiguous + // 64-byte buffers, so each spans offset 63; k is 32B. + if !addr_limb_ok(addr_xg, 63) + || !addr_limb_ok(addr_xr, 63) + || !addr_limb_ok(addr_k, 31) + { + return Err(ExecutionError::EcsmAddressOverflow); + } + // The input is a contiguous 64-byte point (xG at +0, yG at +32) and + // k a 32-byte scalar. They must occupy disjoint regions — the trace + // builder reads xG/yG at T and k at T+1 as unaligned doubleword MEMW + // accesses; overlap touches the same address at both timestamps and + // the MEMW consistency argument can't prove the chain. The guard is + // about trace provability, not correctness. xR (output) may alias + // either: its accesses are at a later timestamp. + // + // Exact range test rather than a distance bound: the two operands have + // different sizes, so a k placed just below the point (`addr_k + 32 == + // addr_xg`) is disjoint at a distance of 32. A `< 64` bound would reject + // it, making the ecall depend on which operand the guest's compiler + // happens to lay out first. + if addr_k < addr_xg.wrapping_add(64) && addr_xg < addr_k.wrapping_add(32) { + return Err(ExecutionError::EcsmOperandOverlap); + } + let xg = load_u256_le(memory, addr_xg)?; + let yg = load_u256_le(memory, addr_xg.wrapping_add(32))?; + let k = load_u256_le(memory, addr_k)?; + // Caller passes the full point (xG, yG); return both coordinates of + // k·(xG, yG) so the guest skips the x-only (k+1)·P y-reconstruction. + // xR at addr_xr, yR at +32. + let (xr, yr) = ecsm::scalar_mul_xy_with_y(&k, &xg, &yg)?; + store_u256_le(memory, addr_xr, &xr)?; + store_u256_le(memory, addr_xr.wrapping_add(32), &yr)?; + // 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; + dst_val = addr_k; + } SyscallNumbers::Hint => { // Non-constraining hint: host computes a modular inverse/sqrt // and writes it to the guest, which verifies it (and falls back diff --git a/prover/src/tables/cpu.rs b/prover/src/tables/cpu.rs index fc4c2f976..bff303421 100644 --- a/prover/src/tables/cpu.rs +++ b/prover/src/tables/cpu.rs @@ -187,8 +187,14 @@ pub struct CpuOperation { pub keccak_state_addr: u64, /// Whether this ECALL is an ECSM (elliptic-curve scalar multiply) syscall + /// (either the x-only or the affine variant). pub ecall_ecsm: bool, + /// Whether this ECSM ecall is the affine variant (full point in / out). Selects the + /// `IS_AFFINE` column of the ECSM table and, in the trace builder, the yG-read / yR-write + /// memory ops. `false` for the x-only variant and for non-ECSM rows. + pub ecsm_affine: bool, + /// Whether this ECALL is a non-constraining Hint syscall. The hint operand /// addresses (x10/x11/x12) are recovered from the register state in the trace /// builder, exactly like ECSM. @@ -237,9 +243,13 @@ impl CpuOperation { f.ecall && log.src1_val == executor::vm::instruction::execution::KECCAK_SYSCALL_NUMBER; let keccak_state_addr = if ecall_keccak { log.src2_val } else { 0 }; // The ECSM operand addresses (x10/x11/x12) are recovered from the register state - // in the trace builder. - let ecall_ecsm = - f.ecall && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; + // in the trace builder. The x-only and affine variants share the ECSM table; the + // affine flag selects the yG-read / yR-write memory ops and the IS_AFFINE column. + let ecsm_affine = f.ecall + && log.src1_val == executor::vm::instruction::execution::ECSM_AFFINE_SYSCALL_NUMBER; + let ecall_ecsm = ecsm_affine + || (f.ecall + && log.src1_val == executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER); let ecall_hint = f.ecall && log.src1_val == executor::vm::instruction::execution::HINT_SYSCALL_NUMBER; @@ -360,6 +370,7 @@ impl CpuOperation { ecall_keccak, keccak_state_addr, ecall_ecsm, + ecsm_affine, ecall_hint, } } diff --git a/prover/src/tables/ecsm.rs b/prover/src/tables/ecsm.rs index 746bef91c..7e73c540d 100644 --- a/prover/src/tables/ecsm.rs +++ b/prover/src/tables/ecsm.rs @@ -1,11 +1,21 @@ //! ECSM core chip — orchestrates one secp256k1 scalar multiplication `k·G`. //! -//! One row per `ECALL(-11)`. It reads `xG` and `k` from memory, witnesses `yG` and proves +//! One row per ECSM `ECALL`. It reads `xG` and `k` from memory, witnesses `yG` and proves //! `yG² ≡ xG³ + b mod p` (via two byte-limb convolution relations with quotients `q0,q1` //! and 64-entry carry arrays `c0,c1`), enforces `0 < k < N` and `xR < p`, writes `xR` back, //! serves the scalar bits directly via the `Bit` bus, and delegates the double-and-add to ECDAS //! over the `Ecdas`/`Bit` buses. //! +//! ## Two modes (`IS_AFFINE` selector) +//! The chip serves both ecall variants with one prover, selected by the `IS_AFFINE` column: +//! - **x-only** (`ECSM_SYSCALL_NUMBER`): input `xG` (32B), output `xR` (32B). `yG` is the +//! canonical even lift (not read from memory), `yR` is witnessed only for ECDAS. +//! - **affine** (`ECSM_AFFINE_SYSCALL_NUMBER`): input `xG‖yG` (64B), output `xR‖yR` (64B); +//! the yG-read and yR-write MEMW buses fire with `mult = IS_AFFINE`. +//! +//! `IS_AFFINE` is pinned to the actual ecall number by the `Ecall` receiver, so it can't be +//! forged (see `bus_interactions`). +//! //! See `spec/src/ecsm.toml`. All multi-limb arithmetic uses 8-bit limbs; the witness is built //! by `ecsm::compute_witness`, which reproduces these exact recurrences. //! @@ -15,7 +25,7 @@ //! relation has no standalone constant and also closes at all-zero. The range checks / //! virtual-carry checks remain µ-gated as before. -use executor::vm::instruction::execution::ECSM_SYSCALL_NUMBER; +use executor::vm::instruction::execution::{ECSM_AFFINE_SYSCALL_NUMBER, ECSM_SYSCALL_NUMBER}; use stark::lookup::{BusInteraction, BusValue, LinearTerm, Multiplicity, Packing}; use stark::trace::TraceTable; @@ -28,7 +38,7 @@ pub(crate) const CARRY_OFFSET_X2: i64 = 8160; pub(crate) const CARRY_OFFSET_YG: i64 = 16319; // ========================================================================= -// Column indices (667 columns; keep in sync with NUM_COLUMNS below) +// Column indices (684 columns; keep in sync with NUM_COLUMNS below) // ========================================================================= pub mod cols { @@ -56,8 +66,13 @@ pub mod cols { pub const K_SUB_N: usize = 634; // U256HL (16 halfwords) pub const XR_SUB_P: usize = 650; // U256HL (16 halfwords) pub const MU: usize = 666; + /// Mode selector: 1 = affine ecall (read yG, write yR), 0 = x-only / padding. + /// Pinned to the actual ecall number by the `Ecall` receiver (see `bus_interactions`). + pub const IS_AFFINE: usize = 667; + /// `(yR - p) mod 2^256`, the addend that forces `yR < p` (see `OverflowKind::YrLtP`). + pub const YR_SUB_P: usize = 668; // U256HL (16 halfwords) - pub const NUM_COLUMNS: usize = 667; + pub const NUM_COLUMNS: usize = 684; #[inline] pub const fn xr(i: usize) -> usize { @@ -108,6 +123,10 @@ pub mod cols { pub const fn xr_sub_p(i: usize) -> usize { XR_SUB_P + i } + #[inline] + pub const fn yr_sub_p(i: usize) -> usize { + YR_SUB_P + i + } } // ========================================================================= @@ -121,6 +140,9 @@ pub struct EcsmOperation { pub addr_xg: u64, pub addr_k: u64, pub addr_xr: u64, + /// Affine variant (full point in/out): drives the `IS_AFFINE` column and the + /// yG-read / yR-write memory ops. `false` for the x-only variant. + pub is_affine: bool, pub witness: EcsmWitness, } @@ -181,6 +203,7 @@ pub fn generate_ecsm_trace( write_halfwords(table, row_idx, cols::XG_SUB_P, &w.x_g_sub_p); write_halfwords(table, row_idx, cols::K_SUB_N, &w.k_sub_n); write_halfwords(table, row_idx, cols::XR_SUB_P, &w.x_r_sub_p); + write_halfwords(table, row_idx, cols::YR_SUB_P, &w.y_r_sub_p); for i in 0..64 { debug_assert!((0..1 << 16).contains(&(w.c0[i] + CARRY_OFFSET_X2))); @@ -190,6 +213,9 @@ pub fn generate_ecsm_trace( } table.set_fe(row_idx, cols::MU, FE::one()); + if op.is_affine { + table.set_fe(row_idx, cols::IS_AFFINE, FE::one()); + } } trace @@ -299,19 +325,42 @@ fn k_dword_busvalues(dword_idx: usize) -> [BusValue; 8] { pub fn bus_interactions() -> Vec { let mu = || Multiplicity::Column(cols::MU); + // Affine-only multiplicity: fires only when IS_AFFINE = 1 (never on x-only or padding + // rows, where IS_AFFINE is constrained to 0). Used for the yG-read / yR-write buses. + let affine = || Multiplicity::Column(cols::IS_AFFINE); let ts_lo = || packed(cols::TIMESTAMP_0); let ts_hi = || packed(cols::TIMESTAMP_1); let mut out = Vec::new(); // ECALL receiver (mult = mu): [ts_lo, ts_hi, syscall_lo32, syscall_hi32]. + // + // The received syscall number is LINEAR in IS_AFFINE: + // syscall = xonly + IS_AFFINE·(affine − xonly). + // The CPU sends the *actual* a7 (rv1) on the `Ecall` bus, so this pins IS_AFFINE to the + // real ecall variant — a prover that flips IS_AFFINE without changing the guest's ecall + // number makes the bus fingerprint mismatch and the LogUp argument fail. This is the + // soundness anchor that lets the yG-read / yR-write buses key off IS_AFFINE below. + let xonly_lo = (ECSM_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; + let xonly_hi = (ECSM_SYSCALL_NUMBER >> 32) as i64; + let affine_lo = (ECSM_AFFINE_SYSCALL_NUMBER & 0xFFFF_FFFF) as i64; + let affine_hi = (ECSM_AFFINE_SYSCALL_NUMBER >> 32) as i64; + let syscall_word = |xonly: i64, affine: i64| { + BusValue::linear(vec![ + LinearTerm::Constant(xonly), + LinearTerm::Column { + coefficient: affine - xonly, + column: cols::IS_AFFINE, + }, + ]) + }; out.push(BusInteraction::receiver( BusId::Ecall, mu(), vec![ ts_lo(), ts_hi(), - BusValue::constant(ECSM_SYSCALL_NUMBER & 0xFFFF_FFFF), - BusValue::constant(ECSM_SYSCALL_NUMBER >> 32), + syscall_word(xonly_lo, affine_lo), + syscall_word(xonly_hi, affine_hi), ], )); @@ -354,6 +403,34 @@ pub fn bus_interactions() -> Vec { ), )); } + // AFFINE (mult = IS_AFFINE): read yG: 4 doublewords at addr_xG + 32 + 8i (ts). Pins the + // witnessed yG (col YG) to the caller's input point, so the returned yR corresponds to + // the caller's actual (xG, yG) — closes the parity soundness gap. Fires only on affine + // rows; x-only guests pass a 32-byte xG (no yG in memory), so these must not fire there. + // Same low address limb (the +63 span is guarded to not cross the 2^32 limb boundary). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XG_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + affine(), + memw_read( + dword_bytes(cols::YG, i), + 0, + base_lo, + packed(cols::ADDR_XG_1), + ts_lo(), + ts_hi(), + 0, + 1, + ), + )); + } let ts_lo_plus = |d: i64| { BusValue::linear(vec![ @@ -443,6 +520,33 @@ pub fn bus_interactions() -> Vec { )); } + // AFFINE (mult = IS_AFFINE): write yR as 4 doublewords at addr_xR + 32 + 8i (ts + 3). + // Reuses the ADDR_XR register (output buffer is the contiguous 64-byte [xR‖yR]); no new + // column or register read. yR (col YR) is the ECDAS-constrained y of k·(xG, yG). Fires + // only on affine rows; x-only guests get only xR written back. ts + 3 is the free 4th + // sub-timestamp (instruction stride is 4; xG@T, k@T+1, xR@T+2 use the first three). + for i in 0..4 { + let base_lo = BusValue::linear(vec![ + LinearTerm::Column { + coefficient: 1, + column: cols::ADDR_XR_0, + }, + LinearTerm::Constant((32 + 8 * i) as i64), + ]); + out.push(BusInteraction::sender( + BusId::Memw, + affine(), + memw_write( + dword_bytes(cols::YR, i), + base_lo, + packed(cols::ADDR_XR_1), + ts_lo_plus(3), + ts_hi(), + 1, + ), + )); + } + // IS_BYTE range checks (single byte → AreBytes[x, 0]). let is_byte = |col: usize, len: usize, out: &mut Vec| { for i in 0..len { @@ -504,6 +608,13 @@ pub fn bus_interactions() -> Vec { vec![packed(cols::xr_sub_p(i))], )); } + for i in 0..16 { + out.push(BusInteraction::sender( + BusId::IsHalfword, + mu(), + vec![packed(cols::yr_sub_p(i))], + )); + } // ZERO bus: assert k != 0 (sum of byte_k[0..31] is nonzero). // byte_k[i] = Σ_{j=0}^{7} 2^j · k[8i+j], so Σ byte_k = Σ_{b=0}^{255} 2^(b%8) · k[b]. @@ -628,6 +739,7 @@ pub enum OverflowKind { XgLtP, KLtN, XrLtP, + YrLtP, } impl OverflowKind { @@ -637,6 +749,7 @@ impl OverflowKind { OverflowKind::XgLtP => &P_BYTES, OverflowKind::KLtN => &N_BYTES, OverflowKind::XrLtP => &P_BYTES, + OverflowKind::YrLtP => &P_BYTES, }; let mut w = 0u64; for b in 0..4 { @@ -650,6 +763,7 @@ impl OverflowKind { OverflowKind::XgLtP => cols::XG_SUB_P, OverflowKind::KLtN => cols::K_SUB_N, OverflowKind::XrLtP => cols::XR_SUB_P, + OverflowKind::YrLtP => cols::YR_SUB_P, } } /// Column base of the sum. @@ -658,6 +772,7 @@ impl OverflowKind { OverflowKind::XgLtP => cols::XG, OverflowKind::KLtN => cols::K, OverflowKind::XrLtP => cols::XR, + OverflowKind::YrLtP => cols::YR, } } /// Whether the sum is stored as individual bits (k) rather than bytes (xG/xR). @@ -671,7 +786,7 @@ impl OverflowKind { // ========================================================================= // // One body against the generic `ConstraintBuilder` serves the compiled prover -// folder, the verifier folder and IR capture. Constraint indices 0..413: +// folder, the verifier folder and IR capture. Constraint indices 0..423: // 0 : IS_BIT(MU) // 1..257 : IS_BIT(k[i]) for the 256 scalar bits // 257 : KBitsZeroOnPadding — (Σ k_bit[i])·(1−µ) @@ -686,10 +801,14 @@ impl OverflowKind { // 404 : OverflowRequired(KLtN) // 405..412 : CarryBit(XrLtP, 0..7) // 412 : OverflowRequired(XrLtP) +// 413..420 : CarryBit(YrLtP, 0..7) +// 420 : OverflowRequired(YrLtP) +// 421 : IS_BIT(IS_AFFINE) +// 422 : AffineZeroOnPadding — IS_AFFINE·(1−µ) use stark::constraints::builder::{ConstraintBuilder, ConstraintSet}; -/// ECSM transition constraints as a single-source [`ConstraintSet`] (413 +/// ECSM transition constraints as a single-source [`ConstraintSet`] (423 /// total). No column configuration needed (the layout is fixed via `cols`). #[derive(Clone, Copy)] pub struct EcsmConstraints; @@ -881,7 +1000,12 @@ impl ConstraintSet for EcsmConstraints { idx += 1; // xG < p, k < N and xR < p: 7 carry bits (deg 3) + overflow-required (deg 2) each. - for kind in [OverflowKind::XgLtP, OverflowKind::KLtN, OverflowKind::XrLtP] { + for kind in [ + OverflowKind::XgLtP, + OverflowKind::KLtN, + OverflowKind::XrLtP, + OverflowKind::YrLtP, + ] { let c = Self::carry_chain(b, kind); for ci in c.iter().take(7) { // µ · c_i · (1 − c_i) @@ -897,6 +1021,20 @@ impl ConstraintSet for EcsmConstraints { idx += 1; } - debug_assert_eq!(idx, 413); + // idx 421: IS_BIT(IS_AFFINE): a·(1−a). The mode selector is a bit. (deg 2) + let is_affine = b.main(0, cols::IS_AFFINE); + let one = b.one(); + b.emit_base(idx, is_affine.clone() * (one - is_affine)); + idx += 1; + + // idx 422: AffineZeroOnPadding: IS_AFFINE·(1−µ). Forces IS_AFFINE = 0 on padding + // rows (µ=0), so the affine-gated yG-read / yR-write buses can't fire there. (deg 2) + let is_affine = b.main(0, cols::IS_AFFINE); + let mu = b.main(0, cols::MU); + let one = b.one(); + b.emit_base(idx, is_affine * (one - mu)); + idx += 1; + + debug_assert_eq!(idx, 423); } } diff --git a/prover/src/tables/trace_builder.rs b/prover/src/tables/trace_builder.rs index ef6ade6d7..afdcf3888 100644 --- a/prover/src/tables/trace_builder.rs +++ b/prover/src/tables/trace_builder.rs @@ -847,22 +847,37 @@ fn collect_ecsm_ops( Vec, ) { let t = op.timestamp; + let is_affine = op.ecsm_affine; let addr_xr = register_state.read(10).0; let addr_xg = register_state.read(11).0; let addr_k = register_state.read(12).0; - // Read the xG and k operands (32 little-endian bytes each) from memory. + // Read the operands from memory. x-only reads xG (32B) + k (32B); affine also reads yG + // (the caller's real input y, pinned below by a memory read at T) so the returned point + // is the caller's actual point — no even-parity convention. let mut xg = [0u8; 32]; + let mut yg = [0u8; 32]; let mut k = [0u8; 32]; for i in 0..32 { xg[i] = memory_state.read_byte(addr_xg.wrapping_add(i as u64)).0; + if is_affine { + yg[i] = memory_state + .read_byte(addr_xg.wrapping_add(32 + i as u64)) + .0; + } k[i] = memory_state.read_byte(addr_k.wrapping_add(i as u64)).0; } - let witness = ::ecsm::compute_witness(&k, &xg) - .expect("ECSM witness: executor validates 0 < k < N and xG on curve"); + let witness = if is_affine { + ::ecsm::compute_witness_with_y(&k, &xg, &yg) + .expect("ECSM witness: executor validates 0 < k < N, xG/yG < p, (xG,yG) on curve") + } else { + ::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); + // 15 ops on the x-only path; the affine path adds 4 yG reads and 4 yR writes. + let mut memw_ops = Vec::with_capacity(if is_affine { 23 } else { 15 }); // x11 -> addr_xG (register read at T), x12 -> addr_k (register read at T+1). { @@ -889,6 +904,25 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t); } + // AFFINE only: yG: 4 doubleword reads at T (addr_xG + 32 + 8i). Pins the witnessed yG to + // the caller's input, closing the parity soundness gap. x-only guests pass only xG, so + // this block (and the ECSM table's yG-read bus, gated by IS_AFFINE) does not run. + if is_affine { + for i in 0..4 { + let addr = addr_xg.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_g[8 * i + j] as u32; + dword |= (witness.y_g[8 * i + j] as u64) << (8 * j); + } + let (_old, old_ts) = memory_state.read_bytes(addr, 8); + memw_ops + .push(MemwOperation::new(false, addr, value, t, 8, true).with_old(value, old_ts)); + memory_state.write_bytes(addr, dword, 8, t); + } + } + // x12 -> addr_k (register read at T+1). { let (val, old_ts) = register_state.read(12); @@ -942,6 +976,26 @@ fn collect_ecsm_ops( memory_state.write_bytes(addr, dword, 8, t + 2); } + // AFFINE only: yR writes at T + 3 (4 doublewords at addr_xR + 32 + 8i). Matches the + // ecsm.rs YR sender block (gated by IS_AFFINE); the executor wrote yR to addr_xR + 32. + // x-only guests get only xR written back. + if is_affine { + for i in 0..4 { + let addr = addr_xr.wrapping_add((32 + 8 * i) as u64); + let mut value = [0u32; 8]; + let mut dword = 0u64; + for j in 0..8 { + value[j] = witness.y_r[8 * i + j] as u32; + dword |= (witness.y_r[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, t + 3, 8, false).with_old(old_vals, old_ts), + ); + memory_state.write_bytes(addr, dword, 8, t + 3); + } + } + let ecdas_ops = witness .steps .iter() @@ -953,6 +1007,7 @@ fn collect_ecsm_ops( addr_xg, addr_k, addr_xr, + is_affine, witness, }; @@ -2392,7 +2447,7 @@ pub(crate) fn collect_bitwise_from_ecsm(ops: &[ecsm::EcsmOperation]) -> Vec Vec [u8; 32] { // secp256k1 Gx, little-endian. @@ -37,6 +42,17 @@ fn k_le(v: u64) -> [u8; 32] { k } +fn gy_le() -> [u8; 32] { + // secp256k1 Gy, little-endian. + let mut be = [ + 0x48, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + be.reverse(); + be +} + fn op_for(k: u64) -> EcsmOperation { let witness = compute_witness(&k_le(k), &gx_le()).unwrap(); EcsmOperation { @@ -44,6 +60,21 @@ fn op_for(k: u64) -> EcsmOperation { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, + witness, + } +} + +/// Affine-variant row: `yG` comes from the caller's buffer instead of the even lift, and +/// `IS_AFFINE = 1`. +fn affine_op_for(k: u64) -> EcsmOperation { + let witness = compute_witness_with_y(&k_le(k), &gx_le(), &gy_le()).unwrap(); + EcsmOperation { + timestamp: 448, + addr_xg: 0x2000, + addr_k: 0x3000, + addr_xr: 0x1000, + is_affine: true, witness, } } @@ -94,7 +125,93 @@ fn constraints_hold_on_generated_trace() { #[test] fn constraint_set_count() { - assert_eq!(EcsmConstraints.meta().len(), 413); + assert_eq!(EcsmConstraints.meta().len(), 423); +} + +/// One prover serves both ecall variants, so affine rows (`IS_AFFINE = 1`, `yG` read from +/// the caller's buffer) and x-only rows must satisfy the same constraint set in one trace. +/// Covers `IS_BIT(IS_AFFINE)` and `AffineZeroOnPadding` with the selector actually set. +#[test] +fn constraints_hold_on_mixed_affine_and_xonly_trace() { + let ops = vec![ + affine_op_for(1), + op_for(2), + affine_op_for(0xFFFF), + op_for(1_000_003), + ]; + let trace = generate_ecsm_trace(&ops); + assert_eq!( + *trace.main_table.get(0, cols::IS_AFFINE), + FE::one(), + "row 0 is an affine row" + ); + assert_eq!( + *trace.main_table.get(1, cols::IS_AFFINE), + FE::zero(), + "row 1 is an x-only row" + ); + + for row in 0..trace.num_rows() { + for (i, v) in eval_row(&trace, row).iter().enumerate() { + assert_eq!(*v, FE::zero(), "constraint {i} must hold at row {row}"); + } + } +} + +/// `IS_AFFINE` must be a bit, and zero on padding — otherwise a witness could fire the +/// affine-gated yG-read / yR-write MEMW buses on rows that never ran an affine ecall. +#[test] +fn affine_selector_must_be_a_bit_and_zero_on_padding() { + let row_with = |mu: u64, is_affine: u64| { + let mut main = vec![FE::zero(); cols::NUM_COLUMNS]; + main[cols::MU] = FE::from(mu); + main[cols::IS_AFFINE] = FE::from(is_affine); + eval_main_row(main) + }; + + assert_ne!( + row_with(1, 2)[IDX_IS_AFFINE_BIT], + FE::zero(), + "IS_BIT must fire for a non-boolean IS_AFFINE" + ); + assert_ne!( + row_with(0, 1)[IDX_AFFINE_PADDING], + FE::zero(), + "AffineZeroOnPadding must fire for IS_AFFINE = 1 on a padding row" + ); + for is_affine in [0, 1] { + let row = row_with(1, is_affine); + assert_eq!(row[IDX_IS_AFFINE_BIT], FE::zero()); + assert_eq!(row[IDX_AFFINE_PADDING], FE::zero()); + } +} + +/// OverflowRequired for YrLtP fires when yR = p, the check that keeps the published `yR` +/// canonical (the byte range checks alone only bound it below 2^256, and the quotient +/// columns would absorb the extra multiple of p). Mirrors the xG case: all CarryBit +/// constraints still hold, but the chain never reaches c_7 = 1. +#[test] +fn yr_ge_p_overflow_required_fires() { + let mut main = vec![FE::zero(); cols::NUM_COLUMNS]; + main[cols::MU] = FE::one(); + // yR = p, yr_sub_p = 0 (invalid subtraction witness — fine for this isolation test). + for (i, &b) in P_BYTES.iter().enumerate() { + main[cols::YR + i] = FE::from(b as u64); + } + let row = eval_main_row(main); + + for i in 0..7 { + assert_eq!( + row[IDX_YR_CARRY0 + i], + FE::zero(), + "carry bit {i}: c_i=0 is a valid bit" + ); + } + assert_ne!( + row[IDX_YR_OVERFLOW], + FE::zero(), + "OverflowRequired must fire when yR = p" + ); } /// The yG carry recurrence closes on all-zero padding because both the `µ·p²` offset and the @@ -231,6 +348,7 @@ fn q1_bit32_equals_one_path() { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, witness, }; let trace = generate_ecsm_trace(&[op]); @@ -257,6 +375,7 @@ fn constraints_hold_for_k_eq_n_minus_one() { addr_xg: 0x2000, addr_k: 0x3000, addr_xr: 0x1000, + is_affine: false, witness, }; let trace = generate_ecsm_trace(&[op]); diff --git a/prover/src/tests/prove_elfs_tests.rs b/prover/src/tests/prove_elfs_tests.rs index c120650f5..d294e3283 100644 --- a/prover/src/tests/prove_elfs_tests.rs +++ b/prover/src/tests/prove_elfs_tests.rs @@ -1590,6 +1590,120 @@ fn test_prove_elfs_ecsm_forged_ecdas_mu_rejected() { ); } +/// secp256k1 generator (Gx, Gy) as little-endian 32-byte coordinates. +fn secp256k1_generator_le() -> ([u8; 32], [u8; 32]) { + let mut gx = [ + 0x79u8, 0xBE, 0x66, 0x7E, 0xF9, 0xDC, 0xBB, 0xAC, 0x55, 0xA0, 0x62, 0x95, 0xCE, 0x87, 0x0B, + 0x07, 0x02, 0x9B, 0xFC, 0xDB, 0x2D, 0xCE, 0x28, 0xD9, 0x59, 0xF2, 0x81, 0x5B, 0x16, 0xF8, + 0x17, 0x98, + ]; + gx.reverse(); + let mut gy = [ + 0x48u8, 0x3A, 0xDA, 0x77, 0x26, 0xA3, 0xC4, 0x65, 0x5D, 0xA4, 0xFB, 0xFC, 0x0E, 0x11, 0x08, + 0xA8, 0xFD, 0x17, 0xB4, 0x48, 0xA6, 0x85, 0x54, 0x19, 0x9C, 0x47, 0xD0, 0x8F, 0xFB, 0x10, + 0xD4, 0xB8, + ]; + gy.reverse(); + (gx, gy) +} + +/// Reads the compiled affine ECSM rust guest (`ecsm_mul_affine` → commit xR‖yR). +fn ecsm_affine_elf_bytes() -> Vec { + let workspace_root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("workspace root") + .to_path_buf(); + std::fs::read(workspace_root.join("executor/program_artifacts/rust/ecsm_affine.elf")) + .expect("ecsm_affine.elf not found — run `make compile-programs-rust`") +} + +/// End-to-end via the **affine** Rust-guest path: `ecsm_mul_affine` computes 5·G and commits +/// the full 64-byte point xR‖yR. Verifies the affine ecall proves (IS_AFFINE=1: yG read from +/// memory, yR written back) and that the committed point matches the native reference. +#[test] +fn test_prove_ecsm_affine_rust_guest() { + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + let proof = prove_vm_minimal(&elf_bytes, &[], &Default::default()); + assert!( + verify_vm_minimal(&proof, &elf_bytes), + "ecsm_affine rust guest should verify" + ); + + // Committed output must equal the full point 5·G = (xR, yR). + let (gx, gy) = secp256k1_generator_le(); + let mut k = [0u8; 32]; + k[0] = 5; + let (xr, yr) = ecsm::scalar_mul_xy_with_y(&k, &gx, &gy).unwrap(); + let mut expected = xr.to_vec(); + expected.extend_from_slice(&yr); + assert_eq!(proof.public_output, expected); +} + +/// Soundness: forging the returned `yR` on an affine ECSM row must be rejected. `yR` is pinned +/// both by the affine yR-write MEMW bus (mult = IS_AFFINE) and by the ECDAS final-receiver +/// tuple, so tampering it unbalances those buses and the proof must fail to verify. +#[test] +fn test_prove_ecsm_forged_yr_rejected() { + use crate::tables::ecsm::cols as ecsm_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let executor = + executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + let result = executor.run().expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + + // Forge the low byte of yR on the (single) real affine ECSM row. + let orig = *traces.ecsm.main_table.get(0, ecsm_cols::YR); + let forged = orig + FieldElement::::one(); + traces.ecsm.main_table.set(0, ecsm_cols::YR, forged); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject a forged ECSM result yR" + ); +} + +/// Soundness: `IS_AFFINE` cannot be forged. It is what gates the yG-read and yR-write MEMW +/// buses, and it is pinned by the `Ecall` receiver, whose syscall word is +/// `xonly + IS_AFFINE·(affine − xonly)` — the CPU sends the guest's real `a7`, so clearing +/// the selector on a row that ran the affine ecall leaves that bus (and the two it gates) +/// unbalanced. +#[test] +fn test_prove_ecsm_forged_is_affine_rejected() { + use crate::tables::ecsm::cols as ecsm_cols; + + let _ = env_logger::builder().is_test(true).try_init(); + + let elf_bytes = ecsm_affine_elf_bytes(); + let elf = Elf::load(&elf_bytes).expect("Failed to load ELF"); + let executor = + executor::vm::execution::Executor::new(&elf, vec![]).expect("Failed to create executor"); + let result = executor.run().expect("Failed to run program"); + let mut traces = + Traces::from_elf_and_logs_minimal(&elf, &result.logs, &Default::default(), &[]).unwrap(); + + assert_eq!( + *traces.ecsm.main_table.get(0, ecsm_cols::IS_AFFINE), + FieldElement::::one(), + "sanity: the ecsm_affine guest produces an affine row" + ); + traces + .ecsm + .main_table + .set(0, ecsm_cols::IS_AFFINE, FieldElement::zero()); + + assert!( + !prove_and_verify_vm_minimal(&elf, &mut traces), + "Verifier must reject an ECSM row that claims the wrong ecall variant" + ); +} + /// Verifier REJECTS a forged trace where an addr byte cell is set to a /// non-byte field element. /// diff --git a/syscalls/src/syscalls.rs b/syscalls/src/syscalls.rs index 5228455ea..7c3f470a1 100644 --- a/syscalls/src/syscalls.rs +++ b/syscalls/src/syscalls.rs @@ -29,10 +29,15 @@ pub enum SyscallNumbers { #[cfg(target_arch = "riscv64")] const KECCAK_SYSCALL_NUMBER: usize = usize::MAX - 1; -/// Syscall number for the ECSM secp256k1 scalar-multiply accelerator (-11 as usize). +/// Syscall number for the x-only ECSM secp256k1 scalar-multiply accelerator (-11 as usize). #[cfg(target_arch = "riscv64")] const ECSM_SYSCALL_NUMBER: usize = usize::MAX - 10; +/// Syscall number for the affine ECSM variant (full point in/out). +/// Must match `executor::...::execution::ECSM_AFFINE_SYSCALL_NUMBER` (u64::MAX - 11). +#[cfg(target_arch = "riscv64")] +const ECSM_AFFINE_SYSCALL_NUMBER: usize = usize::MAX - 11; + /// Syscall number for the non-constraining Hint ecall. /// Must match `executor::...::execution::HINT_SYSCALL_NUMBER` (u64::MAX - 30). #[cfg(target_arch = "riscv64")] @@ -191,6 +196,31 @@ pub fn ecsm_mul(xr: &mut [u8; 32], xg: &[u8; 32], k: &[u8; 32]) { } } +/// AFFINE: compute `k·(xG, yG)` on secp256k1 and write BOTH result coordinates into a +/// contiguous 64-byte buffer (`xR` at `out[0..32]`, `yR` at `out[32..64]`). The input is +/// the full affine point as a contiguous 64-byte buffer (`xG` at `in[0..32]`, `yG` at +/// `in[32..64]`); `k` is 32 bytes. All values 32-byte little-endian. Passing the full point +/// (not just `xG`) means the returned `yR` is the y of the caller's actual point — no +/// parity convention or caller-side sign flip. Lets ECDSA recovery avoid the second +/// `(k+1)·P` query and the x-only y-reconstruction. +#[cfg(target_arch = "riscv64")] +pub fn ecsm_mul_affine(out: &mut [u8; 64], input: &[u8; 64], k: &[u8; 32]) { + unsafe { + asm!( + "ecall", + in("a0") out.as_mut_ptr(), // x10 = address to write [xR‖yR] (64 bytes) + in("a1") input.as_ptr(), // x11 = address of [xG‖yG] (64 bytes) + in("a2") k.as_ptr(), // x12 = address of k + in("a7") ECSM_AFFINE_SYSCALL_NUMBER, + ) + } +} + +#[cfg(not(target_arch = "riscv64"))] +pub fn ecsm_mul_affine(_out: &mut [u8; 64], _input: &[u8; 64], _k: &[u8; 32]) { + unimplemented!("syscalls are only implemented for riscv64 targets"); +} + #[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]) {