Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions crypto/ecsm/src/curve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Scalar>::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
Expand Down
25 changes: 22 additions & 3 deletions crypto/ecsm/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<EcsmOutput, EcsmError> {
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)))
}
188 changes: 97 additions & 91 deletions crypto/ethrex-crypto/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …).
Expand All @@ -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;
Expand Down Expand Up @@ -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<const N: usize>([u8; N]);

/// Scalar-field inverse `x⁻¹ mod n`.
///
/// On riscv64 the inverse is first requested from the untrusted `hint` ecall and
Expand Down Expand Up @@ -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,
Expand All @@ -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<FieldElement> {
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<FieldElement> {
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`.
Expand Down Expand Up @@ -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))]
Expand All @@ -407,45 +427,30 @@ fn lincomb2_with_oracle<O>(
oracle: O,
) -> Option<AffinePoint>
where
O: Fn(&FieldElement, &Scalar) -> Option<FieldElement>,
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();

Expand All @@ -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<O>(
xp: &FieldElement,
yp: &FieldElement,
xa: &FieldElement,
xc: &FieldElement,
dx: &FieldElement,
inv_den: &FieldElement,
) -> Option<FieldElement> {
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
Expand Down
Loading
Loading