diff --git a/.config/spellcheck.dic b/.config/spellcheck.dic index 36736b2..19f65bf 100644 --- a/.config/spellcheck.dic +++ b/.config/spellcheck.dic @@ -33,3 +33,5 @@ syscall syscalls verifier versa +MSM +radix diff --git a/Cargo.lock b/Cargo.lock index 2388a33..3e49e03 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1577,7 +1577,7 @@ checksum = "a6cababcb62a2e739c7078ae16eda02789a6e3edd21bbdded864e85c38a7914d" dependencies = [ "solana-account-view", "solana-address 2.7.0", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-instruction-view", "solana-program-error", ] @@ -2049,7 +2049,7 @@ dependencies = [ "serde_derive", "sha2-const-stable", "solana-atomic-u64", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-program-error", "solana-sanitize", "solana-sha256-hasher", @@ -2109,7 +2109,7 @@ dependencies = [ "ark-ff 0.5.0", "ark-serialize 0.5.0", "bytemuck", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "thiserror 2.0.18", ] @@ -2188,7 +2188,7 @@ dependencies = [ "bytemuck", "bytemuck_derive", "curve25519-dalek", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "subtle", "thiserror 2.0.18", ] @@ -2207,9 +2207,9 @@ checksum = "57e5b1c0bc1d4a4d10c88a4100499d954c09d3fecfae4912c1a074dff68b1738" [[package]] name = "solana-define-syscall" -version = "5.1.0" +version = "5.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21e14a4f604117f379840956a8fc8695e4c84f5b0ebed192f31f60d9b85d581d" +checksum = "bf8209ece2bd9f1450e672858ffc0e5c8c786ff6916d2a862b126dd0128f380f" [[package]] name = "solana-ed25519-program" @@ -2234,6 +2234,7 @@ dependencies = [ "ed25519-dalek", "solana-address 2.7.0", "solana-curve25519", + "solana-define-syscall 5.2.0", "solana-ed25519-verify", "solana-instruction", "solana-sha512-hasher", @@ -2322,7 +2323,7 @@ dependencies = [ "bincode", "serde", "serde_derive", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-instruction-error", "solana-pubkey 4.1.0", ] @@ -2347,7 +2348,7 @@ checksum = "6ab7a27d0c4214b9f7389c3dd00b68c93093a67f1dcc5b7893aebe299bbcbb47" dependencies = [ "solana-account-view", "solana-address 2.7.0", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-program-error", ] @@ -2456,7 +2457,7 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "726b7cbbc6be6f1c6f29146ac824343b9415133eee8cce156452ad1db93f8008" dependencies = [ - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", ] [[package]] @@ -2673,7 +2674,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c5f18893d62e6c73117dcba48f8f5e3266d90e5ec3d0a0a90f9785adac36c1" dependencies = [ "k256", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "thiserror 2.0.18", ] @@ -2706,7 +2707,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11e10e103ab5bd52af341e7bc2f4123a2f4e66bb7ba4e6ca40f1e00cd6314e02" dependencies = [ "sha2 0.10.9", - "solana-define-syscall 5.1.0", + "solana-define-syscall 5.2.0", "solana-hash-512", ] diff --git a/Cargo.toml b/Cargo.toml index c276045..67eea36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ serde = { version = "1.0.228", default-features = false } serde_derive = "1.0.228" solana-account = "3.4.0" solana-curve25519 = "4.0.1" +solana-define-syscall = "5.2.0" solana-ed25519-verify = { path = "ed25519-verify", version = "0.1.0", default-features = false } solana-instruction = "3.3.0" solana-program-error = "3.0.1" diff --git a/README.md b/README.md index e32fbe7..a4a60a9 100644 --- a/README.md +++ b/README.md @@ -19,11 +19,11 @@ succeeded. ## Syscalls used -| Syscall | SDK wrapper | -| --------------------------- | ------------------------------------------------------------- | -| `sol_sha512` | `solana_sha512_hasher::hashv` | -| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` | -| `sol_curve_multiscalar_mul` | `solana_curve25519::edwards::multiscalar_multiply_edwards` | +| Syscall | Wrapper or entry point | +| --------------------------- | --------------------------------------------------------------------------------------------------- | +| `sol_sha512` | `solana_sha512_hasher::hashv` | +| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` | +| `sol_curve_multiscalar_mul` | Fixed two-term wrapper using `solana_define_syscall::definitions::sol_curve_multiscalar_mul` on SBF | `sol_sha512` is not live on mainnet yet. The wrapper crate is published as `solana-sha512-hasher`, and a local/custom VM must enable the SHA-512 syscall @@ -78,19 +78,27 @@ non-canonical encodings, and small-order rejection (see Henry de Valence's [It's 255:19AM]). The `solana-ed25519-verify` crate exposes these as independent knobs via `VerificationCriteria`: -| Knob | Effect when enabled | Extra syscalls | -| ---------------------- | -------------------------------------------------------------------------------------------- | ------------------------------------------------- | -| `cofactored` | Use `[8](S·B − H·A − R) == identity` instead of the cofactorless `S·B − H·A − R == identity` | +3 `sol_curve_group_op` (1,419 CU), fallback only | -| `require_canonical_a` | Reject public keys whose `y`-coordinate is `≥ p` | none | -| `require_canonical_r` | Reject signature `R` whose `y`-coordinate is `≥ p` | none | -| `reject_small_order_a` | Reject small-order (torsion) public keys | +3 `sol_curve_group_op` (1,419 CU) | -| `reject_small_order_r` | Reject small-order signature `R` values | +3 `sol_curve_group_op` (1,419 CU) | - -The cofactor multiplication is only reached when a signature fails the exact -comparison first, so it costs nothing on the path a valid signature follows. - -Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it — -accepting `S ≥ L` reintroduces signature malleability — and +| Knob | Effect when enabled | Extra curve syscalls | +| ---------------------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------- | +| `cofactored` | Use `[8](S·B − H·A − R) == identity` instead of the cofactorless `S·B − H·A − R == identity` | none; torsion lookup on the fallback difference | +| `require_canonical_a` | Reject public keys whose `y`-coordinate is `≥ p` | none | +| `require_canonical_r` | Reject signature `R` whose `y`-coordinate is `≥ p` | none | +| `reject_small_order_a` | Reject small-order (torsion) public keys | +1 addition (473 CU), plus a torsion lookup | +| `reject_small_order_r` | Reject small-order signature `R` values | +1 addition (473 CU), plus a torsion lookup | + +The verifier first compares the computed point encoding with `R`. If they differ, +it subtracts `R` and checks the resulting point. The cofactored profile tests this +canonical difference against the torsion encodings, replacing three doublings +with a lookup. This lookup is skipped when the initial comparison succeeds. + +Each small-order input check adds the identity to validate the input and produce +a canonical encoding before the lookup. This preserves support for valid +non-canonical encodings and rejects inputs that do not decompress. The 473 CU +figure above is the addition syscall charge in the benchmark runtime; the total +check also includes the surrounding SBF instructions. + +Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it +because accepting `S ≥ L` reintroduces signature malleability. The syscall `sol_curve_multiscalar_mul` enforces it regardless, converting scalars through `Scalar::from_canonical_bytes` and rejecting out-of-range values before any group operation runs. @@ -208,3 +216,57 @@ unless `SBF_OUT_DIR` is set. Because published Mollusk/Agave crates do not yet register `sol_sha512`, `program/tests/mollusk.rs` installs a local SHA-512 syscall shim before loading the SBF program. A production/localnet VM must register the real `sol_sha512` syscall instead. + +## Compute units + +The measurements below were collected on September 8, 2026, using an SBF v2 +release build, Mollusk `0.13.1`, and the metered SHA-512 test syscall shim. They +include execution of the program wrapper and the syscall charges in that +harness. These are compute-unit measurements, not host execution times. + +The signature corpus contains 32 cases: signing-key seeds `7`, `42`, `99`, and +`201`, each tested with the eight message lengths below. For each message length, +all four seeds consumed the same number of compute units in the final measured +build. + +| Message bytes | Default ZIP-215 CU | Strict CU | +| ----------------: | -----------------: | -----------: | +| 0 | 3,847 | 4,980 | +| 1 | 3,847 | 4,980 | +| 38 | 3,856 | 4,989 | +| 47 | 3,860 | 4,993 | +| 48 | 3,861 | 4,994 | +| 49 | 3,861 | 4,994 | +| 128 | 3,901 | 5,034 | +| 1,024 | 4,349 | 5,482 | +| **32-case total** | **125,528** | **161,784** | +| **Mean per case** | **3,922.75** | **5,055.75** | + +The separate 38-byte signature fixture also consumes **3,856 CU** with ZIP-215 +and **4,989 CU** with `VerificationCriteria::dalek_verify_strict()`. The strict +column was measured using a separate build of the same program wrapper with +that preset selected. The shipped program continues to use ZIP-215. + +Other measured paths in the default ZIP-215 build: + +| Test case | CU | +| ------------------------------------------------ | ---------------------------------: | +| Accepted small-order public-key fixture | 4,388 | +| Accepted torsion encodings, 14 cases | 3,848-4,400 per case; 60,898 total | +| Tampered message or public key | 4,403 each | +| Non-canonical `S` or invalid public-key encoding | 3,836 each | +| Unexpected accounts | 18 | +| Instruction shorter than 96 bytes | 21 | + +To reproduce the default program measurements: + +```sh +cargo build-sbf --arch v2 --manifest-path program/Cargo.toml \ + --sbf-out-dir "$PWD/target/deploy" +SBF_OUT_DIR="$PWD/target/deploy" cargo test --locked \ + -p solana-ed25519-program --test mollusk \ + -- --nocapture --test-threads=1 +``` + +Changing the SBF toolchain, dependencies, or syscall cost model can change these +results. Compare builds using the same corpus and metered syscall shim. diff --git a/ed25519-verify/Cargo.toml b/ed25519-verify/Cargo.toml index 8107c81..f3fcb09 100644 --- a/ed25519-verify/Cargo.toml +++ b/ed25519-verify/Cargo.toml @@ -12,7 +12,11 @@ default = ["verify", "instruction"] instruction = ["dep:solana-instruction", "dep:solana-address"] # Enables the in-program Ed25519 signature verifier (`Ed25519Verifier`) and # the curve/hash syscalls it depends on. -verify = ["dep:solana-curve25519", "dep:solana-sha512-hasher"] +verify = [ + "dep:solana-curve25519", + "dep:solana-sha512-hasher", + "dep:solana-define-syscall", +] [dependencies] solana-curve25519 = { workspace = true, optional = true } @@ -26,5 +30,8 @@ ed25519-dalek = { workspace = true } solana-address = { workspace = true, features = ["atomic"] } solana-ed25519-verify = { path = ".", features = ["instruction"] } +[target.'cfg(target_os = "solana")'.dependencies] +solana-define-syscall = { workspace = true, optional = true } + [lints] workspace = true diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs index e55fd8e..0b5fed5 100644 --- a/ed25519-verify/src/config.rs +++ b/ed25519-verify/src/config.rs @@ -44,11 +44,9 @@ pub struct VerificationCriteria { /// Use the cofactored verification equation /// `[8](S·B − H·A − R) == identity`. /// - /// When `false`, the cofactorless equation `S·B − H·A − R == identity` is - /// used, which rejects mixed-order points that the cofactored equation - /// tolerates. The cofactored form costs one extra multiplication by the - /// cofactor 8, which the verifier performs as three `sol_curve_group_op` - /// additions. + /// When `false`, the difference must be the identity. + /// When `true`, any torsion difference is accepted, using a lookup of + /// its canonical compressed encoding. pub cofactored: bool, /// Reject public keys whose compressed `y`-coordinate is `>= p` (a /// non-canonical encoding of a reduced point). @@ -57,13 +55,13 @@ pub struct VerificationCriteria { pub require_canonical_r: bool, /// Reject public keys that lie in the small-order (torsion) subgroup. /// - /// Costs a multiplication by the cofactor 8 (three `sol_curve_group_op` - /// additions) when enabled. + /// Costs one point addition to validate the input and produce a canonical + /// encoding, followed by a torsion lookup, when enabled. pub reject_small_order_a: bool, /// Reject signature `R` values that lie in the small-order subgroup. /// - /// Costs a multiplication by the cofactor 8 (three `sol_curve_group_op` - /// additions) when enabled. + /// Costs one point addition to validate the input and produce a canonical + /// encoding, followed by a torsion lookup, when enabled. pub reject_small_order_r: bool, } diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index 1e060d3..b78c1fc 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -1,25 +1,27 @@ //! Edwards curve point operations built on `solana-curve25519` syscalls. //! -//! Covers the cofactor-8 multiplication used both for small-order rejection -//! and for the ZIP-215 cofactored verification equation, and the Ed25519 +//! Covers small-order checks, the ZIP-215 torsion lookup, and the Ed25519 //! challenge hash `H(R || A || M) mod L`. use { crate::{constants::EDWARDS_IDENTITY_COMPRESSED, error::Ed25519VerifyError, scalar}, - solana_curve25519::edwards::{add_edwards, PodEdwardsPoint}, + solana_curve25519::{ + edwards::{add_edwards, PodEdwardsPoint}, + scalar::PodScalar, + }, }; /// Returns `Ok(true)` if `point` decompresses to a small-order (torsion) point. /// -/// A point has order dividing the cofactor 8 exactly when `[8]P` is the -/// identity. This decompresses `point` (accepting non-canonical encodings, which -/// reduce modulo `p`). An encoding that does not decompress returns -/// `Err(InvalidEncoding)` so the caller can reject it immediately, rather than -/// treating it as non-small-order and paying for the subsequent verification -/// syscalls only to fail there. +/// Adding the identity validates the input and produces a canonical encoding. +/// This accepts non-canonical encodings supported by decompression. The result +/// can then be checked against the canonical torsion encodings. +/// +/// An encoding that does not decompress returns `Err(InvalidEncoding)`. pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result { - let product = multiply_by_8(point).ok_or(Ed25519VerifyError::InvalidEncoding)?; - Ok(product == EDWARDS_IDENTITY_COMPRESSED) + let canonical = add_edwards(point, &EDWARDS_IDENTITY_COMPRESSED) + .ok_or(Ed25519VerifyError::InvalidEncoding)?; + Ok(is_small_order_canonical(&canonical)) } /// Multiplies `point` by the cofactor 8 via three point doublings. @@ -27,6 +29,7 @@ pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result Option { let double = add_edwards(point, point)?; let quadruple = add_edwards(&double, &double)?; @@ -34,13 +37,100 @@ pub(crate) fn multiply_by_8(point: &PodEdwardsPoint) -> Option } /// Computes the Ed25519 challenge scalar `H(R || A || M) mod L`. +pub(crate) fn compute_challenge_into( + signature_r: &[u8; 32], + public_key: &[u8; 32], + message: &[u8], + challenge: &mut [u8; 32], +) { + let digest = solana_sha512_hasher::hashv(&[signature_r, public_key, message]).to_bytes(); + scalar::reduce_wide_into(&digest, challenge); +} + +#[cfg(test)] pub(crate) fn compute_challenge( signature_r: &[u8; 32], public_key: &[u8; 32], message: &[u8], ) -> [u8; 32] { - let digest = solana_sha512_hasher::hashv(&[signature_r, public_key, message]).to_bytes(); - scalar::reduce_wide(&digest) + let mut challenge = [0u8; 32]; + compute_challenge_into(signature_r, public_key, message, &mut challenge); + challenge +} + +/// Tests torsion membership for a valid, canonical curve encoding. +/// +/// Use only with canonical encodings returned by successful curve operations. +#[inline(never)] +pub(crate) fn is_small_order_canonical(point: &PodEdwardsPoint) -> bool { + const ORDER_TWO_Y: [u8; 32] = { + let mut y = [0xff; 32]; + y[0] = 0xec; + y[31] = 0x7f; + y + }; + const ORDER_EIGHT_Y0: [u8; 32] = [ + 0x26, 0xe8, 0x95, 0x8f, 0xc2, 0xb2, 0x27, 0xb0, 0x45, 0xc3, 0xf4, 0x89, 0xf2, 0xef, 0x98, + 0xf0, 0xd5, 0xdf, 0xac, 0x05, 0xd3, 0xc6, 0x33, 0x39, 0xb1, 0x38, 0x02, 0x88, 0x6d, 0x53, + 0xfc, 0x05, + ]; + const ORDER_EIGHT_Y1: [u8; 32] = [ + 0xc7, 0x17, 0x6a, 0x70, 0x3d, 0x4d, 0xd8, 0x4f, 0xba, 0x3c, 0x0b, 0x76, 0x0d, 0x10, 0x67, + 0x0f, 0x2a, 0x20, 0x53, 0xfa, 0x2c, 0x39, 0xcc, 0xc6, 0x4e, 0xc7, 0xfd, 0x77, 0x92, 0xac, + 0x03, 0x7a, + ]; + + let mut y = point.0; + y[31] &= 0x7f; + y == [0u8; 32] + || y == EDWARDS_IDENTITY_COMPRESSED.0 + || y == ORDER_TWO_Y + || y == ORDER_EIGHT_Y0 + || y == ORDER_EIGHT_Y1 +} + +/// Computes an Edwards MSM with exactly two scalar-point pairs. +#[inline(always)] +pub(crate) fn multiscalar_multiply_edwards_2( + scalars: &[PodScalar; 2], + points: &[PodEdwardsPoint; 2], +) -> Option { + #[cfg(not(target_os = "solana"))] + { + solana_curve25519::edwards::multiscalar_multiply_edwards(scalars, points) + } + + #[cfg(target_os = "solana")] + { + use { + core::mem::MaybeUninit, solana_define_syscall::definitions::sol_curve_multiscalar_mul, + }; + + // Edwards25519 selector in Solana's curve syscall ABI. + const CURVE25519_EDWARDS: u64 = 0; + + let mut output = MaybeUninit::::uninit(); + + // SAFETY: Both input arrays contain exactly two contiguous POD + // encodings. The output has space for one encoded point and does + // not overlap either input. The syscall validates the encodings. + let status = unsafe { + sol_curve_multiscalar_mul( + CURVE25519_EDWARDS, + scalars.as_ptr().cast::(), + points.as_ptr().cast::(), + 2, + output.as_mut_ptr().cast::(), + ) + }; + + if status != 0 { + return None; + } + + // SAFETY: A successful syscall writes the complete output point. + Some(unsafe { output.assume_init() }) + } } #[cfg(test)] @@ -133,4 +223,99 @@ mod tests { assert_eq!(compute_challenge(&r, &a, message), expected); } + + #[test] + fn canonical_torsion_lookup_matches_dalek() { + use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}, + scalar::Scalar, + }; + + for i in 0..16u64 { + let prime = ED25519_BASEPOINT_POINT * Scalar::from(i); + for torsion in &EIGHT_TORSION { + let point = prime + torsion; + let encoded = PodEdwardsPoint(point.compress().to_bytes()); + assert_eq!( + is_small_order_canonical(&encoded), + point.is_small_order(), + "prime scalar={i}" + ); + } + } + } + + #[test] + fn cofactorless_rejects_nonidentity_torsion_differences() { + let verifier = crate::Ed25519Verifier::with_criteria(crate::VerificationCriteria { + cofactored: false, + ..crate::VerificationCriteria::zip215() + }); + let public_key = EDWARDS_IDENTITY_COMPRESSED.0; + + for torsion in &curve25519_dalek::constants::EIGHT_TORSION { + let r = torsion.compress().to_bytes(); + let mut signature = [0u8; 64]; + signature[..32].copy_from_slice(&r); + let expected = if r == public_key { + Ok(()) + } else { + Err(Ed25519VerifyError::SignatureMismatch) + }; + assert_eq!( + verifier.verify_signature(&signature, &public_key, b"cofactorless torsion"), + expected + ); + } + } + + #[test] + fn is_small_order_matches_dalek_on_canonical_and_noncanonical_inputs() { + use curve25519_dalek::{ + constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}, + edwards::CompressedEdwardsY, + scalar::Scalar, + }; + + let check = |encoding: [u8; 32]| { + let expected = CompressedEdwardsY(encoding) + .decompress() + .map(|point| point.is_small_order()) + .ok_or(Ed25519VerifyError::InvalidEncoding); + + assert_eq!( + is_small_order(&PodEdwardsPoint(encoding)), + expected, + "encoding={encoding:02x?}" + ); + }; + + for i in 0..16u64 { + let prime = ED25519_BASEPOINT_POINT * Scalar::from(i); + for torsion in EIGHT_TORSION { + check((prime + torsion).compress().to_bytes()); + } + } + + // Every representable non-canonical y = p + n, for both sign bits, + // together with its reduced encoding. Includes invalid encodings. + for y in 0..19u8 { + for sign in [0u8, 0x80] { + let mut canonical = [0u8; 32]; + canonical[0] = y; + canonical[31] = sign; + check(canonical); + + let mut alias = [0xffu8; 32]; + alias[0] = 0xed + y; + alias[31] = 0x7f | sign; + check(alias); + } + } + + // The order-two point with the sign bit set despite x = 0. + let mut negative_zero = [0xffu8; 32]; + negative_zero[0] = 0xec; + check(negative_zero); + } } diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 3e835e0..55cccc8 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -16,62 +16,111 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { cmp_le(&y, &FIELD_MODULUS).is_lt() } -/// Reduces a 64-byte little-endian integer modulo the ed25519 base point order. +/// Reduces a 64-byte little-endian integer modulo the Ed25519 group order. /// -/// Bit-serial long division: shift the remainder left, shift in the next bit of -/// the dividend, and subtract `L` whenever the result reaches it. The remainder -/// is carried as 64-bit limbs rather than bytes, so each step touches four words -/// instead of thirty-two. -pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { - let mut remainder = [0u64; 4]; +/// Uses radix `2^21` limbs and the relation `2^252 = -c (mod L)`, where +/// `L = 2^252 + c`. After folding, `-L < r < L`; adding `L` when `r` is +/// negative produces a canonical scalar. +pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { + #[inline(always)] + fn fold(limbs: &mut [i64; 24], index: usize) { + // The radix-2^21 expansion of -c. + const COEFFICIENTS: [i64; 6] = [666643, 470296, 654183, -997805, 136657, -683901]; - for bit_index in (0..512).rev() { - shl1(&mut remainder); - remainder[0] |= u64::from((wide[bit_index / 8] >> (bit_index % 8)) & 1); - conditional_sub_order(&mut remainder); + let high = limbs[index]; + limbs[index] = 0; + for (j, &coefficient) in COEFFICIENTS.iter().enumerate() { + limbs[index - 12 + j] += high * coefficient; + } } - let mut reduced = [0u8; 32]; - for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { - chunk.copy_from_slice(&limb.to_le_bytes()); + let mut limbs = [0i64; 24]; + for (i, limb) in limbs.iter_mut().enumerate().take(23) { + let bit = i * 21; + let byte = bit / 8; + let word = u32::from_le_bytes(wide[byte..byte + 4].try_into().unwrap()); + *limb = i64::from((word >> (bit % 8)) & 0x1f_ffff); } - reduced -} + limbs[23] = i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3); -/// Doubles `value` in place, discarding the bit shifted out of the top. -/// -/// The caller keeps `value < L < 2^253` between iterations, so the discarded bit -/// is always zero. -fn shl1(value: &mut [u64; 4]) { - let mut carry = 0u64; - for limb in value { - let next_carry = *limb >> 63; - *limb = (*limb << 1) | carry; - carry = next_carry; + // Fold the highest six limbs before propagating their carries. + fold(&mut limbs, 23); + fold(&mut limbs, 22); + fold(&mut limbs, 21); + fold(&mut limbs, 20); + fold(&mut limbs, 19); + fold(&mut limbs, 18); + + // Normalize through limb 16 before folding limb 17. + // Stop before limb 18, which has already been folded. + for i in 6..17 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; } -} -/// Subtracts `L` from `value` when `value >= L`, without branching on the input. -fn conditional_sub_order(value: &mut [u64; 4]) { - let mut difference = [0u64; 4]; - let mut borrow = 0u64; - - for index in 0..4 { - let (partial, borrow_from_order) = - value[index].overflowing_sub(BASEPOINT_ORDER_LIMBS[index]); - let (limb, borrow_from_carry) = partial.overflowing_sub(borrow); - difference[index] = limb; - borrow = u64::from(borrow_from_order | borrow_from_carry); + fold(&mut limbs, 17); + fold(&mut limbs, 16); + fold(&mut limbs, 15); + fold(&mut limbs, 14); + fold(&mut limbs, 13); + fold(&mut limbs, 12); + + for i in 0..12 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; } - // A borrow out of the top limb means `value < L`, so the difference is - // discarded. `mask` is all-ones exactly when the subtraction should apply. - let mask = borrow.wrapping_sub(1); - for index in 0..4 { - value[index] = (value[index] & !mask) | (difference[index] & mask); + // Here limbs[12] is in [-1, 28], and the lower twelve limbs encode + // a value below 2^252. Folding once more gives -28*c <= r < L. + // Since 28*c < L, a negative remainder needs exactly one addition of L. + fold(&mut limbs, 12); + for i in 0..11 { + let carry = limbs[i] >> 21; + limbs[i] &= 0x1f_ffff; + limbs[i + 1] += carry; + } + + // Pack r modulo 2^256. The top limb retains the sign of r. + let mut remainder = [ + (limbs[0] as u64) + | ((limbs[1] as u64) << 21) + | ((limbs[2] as u64) << 42) + | ((limbs[3] as u64) << 63), + ((limbs[3] as u64) >> 1) + | ((limbs[4] as u64) << 20) + | ((limbs[5] as u64) << 41) + | ((limbs[6] as u64) << 62), + ((limbs[6] as u64) >> 2) + | ((limbs[7] as u64) << 19) + | ((limbs[8] as u64) << 40) + | ((limbs[9] as u64) << 61), + ((limbs[9] as u64) >> 3) | ((limbs[10] as u64) << 18) | ((limbs[11] as u64) << 39), + ]; + + let mask = 0u64.wrapping_sub(remainder[3] >> 63); + let mut carry = 0u64; + for i in 0..4 { + let (partial, carry_from_order) = + remainder[i].overflowing_add(BASEPOINT_ORDER_LIMBS[i] & mask); + let (limb, carry_from_carry) = partial.overflowing_add(carry); + remainder[i] = limb; + carry = u64::from(carry_from_order | carry_from_carry); + } + + for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { + chunk.copy_from_slice(&limb.to_le_bytes()); } } +#[cfg(test)] +pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { + let mut reduced = [0u8; 32]; + reduce_wide_into(wide, &mut reduced); + reduced +} + pub(crate) fn cmp_le(left: &[u8; 32], right: &[u8; 32]) -> core::cmp::Ordering { for index in (0..4).rev() { let left_limb = u64::from_le_bytes(left[index * 8..index * 8 + 8].try_into().unwrap()); @@ -141,6 +190,75 @@ mod tests { assert_eq!(reduce_wide(&saturated), expected); } + #[test] + fn matches_dalek_on_wide_reduction_boundaries_and_random_inputs() { + fn check(wide: &[u8; 64]) { + let expected = + curve25519_dalek::scalar::Scalar::from_bytes_mod_order_wide(wide).to_bytes(); + assert_eq!(reduce_wide(wide), expected, "wide input: {wide:02x?}"); + } + + fn check_neighbors(wide: [u8; 64]) { + check(&wide); + + let mut below = wide; + for byte in &mut below { + let (value, borrow) = byte.overflowing_sub(1); + *byte = value; + if !borrow { + break; + } + } + check(&below); + + let mut above = wide; + for byte in &mut above { + let (value, carry) = byte.overflowing_add(1); + *byte = value; + if !carry { + break; + } + } + check(&above); + } + + check(&[0; 64]); + check(&[0xff; 64]); + + // Powers of two and their neighbors across every bit position. + for bit in 0..512usize { + let mut wide = [0u8; 64]; + wide[bit / 8] = 1u8 << (bit % 8); + check_neighbors(wide); + } + + // L * 2^shift and its neighbors, through the highest fitting shift. + let mut shifted_order = wide_from_low_32(&BASEPOINT_ORDER); + for _ in 0..=259 { + check_neighbors(shifted_order); + + let mut carry = 0u8; + for byte in &mut shifted_order { + let next_carry = *byte >> 7; + *byte = (*byte << 1) | carry; + carry = next_carry; + } + } + + // Deterministic test inputs; this PRNG is not used by the verifier. + let mut state = 0x9e37_79b9_7f4a_7c15u64; + for _ in 0..4096 { + let mut wide = [0u8; 64]; + for chunk in wide.chunks_exact_mut(8) { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + chunk.copy_from_slice(&state.to_le_bytes()); + } + check(&wide); + } + } + #[test] fn accepts_reduced_encodings() { // y = 0 diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index 4f30465..cde0c8d 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -5,11 +5,14 @@ use { PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE, }, error::Ed25519VerifyError, - points::{compute_challenge, is_small_order, multiply_by_8}, + points::{ + compute_challenge_into, is_small_order, is_small_order_canonical, + multiscalar_multiply_edwards_2, + }, scalar, VerificationCriteria, }, solana_curve25519::{ - edwards::{multiscalar_multiply_edwards, subtract_edwards, PodEdwardsPoint}, + edwards::{subtract_edwards, PodEdwardsPoint}, scalar::PodScalar, }, }; @@ -56,6 +59,7 @@ impl Ed25519Verifier { /// Checks `S*B - H(R || A || M)*A - R == identity`, multiplied by the /// cofactor 8 when [`VerificationCriteria::cofactored`] is set. /// Canonical-encoding and small-order rejections run first. + #[inline(always)] pub fn verify_signature( &self, signature: &[u8; SIGNATURE_SERIALIZED_SIZE], @@ -86,12 +90,13 @@ impl Ed25519Verifier { return Err(Ed25519VerifyError::SmallOrderR); } - let challenge = compute_challenge(r_bytes, public_key, message); + let mut scalars = [PodScalar(*s_bytes), PodScalar([0u8; 32])]; + compute_challenge_into(r_bytes, public_key, message, &mut scalars[1].0); // `S*(-B) + H*A` is `-(S*B - H*A)`, the negation of the value the // verification equation compares against `R`. - let neg_lhs = multiscalar_multiply_edwards( - &[PodScalar(*s_bytes), PodScalar(challenge)], + let neg_lhs = multiscalar_multiply_edwards_2( + &scalars, &[ED25519_BASEPOINT_NEGATED_COMPRESSED, public_key_point], ) .ok_or(Ed25519VerifyError::InvalidEncoding)?; @@ -113,8 +118,8 @@ impl Ed25519Verifier { let difference = subtract_edwards(&lhs, &r_point).ok_or(Ed25519VerifyError::InvalidEncoding)?; - // Exact identity satisfies both equations, so accept before paying for - // the cofactor multiplication. + // Exact identity satisfies both equations, so accept before performing + // the torsion lookup. if difference == EDWARDS_IDENTITY_COMPRESSED { return Ok(()); } @@ -124,11 +129,9 @@ impl Ed25519Verifier { if !self.criteria.cofactored { return Err(Ed25519VerifyError::SignatureMismatch); } - // `difference` came from `subtract_edwards`, so `None` should be - // unreachable; `InvalidEncoding` is defensive. - if multiply_by_8(&difference).ok_or(Ed25519VerifyError::InvalidEncoding)? - != EDWARDS_IDENTITY_COMPRESSED - { + // Subtraction produces a valid, canonical encoding. + // Its cofactor multiple is identity exactly when it is a torsion point. + if !is_small_order_canonical(&difference) { return Err(Ed25519VerifyError::SignatureMismatch); } diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index 88f1fc7..aa5f9d1 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -1,6 +1,7 @@ use { ed25519_dalek::{Signer, SigningKey}, - mollusk_svm::Mollusk, + mollusk_svm::{result::ProgramResult, Mollusk}, + pinocchio::error::ProgramError, solana_account::Account, solana_address::Address, solana_ed25519_verify::{ @@ -47,7 +48,7 @@ declare_builtin_function!( /// live on mainnet and available in the local Agave/Mollusk runtime. SyscallSha512, fn rust( - _invoke_context: &mut InvokeContext, + invoke_context: &mut InvokeContext, vals_addr: u64, vals_len: u64, result_addr: u64, @@ -55,12 +56,19 @@ declare_builtin_function!( _arg5: u64, memory_mapping: &mut MemoryMapping, ) -> Result> { + let compute_cost = *invoke_context.get_execution_cost(); + invoke_context.consume_checked(compute_cost.sha256_base_cost)?; + let vals = translate_slice::(memory_mapping, vals_addr, vals_len, AccessType::Load)? .to_vec(); let mut hasher = solana_sha512_hasher::Hasher::default(); for val in &vals { let bytes = translate_slice::(memory_mapping, val.ptr, val.len, AccessType::Load)?; + let byte_cost = compute_cost + .sha256_byte_cost + .saturating_mul(val.len / 2); + invoke_context.consume_checked(compute_cost.mem_op_base_cost.max(byte_cost))?; hasher.hash(bytes); } @@ -211,10 +219,9 @@ fn rejects_tampered_message_on_sbf() { ix.data[MESSAGE_OFFSET] ^= 1; let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on tampered message, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } @@ -227,10 +234,9 @@ fn rejects_tampered_public_key_on_sbf() { ix.data[PUBLIC_KEY_OFFSET] ^= 1; let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on tampered public key, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } @@ -245,10 +251,9 @@ fn rejects_accounts_on_sbf() { let accounts = [(account, Account::default())]; let result = mollusk.process_instruction(&ix, &accounts); - assert!( - result.program_result.is_err(), - "expected failure when accounts are provided, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidArgument), ); } @@ -260,9 +265,145 @@ fn rejects_short_instruction_on_sbf() { let ix = instruction(program_id, vec![0; MESSAGE_OFFSET - 1]); let result = mollusk.process_instruction(&ix, &[]); - assert!( - result.program_result.is_err(), - "expected failure on short instruction data, got: {:?}", - result.program_result + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), ); } + +#[test] +fn verifies_signature_corpus_on_sbf_and_reports_compute_units() { + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + + let mut cases = 0u64; + let mut total_cus = 0u64; + let mut min_cus = u64::MAX; + let mut max_cus = 0u64; + + for seed in [7u8, 42, 99, 201] { + let signing_key = SigningKey::from_bytes(&[seed; 32]); + let public_key = signing_key.verifying_key().to_bytes(); + + for message_len in [0usize, 1, 38, 47, 48, 49, 128, 1024] { + let message = if message_len == SINGLE_MESSAGE.len() { + SINGLE_MESSAGE.to_vec() + } else { + (0..message_len) + .map(|index| (index as u8).wrapping_mul(31).wrapping_add(17)) + .collect::>() + }; + let signature = signing_key.sign(&message).to_bytes(); + let ix = verify(&program_id, &public_key, &signature, &message); + + let result = mollusk.process_instruction(&ix, &[]); + assert_eq!( + result.program_result, + ProgramResult::Success, + "corpus verification failed: seed={seed}, message_len={message_len}", + ); + + let cus = result.compute_units_consumed; + println!("ed25519 corpus: seed={seed}, message_bytes={message_len}, CUs={cus}"); + + cases += 1; + total_cus += cus; + min_cus = min_cus.min(cus); + max_cus = max_cus.max(cus); + } + } + + println!( + "ed25519 corpus summary: cases={cases}, total_CUs={total_cus}, \ + min_CUs={min_cus}, max_CUs={max_cus}" + ); +} + +#[test] +fn accepts_zip215_torsion_encodings_on_sbf_and_reports_compute_units() { + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + // Eight canonical torsion encodings, followed by six non-canonical aliases. + let encodings = [ + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + "0100000000000000000000000000000000000000000000000000000000000000", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + "0100000000000000000000000000000000000000000000000000000000000080", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "edffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "eeffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff", + ]; + + for (case, encoded) in encodings.iter().enumerate() { + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + for (i, byte) in signature[..32].iter_mut().enumerate() { + *byte = u8::from_str_radix(&encoded[2 * i..2 * i + 2], 16).unwrap(); + } + // A = identity and S = 0 leave -R as the verification difference. + let ix = verify( + &program_id, + &EDWARDS_IDENTITY_COMPRESSED, + &signature, + b"zip215 torsion fallback", + ); + let result = mollusk.process_instruction(&ix, &[]); + assert!( + result.program_result.is_ok(), + "torsion case={case} failed: {:?}", + result.program_result + ); + println!( + "ed25519 torsion: case={case}, CUs={}", + result.compute_units_consumed + ); + } +} + +#[test] +fn rejects_invalid_msm_inputs_on_sbf() { + use {mollusk_svm::result::ProgramResult, pinocchio::error::ProgramError}; + + let Some((mollusk, program_id)) = make_mollusk() else { + return; + }; + + const ORDER: [u8; 32] = [ + 0xed, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58, 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, + 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x10, + ]; + + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + signature[..32].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + // With A = R = identity, reducing S = L to zero would wrongly accept. + let mut noncanonical_s = signature; + noncanonical_s[32..].copy_from_slice(&ORDER); + + // Compressed y = 2 does not decompress to an Edwards point. + let mut invalid_public_key = [0u8; PUBKEY_SERIALIZED_SIZE]; + invalid_public_key[0] = 2; + + for (case, public_key, signature) in [ + ("S equals L", EDWARDS_IDENTITY_COMPRESSED, noncanonical_s), + ("invalid A", invalid_public_key, signature), + ] { + let ix = verify(&program_id, &public_key, &signature, b"invalid MSM inputs"); + let result = mollusk.process_instruction(&ix, &[]); + + assert_eq!( + result.program_result, + ProgramResult::Failure(ProgramError::InvalidInstructionData), + "{case}" + ); + } +}