diff --git a/README.md b/README.md index a4a60a9..f76c8c9 100644 --- a/README.md +++ b/README.md @@ -6,9 +6,8 @@ the Curve25519 and SHA-512 syscalls. ## Motivation The goal is to migrate the native [ed25519 precompile] to SBF so it can be -maintained and deployed like any other on-chain program. The instruction format -is intentionally identical to the precompile for current-instruction data, so -clients can reuse the standard Ed25519 instruction layout. +maintained and deployed like any other on-chain program. This prototype uses the +compact `public_key || signature || message` instruction layout described below. Being a regular SBF program also unlocks CPI: another program can invoke this one and act on the explicit pass/fail result, rather than relying on @@ -22,12 +21,13 @@ succeeded. | 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_curve_group_op` | `solana_curve25519::edwards::subtract_edwards` on the single-signature fallback path | +| `sol_curve_validate_point` | Selected malformed-input checks | +| `sol_curve_multiscalar_mul` | Fixed two-term wrapper for individual verification | -`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 -feature before SBF execution will work. +The benchmark's published Mollusk/Agave runtime does not register `sol_sha512`, +so the tests install a metered shim using `solana-sha512-hasher`. Deployment +requires a VM that enables the SHA-512 syscall. ## Instruction format @@ -83,19 +83,20 @@ knobs via `VerificationCriteria`: | `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 | +| `reject_small_order_a` | Reject small-order (torsion) public keys | none on the successful path | +| `reject_small_order_r` | Reject small-order signature `R` values | none on the successful path | 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 +the default profile subtracts `R` and checks the resulting point. It 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. +Small-order checks inspect compressed encodings directly, including valid +non-canonical aliases. Curve validity is enforced by the MSM, a match to the +computed valid point, or a fallback curve operation. Strict verification with +canonical `R` can reject a byte mismatch after a 159-CU point-validation syscall, +avoiding the 475-CU subtraction. These are syscall charges; surrounding SBF +instructions also contribute to the measured totals. Canonical `S` (`S < L`) has no knob. Every profile worth targeting requires it because accepting `S ≥ L` reintroduces signature malleability. The syscall @@ -154,14 +155,11 @@ Solana-specific type. | `NonCanonicalR` | `R`'s `y`-coordinate is `≥ p` (`require_canonical_r` only) | | `SmallOrderPublicKey` | `A` is a small-order (torsion) point (`reject_small_order_a` only) | | `SmallOrderR` | `R` is a small-order (torsion) point (`reject_small_order_r` only) | -| `InvalidEncoding` | `A` doesn't decode to a valid point, or `S` is non-canonical (`S ≥ L`) | +| `InvalidEncoding` | `A` or `R` doesn't decode to a valid point, or `S` is non-canonical (`S ≥ L`) | | `SignatureMismatch` | Every input decoded successfully, but the equation doesn't hold | -`InvalidEncoding` does not distinguish a malformed public key from a -non-canonical `S` scalar: the syscall that consumes both reports only overall -success or failure. Telling them apart would mean an explicit `S < L` comparison, -or decoding `A` ahead of the syscall — compute units spent on every signature for -precision that only helps malformed input. +The MSM checks `A` and `S` together and reports the same `InvalidEncoding` +error for malformed scalar or point encodings. The on-chain program collapses all of these to `ProgramError::InvalidInstructionData` — see [Constraints](#constraints). @@ -172,7 +170,7 @@ The on-chain program collapses all of these to | Feature | Unlocks | Pulls in | | ------------- | --------------------------------------------------------------- | ------------------------------------------- | -| `verify` | `Ed25519Verifier`, `VerificationCriteria`, `Ed25519VerifyError` | `solana-curve25519`, `solana-sha512-hasher` | +| `verify` | `Ed25519Verifier`, criteria and errors | `solana-curve25519`, `solana-sha512-hasher` | | `instruction` | `verify()`, `id()`, `ID` (the client-side instruction builder) | `solana-instruction`, `solana-address` | A pure client that only needs to construct instructions for CPI or a @@ -190,12 +188,15 @@ solana-ed25519-verify = { version = "0.1.0", default-features = false, features Stable Rust `1.93.1` is pinned in `rust-toolchain.toml`. Some make targets also require the nightly Rust chain `nightly-2026-01-22`. +SBF builds default to **v3** through `SBF_ARCH` in the Makefile, including CI +builds. Pass `--arch v3` when invoking `cargo build-sbf` directly. + ```sh -# Unit tests (host, no SBF toolchain required) -cargo test --manifest-path program/Cargo.toml +# Library unit and integration tests (host, no SBF toolchain required) +cargo test --manifest-path ed25519-verify/Cargo.toml # SBF build only -cargo build-sbf --arch v2 --manifest-path program/Cargo.toml +cargo build-sbf --arch v3 --manifest-path program/Cargo.toml # SBF build via Makefile make build-sbf-program @@ -219,31 +220,32 @@ 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 measurements below were collected on September 18, 2026, using an SBF v3 +release build with `cargo-build-sbf 4.1.0`, platform-tools `v1.54` (Rust `1.89.0`), +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. +`201`, each tested with the eight message lengths below. Ranges show the observed +minimum and maximum across those seeds. Encoding checks and scalar correction +can cause small differences between signatures of the same message length. | 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 +| 0 | 3,733 | 3,745 | +| 1 | 3,733 | 3,745–3,767 | +| 38 | 3,742 | 3,754 | +| 47 | 3,746 | 3,758–3,780 | +| 48 | 3,747 | 3,759 | +| 49 | 3,747–3,748 | 3,759–3,760 | +| 128 | 3,787 | 3,799 | +| 1,024 | 4,235 | 4,247 | +| **32-case total** | **121,881** | **122,309** | +| **Mean per case** | **3,808.78** | **3,822.16** | + +The separate 38-byte signature fixture also consumes **3,742 CU** with ZIP-215 +and **3,754 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. @@ -251,18 +253,18 @@ 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 | +| Accepted small-order public-key fixture | 4,281 | +| Accepted torsion encodings, 14 cases | 3,735–4,287 per case; 59,364 total | +| Tampered message / public key | 4,292 / 4,293 | +| Non-canonical `S` or invalid public-key encoding | 3,722 each | +| Unexpected accounts | 16 | +| Instruction shorter than 96 bytes | 19 | To reproduce the default program measurements: ```sh -cargo build-sbf --arch v2 --manifest-path program/Cargo.toml \ - --sbf-out-dir "$PWD/target/deploy" +cargo build-sbf --arch v3 --tools-version v1.54 --manifest-path program/Cargo.toml \ + --sbf-out-dir "$PWD/target/deploy" -- --locked SBF_OUT_DIR="$PWD/target/deploy" cargo test --locked \ -p solana-ed25519-program --test mollusk \ -- --nocapture --test-threads=1 @@ -270,3 +272,11 @@ SBF_OUT_DIR="$PWD/target/deploy" cargo test --locked \ Changing the SBF toolchain, dependencies, or syscall cost model can change these results. Compare builds using the same corpus and metered syscall shim. + +With matched strict criteria (`dalek_verify_strict()` plus +`require_canonical_a: true`), the 38-byte fixture consumes **3,757 CU**, versus +**4,853 CU** for [brine-ed25519 0.9.3] with its strict feature: **22.6% less** using +the same SBF v3 toolchain and syscall metering. Brine's default policy differs +from ZIP-215, so those defaults are not a like-for-like semantic comparison. + +[brine-ed25519 0.9.3]: https://github.com/zfedoran/brine-ed25519/tree/3683ee1720db99df8252ecbf5b2924e8ab37a851 diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs index 0b5fed5..f6a8470 100644 --- a/ed25519-verify/src/config.rs +++ b/ed25519-verify/src/config.rs @@ -55,13 +55,14 @@ pub struct VerificationCriteria { pub require_canonical_r: bool, /// Reject public keys that lie in the small-order (torsion) subgroup. /// - /// Costs one point addition to validate the input and produce a canonical - /// encoding, followed by a torsion lookup, when enabled. + /// Checks the compressed encoding, including non-canonical aliases. + /// Point validity is enforced by the curve operations during verification. pub reject_small_order_a: bool, /// Reject signature `R` values that lie in the small-order subgroup. /// - /// Costs one point addition to validate the input and produce a canonical - /// encoding, followed by a torsion lookup, when enabled. + /// Checks the compressed encoding, including non-canonical aliases. + /// Point validity is enforced by the verification equation or, on a byte + /// mismatch, a curve operation. pub reject_small_order_r: bool, } diff --git a/ed25519-verify/src/constants.rs b/ed25519-verify/src/constants.rs index 764f9e9..adc7d38 100644 --- a/ed25519-verify/src/constants.rs +++ b/ed25519-verify/src/constants.rs @@ -21,7 +21,7 @@ pub(crate) const BASEPOINT_ORDER: [u8; 32] = [ /// /// Evaluated at compile time so that limb constants are derived from their byte /// counterparts rather than transcribed by hand a second time. -const fn to_le_limbs(bytes: [u8; 32]) -> [u64; 4] { +pub(crate) const fn to_le_limbs(bytes: [u8; 32]) -> [u64; 4] { let mut limbs = [0u64; 4]; let mut limb_index = 0; diff --git a/ed25519-verify/src/error.rs b/ed25519-verify/src/error.rs index e86999e..9f16dc8 100644 --- a/ed25519-verify/src/error.rs +++ b/ed25519-verify/src/error.rs @@ -28,10 +28,10 @@ pub enum Ed25519VerifyError { /// /// [`reject_small_order_r`]: crate::VerificationCriteria::reject_small_order_r SmallOrderR, - /// The public key does not decode to a valid Edwards curve point, or the - /// signature's `S` scalar is not canonical (`S >= L`). + /// The public key or signature's `R` does not decode to a valid Edwards + /// curve point, or the signature's `S` scalar is not canonical (`S >= L`). /// - /// These two causes are not distinguished: the syscall that consumes both + /// Invalid `A` and `S` are not distinguished: the syscall that consumes both /// reports only overall success or failure. Telling them apart would mean an /// explicit `S < L` comparison, or decoding `A` ahead of the syscall — compute /// units spent on every signature for precision that only helps malformed input. diff --git a/ed25519-verify/src/points.rs b/ed25519-verify/src/points.rs index b78c1fc..5263976 100644 --- a/ed25519-verify/src/points.rs +++ b/ed25519-verify/src/points.rs @@ -4,24 +4,47 @@ //! challenge hash `H(R || A || M) mod L`. use { - crate::{constants::EDWARDS_IDENTITY_COMPRESSED, error::Ed25519VerifyError, scalar}, - solana_curve25519::{ - edwards::{add_edwards, PodEdwardsPoint}, - scalar::PodScalar, - }, + crate::{constants::EDWARDS_IDENTITY_COMPRESSED, scalar}, + solana_curve25519::{edwards::PodEdwardsPoint, scalar::PodScalar}, }; -/// Returns `Ok(true)` if `point` decompresses to a small-order (torsion) point. -/// -/// 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. +#[cfg(test)] +use {crate::error::Ed25519VerifyError, solana_curve25519::edwards::add_edwards}; + +/// Tests whether an encoding represents a torsion point, including aliases. /// -/// An encoding that does not decompress returns `Err(InvalidEncoding)`. -pub(crate) fn is_small_order(point: &PodEdwardsPoint) -> Result { - let canonical = add_edwards(point, &EDWARDS_IDENTITY_COMPRESSED) - .ok_or(Ed25519VerifyError::InvalidEncoding)?; - Ok(is_small_order_canonical(&canonical)) +/// This does not validate other encodings. Before accepting a signature, a +/// `false` result still requires a curve operation or a match to a known-valid +/// point encoding. +#[inline(always)] +pub(crate) fn is_small_order_encoding(bytes: &[u8; 32]) -> bool { + const Y0: [u64; 4] = crate::constants::to_le_limbs(ORDER_EIGHT_Y0); + const Y1: [u64; 4] = crate::constants::to_le_limbs(ORDER_EIGHT_Y1); + + let low = || u64::from_le_bytes(bytes[..8].try_into().unwrap()); + let middle_low = || u64::from_le_bytes(bytes[8..16].try_into().unwrap()); + let middle_high = || u64::from_le_bytes(bytes[16..24].try_into().unwrap()); + let high = || u64::from_le_bytes(bytes[24..].try_into().unwrap()) & 0x7fff_ffff_ffff_ffff; + + match bytes[31] & 0x7f { + // y = 0 or 1, with either sign bit (including negative zero). + 0x00 => low() & !1 == 0 && middle_low() == 0 && middle_high() == 0 && high() == 0, + // The two remaining y-coordinates of the order-eight points. + 0x05 => { + low() == Y0[0] && middle_low() == Y0[1] && middle_high() == Y0[2] && high() == Y0[3] + } + 0x7a => { + low() == Y1[0] && middle_low() == Y1[1] && middle_high() == Y1[2] && high() == Y1[3] + } + // y = p - 1, p, or p + 1. The last two alias y = 0 and 1. + 0x7f => { + low().wrapping_add(20) <= 2 + && middle_low() == u64::MAX + && middle_high() == u64::MAX + && high() == 0x7fff_ffff_ffff_ffff + } + _ => false, + } } /// Multiplies `point` by the cofactor 8 via three point doublings. @@ -43,8 +66,9 @@ pub(crate) fn compute_challenge_into( 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); + // Borrow the digest to avoid a 64-byte copy before reduction on SBF. + let digest = solana_sha512_hasher::hashv(&[signature_r, public_key, message]); + scalar::reduce_wide_into(digest.as_bytes(), challenge); } #[cfg(test)] @@ -58,6 +82,16 @@ pub(crate) fn compute_challenge( challenge } +// The two y-coordinates of order-eight points, with the sign bit cleared. +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, +]; + /// Tests torsion membership for a valid, canonical curve encoding. /// /// Use only with canonical encodings returned by successful curve operations. @@ -69,16 +103,6 @@ pub(crate) fn is_small_order_canonical(point: &PodEdwardsPoint) -> bool { 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; @@ -187,26 +211,6 @@ mod tests { assert_eq!(multiply_by_8(&point), None); } - #[test] - fn is_small_order_true_for_torsion_point() { - let point = PodEdwardsPoint(SMALL_ORDER_PUBLIC_KEY_COMPRESSED); - assert_eq!(is_small_order(&point), Ok(true)); - } - - #[test] - fn is_small_order_false_for_prime_order_point() { - assert_eq!(is_small_order(&prime_order_point()), Ok(false)); - } - - #[test] - fn is_small_order_propagates_decompression_failure() { - let point = PodEdwardsPoint(NON_DECOMPRESSING_ENCODING); - assert_eq!( - is_small_order(&point), - Err(Ed25519VerifyError::InvalidEncoding) - ); - } - #[test] fn compute_challenge_hashes_r_then_a_then_message() { // Independently re-derives H(R || A || M) via a differently-shaped @@ -270,7 +274,7 @@ mod tests { } #[test] - fn is_small_order_matches_dalek_on_canonical_and_noncanonical_inputs() { + fn encoded_torsion_lookup_matches_dalek() { use curve25519_dalek::{ constants::{ED25519_BASEPOINT_POINT, EIGHT_TORSION}, edwards::CompressedEdwardsY, @@ -280,11 +284,9 @@ mod tests { let check = |encoding: [u8; 32]| { let expected = CompressedEdwardsY(encoding) .decompress() - .map(|point| point.is_small_order()) - .ok_or(Ed25519VerifyError::InvalidEncoding); - + .is_some_and(|point| point.is_small_order()); assert_eq!( - is_small_order(&PodEdwardsPoint(encoding)), + is_small_order_encoding(&encoding), expected, "encoding={encoding:02x?}" ); @@ -297,25 +299,44 @@ mod tests { } } - // 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 torsion_encodings = [[0u8; 32]; 14]; + for (encoding, point) in torsion_encodings[..8].iter_mut().zip(EIGHT_TORSION) { + *encoding = point.compress().to_bytes(); + } + let mut aliases = 8; + for sign in [0u8, 0x80] { + for y in 0..19u8 { + let mut reduced = [0u8; 32]; + reduced[0] = y; + reduced[31] = sign; + check(reduced); let mut alias = [0xffu8; 32]; alias[0] = 0xed + y; alias[31] = 0x7f | sign; check(alias); + if y < 2 { + torsion_encodings[aliases] = alias; + aliases += 1; + } + } + } + let mut negative_identity = EDWARDS_IDENTITY_COMPRESSED.0; + negative_identity[31] = 0x80; + torsion_encodings[12] = negative_identity; + let mut negative_order_two = [0xffu8; 32]; + negative_order_two[0] = 0xec; + torsion_encodings[13] = negative_order_two; + + // Check all 14 encodings and every one-bit neighbor. This catches + // accidentally omitting a byte or ignoring an extra bit in a lookup. + for encoding in torsion_encodings { + assert!(is_small_order_encoding(&encoding)); + for bit in 0..256 { + let mut neighbor = encoding; + neighbor[bit / 8] ^= 1 << (bit % 8); + check(neighbor); } } - - // 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 55cccc8..1cd7d14 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -21,6 +21,7 @@ pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { /// 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. +/// This variable-time helper only handles public verification challenges. pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { #[inline(always)] fn fold(limbs: &mut [i64; 24], index: usize) { @@ -34,14 +35,19 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { } } - 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); - } - limbs[23] = i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3); + // Initialize every limb at once so the SBF compiler can unroll decoding + // without zeroing a temporary array. Keep the wider top limb here too. + let mut limbs: [i64; 24] = core::array::from_fn(|i| { + if i == 23 { + // The top limb contains the remaining 29 bits. + i64::from(u32::from_le_bytes(wide[60..64].try_into().unwrap()) >> 3) + } else { + let bit = i * 21; + let byte = bit / 8; + let word = u32::from_le_bytes(wide[byte..byte + 4].try_into().unwrap()); + i64::from((word >> (bit % 8)) & 0x1f_ffff) + } + }); // Fold the highest six limbs before propagating their carries. fold(&mut limbs, 23); @@ -51,9 +57,10 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { 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 { + // Normalize limbs 7..16 before the next folds. Limb 6 can wait for the + // full carry pass below; every folding intermediate stays below 2^49 + // in magnitude without carrying it here. + for i in 7..17 { let carry = limbs[i] >> 21; limbs[i] &= 0x1f_ffff; limbs[i + 1] += carry; @@ -76,14 +83,29 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { // 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 { + for i in 0..5 { 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 = [ + let carry = limbs[5] >> 21; + limbs[5] &= 0x1f_ffff; + + // The upper six limbs are still normalized. Pack their 126 bits and add + // the carry once instead of propagating through five more radix-2^21 + // limbs. The carry is in [-10, 1], so upper is in [-10, 2^126] and fits + // in i128. Signed shifts below preserve a negative remainder's sign. + let upper = (i128::from(limbs[6]) + | (i128::from(limbs[7]) << 21) + | (i128::from(limbs[8]) << 42) + | (i128::from(limbs[9]) << 63) + | (i128::from(limbs[10]) << 84) + | (i128::from(limbs[11]) << 105)) + + i128::from(carry); + + // Pack r modulo 2^256. The top word retains the sign of r. + let remainder = [ (limbs[0] as u64) | ((limbs[1] as u64) << 21) | ((limbs[2] as u64) << 42) @@ -91,22 +113,36 @@ pub(crate) fn reduce_wide_into(wide: &[u8; 64], reduced: &mut [u8; 32]) { ((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), + | ((upper as u64) << 62), + (upper >> 2) as u64, + (upper >> 66) as u64, ]; - 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); + // Keep the uncommon correction out of the normal path. Passing words by + // value avoids materializing a temporary array for the SBF call. + #[cold] + fn add_order_and_store(r0: u64, r1: u64, r2: u64, r3: u64, reduced: &mut [u8; 32]) { + let mut carry = 0u64; + for ((chunk, limb), order) in reduced + .chunks_exact_mut(8) + .zip([r0, r1, r2, r3]) + .zip(BASEPOINT_ORDER_LIMBS) + { + // Every order limb is below u64::MAX, so order + carry fits. + let (limb, overflow) = limb.overflowing_add(order + carry); + chunk.copy_from_slice(&limb.to_le_bytes()); + carry = u64::from(overflow); + } + } + if (remainder[3] as i64) < 0 { + add_order_and_store( + remainder[0], + remainder[1], + remainder[2], + remainder[3], + reduced, + ); + return; } for (chunk, limb) in reduced.chunks_exact_mut(8).zip(remainder) { @@ -166,8 +202,8 @@ mod tests { assert_eq!(reduce_wide(&wide_from_low_32(&order_plus_one)), one); } - // `reduce_wide` is hand-rolled modular arithmetic with a branch-free carry - // chain, so it is cross-checked against curve25519-dalek's own wide + // `reduce_wide` is hand-rolled modular arithmetic with a conditional final + // correction, so it is cross-checked against curve25519-dalek's own wide // reduction rather than against a second copy of the same reasoning. #[test] fn matches_curve25519_dalek_wide_reduction() { @@ -234,9 +270,33 @@ mod tests { // 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 { + for shift in 0..=259 { check_neighbors(shifted_order); + // Exercise both carry directions at packed-word boundaries near + // multiples of L, where random inputs rarely reach negative r. + if [0, 64, 126, 127, 128, 252, 259].contains(&shift) { + for bit in [0, 63, 64, 125, 126, 127, 128, 189, 190, 191, 192, 251, 252] { + for subtract in [false, true] { + let mut boundary = shifted_order; + let mut carry = 1u8 << (bit % 8); + for byte in &mut boundary[bit / 8..] { + let (value, next) = if subtract { + byte.overflowing_sub(carry) + } else { + byte.overflowing_add(carry) + }; + *byte = value; + carry = u8::from(next); + if carry == 0 { + break; + } + } + check_neighbors(boundary); + } + } + } + let mut carry = 0u8; for byte in &mut shifted_order { let next_carry = *byte >> 7; diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index cde0c8d..5579278 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -6,13 +6,13 @@ use { }, error::Ed25519VerifyError, points::{ - compute_challenge_into, is_small_order, is_small_order_canonical, + compute_challenge_into, is_small_order_canonical, is_small_order_encoding, multiscalar_multiply_edwards_2, }, scalar, VerificationCriteria, }, solana_curve25519::{ - edwards::{subtract_edwards, PodEdwardsPoint}, + edwards::{subtract_edwards, validate_edwards, PodEdwardsPoint}, scalar::PodScalar, }, }; @@ -59,6 +59,8 @@ 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. + /// Point decompression is deferred to the verification operations, so + /// malformed points may cost as much to reject as other invalid signatures. #[inline(always)] pub fn verify_signature( &self, @@ -73,23 +75,40 @@ impl Ed25519Verifier { // `S < L` is enforced by the multiscalar-mul syscall, not here; see // `VerificationCriteria` for why there is no knob. - if self.criteria.require_canonical_a && !scalar::is_canonical_point_encoding(public_key) { + // Every torsion encoding and non-canonical y has its masked final + // byte in 0..=5 or 0x7a..=0x7f. Other encodings need no byte checks; + // their curve validity is still enforced by the operations below. + let check_a = public_key[31].wrapping_add(6) & 0x7f <= 11; + let check_r = r_bytes[31].wrapping_add(6) & 0x7f <= 11; + + if self.criteria.require_canonical_a + && check_a + && !scalar::is_canonical_point_encoding(public_key) + { return Err(Ed25519VerifyError::NonCanonicalPublicKey); } - if self.criteria.require_canonical_r && !scalar::is_canonical_point_encoding(r_bytes) { + if self.criteria.require_canonical_r + && check_r + && !scalar::is_canonical_point_encoding(r_bytes) + { return Err(Ed25519VerifyError::NonCanonicalR); } - let r_point = PodEdwardsPoint(*r_bytes); - let public_key_point = PodEdwardsPoint(*public_key); - - if self.criteria.reject_small_order_a && is_small_order(&public_key_point)? { + if self.criteria.reject_small_order_a && check_a && is_small_order_encoding(public_key) { return Err(Ed25519VerifyError::SmallOrderPublicKey); } - if self.criteria.reject_small_order_r && is_small_order(&r_point)? { + if self.criteria.reject_small_order_r && check_r && is_small_order_encoding(r_bytes) { + // Preserve the error precedence of validating A before checking R. + // Other A encodings are validated by the MSM below. + if self.criteria.reject_small_order_a + && !validate_edwards(&PodEdwardsPoint(*public_key)) + { + return Err(Ed25519VerifyError::InvalidEncoding); + } return Err(Ed25519VerifyError::SmallOrderR); } + let public_key_point = PodEdwardsPoint(*public_key); let mut scalars = [PodScalar(*s_bytes), PodScalar([0u8; 32])]; compute_challenge_into(r_bytes, public_key, message, &mut scalars[1].0); @@ -104,15 +123,29 @@ impl Ed25519Verifier { // Flipping the sign bit recovers the left-hand side's encoding. // `neg_lhs` is canonical, so the flip is too — except at `x = 0`, where // it yields negative zero, which can only miss, never falsely match. A - // byte match implies `R == lhs`, satisfying both equations, and skips - // `subtract_edwards` on the happy path. + // byte match also proves R is a valid point and implies `R == lhs`, + // satisfying both equations without a separate curve operation. let mut lhs_bytes = neg_lhs.0; lhs_bytes[31] ^= 0x80; if lhs_bytes == *r_bytes { return Ok(()); } + if !self.criteria.cofactored + && self.criteria.require_canonical_r + && self.criteria.reject_small_order_r + { + // Non-small-order points have unique canonical encodings. The + // byte mismatch rules out equality; only R's validity is unknown. + return if validate_edwards(&PodEdwardsPoint(*r_bytes)) { + Err(Ed25519VerifyError::SignatureMismatch) + } else { + Err(Ed25519VerifyError::InvalidEncoding) + }; + } + let lhs = PodEdwardsPoint(lhs_bytes); + let r_point = PodEdwardsPoint(*r_bytes); // `lhs` is valid by construction, so a `None` here means `r_point`, // built from caller-supplied bytes, failed to decode. let difference = diff --git a/ed25519-verify/tests/verify_instruction.rs b/ed25519-verify/tests/verify_instruction.rs index 5537471..0100c0a 100644 --- a/ed25519-verify/tests/verify_instruction.rs +++ b/ed25519-verify/tests/verify_instruction.rs @@ -523,3 +523,34 @@ fn dalek_verify_strict_preset_rejects_zip215_small_order_key() { message )); } + +#[test] +fn malformed_points_remain_invalid() { + let (signature, public_key) = signed_payload(b"invalid point regression"); + let mut invalid = [0; 32]; + invalid[0] = 2; // Does not decompress. + let mut invalid_r = signature; + invalid_r[..32].copy_from_slice(&invalid); + for criteria in [ + VerificationCriteria::zip215(), + VerificationCriteria::dalek_verify_strict(), + ] { + for (signature, key) in [(&signature, &invalid), (&invalid_r, &public_key)] { + assert_eq!( + verify_with(criteria, signature, key, b"invalid point regression"), + Err(Ed25519VerifyError::InvalidEncoding) + ); + } + } + // Strict mode must validate A before rejecting a small-order R. + invalid_r[..32].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + assert_eq!( + verify_with( + VerificationCriteria::dalek_verify_strict(), + &invalid_r, + &invalid, + b"invalid point regression" + ), + Err(Ed25519VerifyError::InvalidEncoding) + ); +}