diff --git a/.config/spellcheck.dic b/.config/spellcheck.dic index 74c3c84..9e8c8ce 100644 --- a/.config/spellcheck.dic +++ b/.config/spellcheck.dic @@ -1,21 +1,35 @@ -20 +33 +19AM CPI +Cofactorless Ed25519 Ed25519SignatureOffsets +FIPS Mollusk SBF SDK SVM Solana +canonicity +catalogued +cofactor cofactored +cofactorless cryptographic +dalek +dalek's +de deserializes ed25519 +encodings entrypoint +libsodium malleability precompile precompiles runtime +selectable syscall syscalls verifier +versa diff --git a/.config/spellcheck.toml b/.config/spellcheck.toml index 9251ba5..de303e0 100644 --- a/.config/spellcheck.toml +++ b/.config/spellcheck.toml @@ -2,4 +2,7 @@ use_builtin = true search_dirs = ["."] extra_dictionaries = ["spellcheck.dic"] - +# Treat the em dash (U+2014) as a token separator; cargo-spellcheck's default +# split chars cover the other dash variants but not this one, so a space-padded +# " — " would otherwise be spell-checked as the word "—". +tokenization_splitchars = "\",';:.!?#(){}[]|/_-‒–⁃⁻₋−⸺⸻\n…—" diff --git a/README.md b/README.md index 79e968f..d78530c 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ succeeded. | Syscall | SDK wrapper | |---|---| | `sol_sha512` | `solana_sha512_hasher::hashv` | -| `sol_curve_group_op` | `solana_curve25519::edwards::multiply_edwards` | +| `sol_curve_group_op` | `solana_curve25519::edwards::{add_edwards, subtract_edwards}` | | `sol_curve_multiscalar_mul` | `solana_curve25519::edwards::multiscalar_multiply_edwards` | `sol_sha512` is not live on mainnet yet. The wrapper crate is published as @@ -31,44 +31,81 @@ feature before SBF execution will work. ## Instruction format -```text -[0] number of signatures (u8) -[1] padding, ignored -[2 .. 2 + 14*N] N x Ed25519SignatureOffsets records (14 bytes each, LE) -[2 + 14*N ..] payload: public keys, signatures, messages (order flexible) -``` - -Each offset record matches `Ed25519SignatureOffsets` exposed by this crate: +The program verifies a single signature. Instruction data is: ```text -[0..2] signature_offset -[2..4] signature_instruction_index -[4..6] public_key_offset -[6..8] public_key_instruction_index -[8..10] message_data_offset -[10..12] message_data_size -[12..14] message_instruction_index +[0 .. 32] public key A (32 bytes) +[32 .. 96] signature R‖S (64 bytes) +[96 ..] message ``` +The `ed25519_verify_instruction` helper in `solana-ed25519-verify` builds this +layout. + ### Constraints -- **All instruction-index fields must be `u16::MAX`.** The native precompile - uses this sentinel for the current instruction. An SBF program receives only - its own instruction data; cross-instruction references require a future - runtime change. -- **ZIP-215 verification.** The program uses the cofactored equation - `[8](S·B − H(R‖A‖M)·A) == [8]R` with canonical `S`, following - [ZIP-215](https://zips.z.cash/zip-0215). Small-order `R` and public-key - points are not explicitly rejected — the cofactor multiplication makes them - indistinguishable from the identity contribution and verification fails - naturally for any signature not crafted for them. This is backward compatible - with `ed25519_dalek::verify_strict`: every point accepted by dalek is also - accepted here (dalek rejects small-order points outright, so no valid dalek - signature is broken by the relaxed check). -- **Zero-signature payloads** are accepted only when the buffer is exactly the - 2-byte header. +- **Verification criteria.** The program always applies [ZIP-215]: the + cofactored equation `[8](S·B − H(R‖A‖M)·A) == [8]R` with canonical `S`. + Small-order and non-canonical points are accepted. Programs needing a + different variant (e.g. `verify_strict`) should depend on the + `solana-ed25519-verify` library directly (see + [Verification criteria](#verification-criteria-library)). - **No accounts.** The program takes no account arguments and returns `InvalidArgument` if any are supplied. +- **Minimum length.** Instruction data shorter than the 96-byte + `A || R‖S` header is rejected with `InvalidInstructionData`. + +[ZIP-215]: https://zips.z.cash/zip-0215 + +## Verification criteria (library) + +Ed25519 "validity" is not one definition — implementations differ on cofactoring, +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` (multiply-by-8 as three doublings) | +| `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` | +| `reject_small_order_r` | Reject small-order signature `R` values | +3 `sol_curve_group_op` | +| `require_canonical_s` | Reject `S ≥ L` | none | + +```rust +use solana_ed25519_verify::{Ed25519Verifier, VerificationCriteria}; + +// Default: the ZIP-215 preset (cofactored, canonical S required). +let verifier = Ed25519Verifier::new(); + +// `ed25519-dalek`'s verify_strict semantics. +let strict = Ed25519Verifier::with_criteria(VerificationCriteria::dalek_verify_strict()); + +// Or compose a variant by overriding individual knobs. +let custom = Ed25519Verifier::with_criteria(VerificationCriteria { + reject_small_order_a: true, + ..VerificationCriteria::zip215() +}); +``` + +Named presets: + +| Preset | `cofactored` | `canonical_a` | `canonical_r` | `small_order_a` | `small_order_r` | `canonical_s` | +|---|---|---|---|---|---|---| +| `zip215()` (default) | ✓ | | | | | ✓ | +| `dalek_verify_strict()` | | | ✓ | ✓ | ✓ | ✓ | + +`dalek_verify_strict()` matches `ed25519_dalek::VerifyingKey::verify_strict` +exactly (cross-checked in the test suite), including the detail that a +non-canonically encoded public key `A` is *not* rejected. Further presets +(libsodium, RFC 8032 / FIPS 186-5) can be added in follow-ups. + +The on-chain program always applies the `zip215()` preset. A program needing a +different variant should depend on this crate directly and build an +`Ed25519Verifier` from the desired `VerificationCriteria`. + +[It's 255:19AM]: https://hdevalence.ca/blog/2020-10-04-its-25519am/ ## Build and test diff --git a/ed25519-verify/src/config.rs b/ed25519-verify/src/config.rs new file mode 100644 index 0000000..c55c0e5 --- /dev/null +++ b/ed25519-verify/src/config.rs @@ -0,0 +1,111 @@ +//! Configurable Ed25519 verification criteria. +//! +//! Ed25519 "signature validity" is not a single definition: implementations +//! differ on cofactored vs. cofactorless verification, whether non-canonical +//! point encodings are accepted, and whether small-order points are rejected. +//! These divergences are catalogued in Henry de Valence's +//! ["It's 255:19AM. Do you know what your validation criteria are?"][blog]. +//! +//! [`VerificationCriteria`] exposes those divergences as independent knobs so a +//! caller can select the exact variant they need. Two named presets ship today — +//! [`zip215`] (the [ZIP-215] criteria specified by [SIMD-0376]) and +//! [`dalek_verify_strict`] — and the knobs are designed so that other well-known +//! profiles (e.g. libsodium, RFC 8032 / FIPS 186-5) can be added as presets in +//! follow-ups without changing the verifier. +//! +//! [blog]: https://hdevalence.ca/blog/2020-10-04-its-25519am/ +//! [ZIP-215]: https://zips.z.cash/zip-0215 +//! [SIMD-0376]: https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0376-verify-strict.md +//! [`zip215`]: VerificationCriteria::zip215 +//! [`dalek_verify_strict`]: VerificationCriteria::dalek_verify_strict + +/// Independent Ed25519 validation knobs. +/// +/// Each field toggles one decision point from the "255:19AM" taxonomy. Fields +/// are public so callers can compose arbitrary combinations, typically by +/// starting from a preset and overriding a single knob: +/// +/// ``` +/// use solana_ed25519_verify::VerificationCriteria; +/// +/// let strict_s = VerificationCriteria { +/// reject_small_order_a: true, +/// ..VerificationCriteria::zip215() +/// }; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct VerificationCriteria { + /// Use the cofactored verification equation `[8](S·B − H·A) == [8]R`. + /// + /// When `false`, the cofactorless equation `S·B − H·A == R` 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. + pub cofactored: bool, + /// Reject public keys whose compressed `y`-coordinate is `>= p` (a + /// non-canonical encoding of a reduced point). + pub require_canonical_a: bool, + /// Reject signature `R` values whose compressed `y`-coordinate is `>= p`. + 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. + 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. + pub reject_small_order_r: bool, + /// Reject signatures whose scalar `S` is not in canonical `[0, L)` form. + pub require_canonical_s: bool, +} + +impl VerificationCriteria { + /// [ZIP-215] verification, as specified for Solana by [SIMD-0376]. + /// + /// Cofactored equation with a canonical `S` requirement; non-canonical point + /// encodings and small-order points are accepted (cofactor multiplication + /// makes them indistinguishable from the identity contribution). This is + /// backward compatible with `ed25519_dalek::verify_strict`: every signature + /// dalek accepts is accepted here. + /// + /// [ZIP-215]: https://zips.z.cash/zip-0215 + /// [SIMD-0376]: https://github.com/solana-foundation/solana-improvement-documents/blob/main/proposals/0376-verify-strict.md + pub const fn zip215() -> Self { + Self { + cofactored: true, + require_canonical_a: false, + require_canonical_r: false, + reject_small_order_a: false, + reject_small_order_r: false, + require_canonical_s: true, + } + } + + /// The criteria enforced by `ed25519_dalek::VerifyingKey::verify_strict`. + /// + /// Cofactorless verification with canonical `S`, canonical `R`, and + /// small-order rejection for both `A` and `R`. Mirrors ed25519-dalek 2.x + /// exactly, including the detail that a non-canonically encoded public key + /// `A` is *not* rejected — dalek's `VerifyingKey::from_bytes` decompresses + /// `A` (reducing `y` modulo `p`) without a canonicity check, and + /// `verify_strict` only re-encodes and compares `R`. Every signature this + /// preset accepts is accepted by dalek's `verify_strict`, and vice versa. + pub const fn dalek_verify_strict() -> Self { + Self { + cofactored: false, + require_canonical_a: false, + require_canonical_r: true, + reject_small_order_a: true, + reject_small_order_r: true, + require_canonical_s: true, + } + } +} + +impl Default for VerificationCriteria { + fn default() -> Self { + Self::zip215() + } +} diff --git a/ed25519-verify/src/lib.rs b/ed25519-verify/src/lib.rs index 77dec64..5b7da8e 100644 --- a/ed25519-verify/src/lib.rs +++ b/ed25519-verify/src/lib.rs @@ -6,16 +6,20 @@ //! `solana-ed25519-program`. Programs can also depend on it directly to verify //! Ed25519 signatures without invoking the standalone verifier program. //! -//! The verifier performs ZIP-215 verification with canonical `S`. +//! By default the verifier performs ZIP-215 verification with canonical `S`. +//! The variant can be selected via [`VerificationCriteria`] and +//! [`Ed25519Verifier::with_criteria`]. #[cfg(feature = "instruction")] extern crate alloc; +mod config; #[cfg(feature = "instruction")] pub mod program; mod scalar; mod verifier; +pub use config::VerificationCriteria; #[cfg(feature = "instruction")] pub use program::ed25519_verify_instruction; pub use verifier::Ed25519Verifier; diff --git a/ed25519-verify/src/program.rs b/ed25519-verify/src/program.rs index c2bcf65..c6796bd 100644 --- a/ed25519-verify/src/program.rs +++ b/ed25519-verify/src/program.rs @@ -8,6 +8,11 @@ use { }; /// Constructs an on-chain instruction to invoke `solana-ed25519-program`. +/// +/// The instruction data is `public_key || signature || message`. The program +/// verifies the signature under the [ZIP-215] criteria. +/// +/// [ZIP-215]: crate::VerificationCriteria::zip215 pub fn ed25519_verify_instruction( program_id: &Pubkey, public_key: &[u8; PUBKEY_SERIALIZED_SIZE], diff --git a/ed25519-verify/src/scalar.rs b/ed25519-verify/src/scalar.rs index 32538dc..f4ed055 100644 --- a/ed25519-verify/src/scalar.rs +++ b/ed25519-verify/src/scalar.rs @@ -1,4 +1,5 @@ -//! Small scalar helpers needed to assemble Ed25519 verification around syscalls. +//! Small scalar- and field-element helpers needed to assemble Ed25519 +//! verification around syscalls. /// Group order of the ed25519 base point in little-endian form: /// `2^252 + 27742317777372353535851937790883648493`. @@ -7,11 +8,30 @@ pub(crate) const BASEPOINT_ORDER: [u8; 32] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, ]; +/// Field modulus `p = 2^255 - 19` in little-endian form. +const FIELD_MODULUS: [u8; 32] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, +]; + /// Returns `true` if `scalar` is in canonical `[0, L)` form. pub(crate) fn is_canonical_scalar(scalar: &[u8; 32]) -> bool { cmp_le(scalar, &BASEPOINT_ORDER).is_lt() } +/// Returns `true` if `encoding` is a canonical compressed Edwards point. +/// +/// A compressed point stores the `y`-coordinate in the low 255 bits and the +/// sign of `x` in the top bit. An encoding is canonical when the masked +/// `y`-coordinate is a reduced field element (`y < p`). Non-canonical encodings +/// (`y >= p`) still decompress — they reduce modulo `p` first — but represent a +/// point with an alternative, non-reduced serialization. +pub(crate) fn is_canonical_point_encoding(encoding: &[u8; 32]) -> bool { + let mut y = *encoding; + y[31] &= 0x7f; + cmp_le(&y, &FIELD_MODULUS).is_lt() +} + /// Reduces a 64-byte little-endian integer modulo the ed25519 base point order. pub(crate) fn reduce_wide(wide: &[u8; 64]) -> [u8; 32] { let mut remainder = [0u8; 32]; @@ -64,7 +84,7 @@ fn sub_assign(left: &mut [u8; 32], right: &[u8; 32]) { } } -fn cmp_le(left: &[u8; 32], right: &[u8; 32]) -> core::cmp::Ordering { +pub(crate) fn cmp_le(left: &[u8; 32], right: &[u8; 32]) -> core::cmp::Ordering { for (left_byte, right_byte) in left.iter().zip(right).rev() { match left_byte.cmp(right_byte) { core::cmp::Ordering::Equal => {} @@ -98,4 +118,33 @@ mod tests { fn negates_zero_to_zero() { assert_eq!(negate(&[0; 32]), [0; 32]); } + + #[test] + fn accepts_reduced_encodings() { + // y = 0 + assert!(is_canonical_point_encoding(&[0; 32])); + + // y = p - 1 (the small-order point (0, -1)), with and without sign bit. + let mut y = FIELD_MODULUS; + y[0] -= 1; + assert!(is_canonical_point_encoding(&y)); + y[31] |= 0x80; + assert!(is_canonical_point_encoding(&y)); + } + + #[test] + fn rejects_unreduced_encodings() { + // y = p + assert!(!is_canonical_point_encoding(&FIELD_MODULUS)); + + // y = p, sign bit set (the sign bit must be ignored, so still rejected). + let mut y = FIELD_MODULUS; + y[31] |= 0x80; + assert!(!is_canonical_point_encoding(&y)); + + // y = 2^255 - 1 (largest value the 255 bits can hold, > p). + let mut y = [0xff; 32]; + y[31] = 0x7f; + assert!(!is_canonical_point_encoding(&y)); + } } diff --git a/ed25519-verify/src/verifier.rs b/ed25519-verify/src/verifier.rs index f970c2d..7f06978 100644 --- a/ed25519-verify/src/verifier.rs +++ b/ed25519-verify/src/verifier.rs @@ -1,9 +1,7 @@ use { - crate::{scalar, PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE}, + crate::{scalar, VerificationCriteria, PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE}, solana_curve25519::{ - edwards::{ - multiply_edwards, multiscalar_multiply_edwards, subtract_edwards, PodEdwardsPoint, - }, + edwards::{add_edwards, multiscalar_multiply_edwards, subtract_edwards, PodEdwardsPoint}, scalar::PodScalar, }, solana_program_error::ProgramError, @@ -20,27 +18,45 @@ pub(crate) const EDWARDS_IDENTITY_COMPRESSED_BYTES: [u8; PUBKEY_SERIALIZED_SIZE] ]; const EDWARDS_IDENTITY_COMPRESSED: PodEdwardsPoint = PodEdwardsPoint(EDWARDS_IDENTITY_COMPRESSED_BYTES); -const EIGHT_SCALAR: PodScalar = PodScalar([ - 0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, - 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, -]); /// Stateless, zero-allocation Ed25519 verifier. +/// +/// The verification behavior is selected by [`VerificationCriteria`]. A verifier +/// created with [`Ed25519Verifier::new`] uses the [`VerificationCriteria::zip215`] +/// preset, matching this crate's historical behavior. #[derive(Debug, Clone, Copy, Default)] -pub struct Ed25519Verifier; +pub struct Ed25519Verifier { + criteria: VerificationCriteria, +} impl Ed25519Verifier { - /// Initializes a new verifier. + /// Initializes a verifier using the default [ZIP-215] criteria. + /// + /// [ZIP-215]: VerificationCriteria::zip215 pub const fn new() -> Self { - Self + Self { + criteria: VerificationCriteria::zip215(), + } + } + + /// Initializes a verifier with explicit [`VerificationCriteria`]. + pub const fn with_criteria(criteria: VerificationCriteria) -> Self { + Self { criteria } } - /// Performs ZIP-215 Ed25519 verification for one signature. + /// Returns the criteria this verifier enforces. + pub const fn criteria(&self) -> VerificationCriteria { + self.criteria + } + + /// Verifies one Ed25519 signature according to the configured criteria. /// - /// Uses the cofactored equation `[8](S*B - H(R || A || M)*A) == [8]R`. - /// The combined multiply-add minus `R` is performed first, then multiplied - /// by 8 and compared with the identity, matching the ed25519-zebra batch - /// verification shape. Canonical `S` is still required. + /// The core relation is `S*B - H(R || A || M)*A == R`. Depending on + /// [`VerificationCriteria::cofactored`], the check is performed either + /// cofactored — `[8](S*B - H*A - R) == identity`, matching the + /// ed25519-zebra batch verification shape — or cofactorless — + /// `S*B - H*A - R == identity`. The canonical-`S`, canonical-encoding, and + /// small-order rejections are applied first per the configured knobs. pub fn verify_signature( &self, signature: &[u8; SIGNATURE_SERIALIZED_SIZE], @@ -50,13 +66,27 @@ impl Ed25519Verifier { let (r_bytes, s_bytes) = signature.split_at(32); let r_bytes: &[u8; 32] = r_bytes.try_into().unwrap(); let s_bytes: &[u8; 32] = s_bytes.try_into().unwrap(); - if !scalar::is_canonical_scalar(s_bytes) { + + if self.criteria.require_canonical_s && !scalar::is_canonical_scalar(s_bytes) { + return Err(ProgramError::InvalidArgument); + } + if self.criteria.require_canonical_a && !scalar::is_canonical_point_encoding(public_key) { + return Err(ProgramError::InvalidArgument); + } + if self.criteria.require_canonical_r && !scalar::is_canonical_point_encoding(r_bytes) { return Err(ProgramError::InvalidArgument); } 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)? { + return Err(ProgramError::InvalidArgument); + } + if self.criteria.reject_small_order_r && is_small_order(&r_point)? { + return Err(ProgramError::InvalidArgument); + } + let challenge = compute_challenge(r_bytes, public_key, message); let minus_challenge = scalar::negate(&challenge); let lhs = multiscalar_multiply_edwards( @@ -65,10 +95,24 @@ impl Ed25519Verifier { ) .ok_or(ProgramError::InvalidArgument)?; let difference = subtract_edwards(&lhs, &r_point).ok_or(ProgramError::InvalidArgument)?; - let difference_cofactored = - multiply_edwards(&EIGHT_SCALAR, &difference).ok_or(ProgramError::InvalidArgument)?; - if difference_cofactored != EDWARDS_IDENTITY_COMPRESSED { + // An exact-identity difference satisfies both the cofactorless and the + // cofactored equation, so accept it without the cofactor multiplication. + // This is the common case for honestly generated (prime-order) signatures, + // so it saves the `multiply_by_8` syscalls on the hot path. + if difference == EDWARDS_IDENTITY_COMPRESSED { + return Ok(()); + } + // Cofactorless verification requires an exact identity, which is now ruled + // out. Cofactored verification additionally accepts a difference that + // clears to identity once multiplied by the cofactor 8 (the mixed-order + // points that ZIP-215 tolerates). + if !self.criteria.cofactored { + return Err(ProgramError::InvalidArgument); + } + if multiply_by_8(&difference).ok_or(ProgramError::InvalidArgument)? + != EDWARDS_IDENTITY_COMPRESSED + { return Err(ProgramError::InvalidArgument); } @@ -76,6 +120,30 @@ impl Ed25519Verifier { } } +/// 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(InvalidArgument)` 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. +fn is_small_order(point: &PodEdwardsPoint) -> Result { + let product = multiply_by_8(point).ok_or(ProgramError::InvalidArgument)?; + Ok(product == EDWARDS_IDENTITY_COMPRESSED) +} + +/// Multiplies `point` by the cofactor 8 via three point doublings. +/// +/// Cheaper than a scalar multiplication by 8: three `sol_curve_group_op` +/// additions (473 CU each, 1,419 total) versus one multiplication (2,177 CU). +/// Returns `None` if `point` is not a valid curve encoding. +fn multiply_by_8(point: &PodEdwardsPoint) -> Option { + let double = add_edwards(point, point)?; + let quadruple = add_edwards(&double, &double)?; + add_edwards(&quadruple, &quadruple) +} + 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) diff --git a/ed25519-verify/tests/verify_instruction.rs b/ed25519-verify/tests/verify_instruction.rs index 99db20c..40de922 100644 --- a/ed25519-verify/tests/verify_instruction.rs +++ b/ed25519-verify/tests/verify_instruction.rs @@ -1,7 +1,7 @@ use { ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey}, solana_ed25519_verify::{ - ed25519_verify_instruction, Ed25519Verifier, PUBKEY_SERIALIZED_SIZE, + ed25519_verify_instruction, Ed25519Verifier, VerificationCriteria, PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE, }, solana_program_error::ProgramError, @@ -16,6 +16,12 @@ const SMALL_ORDER_PUBLIC_KEY_COMPRESSED: [u8; PUBKEY_SERIALIZED_SIZE] = [ 0xec, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, ]; +/// Non-canonical encoding of a small-order point: `y = p` (reduces to `y = 0`, +/// an order-4 point). Its `y`-coordinate is not reduced modulo `p`. +const NON_CANONICAL_SMALL_ORDER_COMPRESSED: [u8; PUBKEY_SERIALIZED_SIZE] = [ + 0xed, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, + 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0x7f, +]; fn signed_payload( message: &[u8], @@ -54,18 +60,21 @@ fn constructs_program_instruction_with_direct_layout() { let instruction = ed25519_verify_instruction(&program_id, &public_key, &signature, message); + const PUBKEY_START: usize = 0; + const SIGNATURE_START: usize = PUBKEY_START + PUBKEY_SERIALIZED_SIZE; + const MESSAGE_START: usize = SIGNATURE_START + SIGNATURE_SERIALIZED_SIZE; + assert_eq!(instruction.program_id, program_id); assert!(instruction.accounts.is_empty()); - assert_eq!(&instruction.data[..PUBKEY_SERIALIZED_SIZE], &public_key); assert_eq!( - &instruction.data - [PUBKEY_SERIALIZED_SIZE..PUBKEY_SERIALIZED_SIZE + SIGNATURE_SERIALIZED_SIZE], - &signature + &instruction.data[PUBKEY_START..SIGNATURE_START], + &public_key ); assert_eq!( - &instruction.data[PUBKEY_SERIALIZED_SIZE + SIGNATURE_SERIALIZED_SIZE..], - message + &instruction.data[SIGNATURE_START..MESSAGE_START], + &signature ); + assert_eq!(&instruction.data[MESSAGE_START..], message); } #[test] @@ -189,3 +198,307 @@ fn accepts_valid_zip215_pure_torsion_signature() { ); } } + +fn verify_with( + criteria: VerificationCriteria, + signature: &[u8; SIGNATURE_SERIALIZED_SIZE], + public_key: &[u8; PUBKEY_SERIALIZED_SIZE], + message: &[u8], +) -> Result<(), ProgramError> { + Ed25519Verifier::with_criteria(criteria).verify_signature(signature, public_key, message) +} + +#[test] +fn new_uses_zip215_criteria() { + assert_eq!( + Ed25519Verifier::new().criteria(), + VerificationCriteria::zip215() + ); + assert_eq!( + VerificationCriteria::default(), + VerificationCriteria::zip215() + ); +} + +#[test] +fn require_canonical_s_is_enforced_by_default_only() { + let message = b"hello ed25519"; + let (mut signature, public_key) = signed_payload(message); + // S = L (the group order): non-canonical. + signature[32..64].copy_from_slice(&[ + 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, + ]); + + // ZIP-215 requires canonical S. + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &public_key, + message + ), + Err(ProgramError::InvalidArgument) + ); + + // Disabling the knob lets the reduced scalar through; S = L reduces to 0, so + // the equation no longer holds and it fails for a different reason, but never + // via the canonical-S gate. Use a genuinely valid signature to confirm the + // gate itself is off. + let (valid_signature, valid_public_key) = signed_payload(message); + let criteria = VerificationCriteria { + require_canonical_s: false, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with(criteria, &valid_signature, &valid_public_key, message), + Ok(()) + ); +} + +#[test] +fn reject_small_order_public_key_rejects_zip215_vector() { + let message = b"zip215 low-order public key vector"; + let mut signature = [0; SIGNATURE_SERIALIZED_SIZE]; + signature[..EDWARDS_IDENTITY_COMPRESSED.len()].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + // Accepted under the default ZIP-215 criteria. + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Ok(()) + ); + + // Rejected once small-order public keys are disallowed. + let criteria = VerificationCriteria { + reject_small_order_a: true, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with( + criteria, + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Err(ProgramError::InvalidArgument) + ); +} + +#[test] +fn reject_small_order_r_rejects_torsion_signature() { + let message = b"torsion r"; + // S = 0, R = a non-canonical small-order point. With a small-order public + // key this satisfies the cofactored equation. + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + signature[..32].copy_from_slice(&NON_CANONICAL_SMALL_ORDER_COMPRESSED); + + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Ok(()) + ); + + let criteria = VerificationCriteria { + reject_small_order_r: true, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with( + criteria, + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Err(ProgramError::InvalidArgument) + ); +} + +#[test] +fn require_canonical_a_rejects_non_canonical_public_key() { + let message = b"non-canonical a"; + // S = 0, R = identity, small-order (order 4) public key encoded as y = p. + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + signature[..32].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &NON_CANONICAL_SMALL_ORDER_COMPRESSED, + message + ), + Ok(()) + ); + + let criteria = VerificationCriteria { + require_canonical_a: true, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with( + criteria, + &signature, + &NON_CANONICAL_SMALL_ORDER_COMPRESSED, + message + ), + Err(ProgramError::InvalidArgument) + ); +} + +#[test] +fn require_canonical_r_rejects_non_canonical_r() { + let message = b"non-canonical r"; + // S = 0, R = small-order point encoded as y = p. + let mut signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + signature[..32].copy_from_slice(&NON_CANONICAL_SMALL_ORDER_COMPRESSED); + + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Ok(()) + ); + + let criteria = VerificationCriteria { + require_canonical_r: true, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with( + criteria, + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Err(ProgramError::InvalidArgument) + ); +} + +#[test] +fn cofactorless_still_accepts_prime_order_signature() { + let message = b"hello ed25519"; + let (signature, public_key) = signed_payload(message); + + let criteria = VerificationCriteria { + cofactored: false, + ..VerificationCriteria::zip215() + }; + assert_eq!( + verify_with(criteria, &signature, &public_key, message), + Ok(()) + ); +} + +/// Whether `ed25519_dalek::verify_strict` accepts the given triple, or `false` +/// if the inputs cannot even be parsed into dalek types. +fn dalek_verify_strict_accepts( + signature: &[u8; SIGNATURE_SERIALIZED_SIZE], + public_key: &[u8; PUBKEY_SERIALIZED_SIZE], + message: &[u8], +) -> bool { + match VerifyingKey::from_bytes(public_key) { + Ok(key) => key + .verify_strict(message, &Signature::from_bytes(signature)) + .is_ok(), + Err(_) => false, + } +} + +#[test] +fn dalek_verify_strict_preset_matches_dalek() { + let good_message = b"hello ed25519"; + let (good_signature, good_public_key) = signed_payload(good_message); + + let mut wrong_public_key = good_public_key; + wrong_public_key[0] ^= 1; + let (mut corrupt_signature, corrupt_public_key) = signed_payload(good_message); + corrupt_signature[0] ^= 1; + + // S = L: non-canonical scalar. + let (mut non_canonical_s, non_canonical_s_key) = signed_payload(good_message); + non_canonical_s[32..64].copy_from_slice(&[ + 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, + ]); + + // Small-order public key with the ZIP-215 torsion signature. + let mut torsion_signature = [0u8; SIGNATURE_SERIALIZED_SIZE]; + torsion_signature[..32].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + let cases: &[( + &[u8; SIGNATURE_SERIALIZED_SIZE], + &[u8; PUBKEY_SERIALIZED_SIZE], + &[u8], + )] = &[ + (&good_signature, &good_public_key, good_message), + (&good_signature, &wrong_public_key, good_message), + (&corrupt_signature, &corrupt_public_key, good_message), + (&non_canonical_s, &non_canonical_s_key, good_message), + ( + &torsion_signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + good_message, + ), + ]; + + for (index, (signature, public_key, message)) in cases.iter().enumerate() { + let ours = verify_with( + VerificationCriteria::dalek_verify_strict(), + signature, + public_key, + message, + ) + .is_ok(); + let dalek = dalek_verify_strict_accepts(signature, public_key, message); + assert_eq!( + ours, dalek, + "case {index} disagrees with dalek verify_strict" + ); + } +} + +#[test] +fn dalek_verify_strict_preset_rejects_zip215_small_order_key() { + // The canonical example where ZIP-215 and verify_strict diverge. + let message = b"zip215 low-order public key vector"; + let mut signature = [0; SIGNATURE_SERIALIZED_SIZE]; + signature[..EDWARDS_IDENTITY_COMPRESSED.len()].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); + + assert_eq!( + verify_with( + VerificationCriteria::zip215(), + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Ok(()) + ); + assert_eq!( + verify_with( + VerificationCriteria::dalek_verify_strict(), + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + ), + Err(ProgramError::InvalidArgument) + ); + assert!(!dalek_verify_strict_accepts( + &signature, + &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + message + )); +} diff --git a/program/src/lib.rs b/program/src/lib.rs index 4e1c61a..23929df 100644 --- a/program/src/lib.rs +++ b/program/src/lib.rs @@ -12,8 +12,9 @@ use { solana_ed25519_verify::{Ed25519Verifier, PUBKEY_SERIALIZED_SIZE, SIGNATURE_SERIALIZED_SIZE}, }; -const SIGNATURE_OFFSET: usize = PUBKEY_SERIALIZED_SIZE; -const MESSAGE_OFFSET: usize = PUBKEY_SERIALIZED_SIZE + SIGNATURE_SERIALIZED_SIZE; +const PUBKEY_OFFSET: usize = 0; +const SIGNATURE_OFFSET: usize = PUBKEY_OFFSET + PUBKEY_SERIALIZED_SIZE; +const MESSAGE_OFFSET: usize = SIGNATURE_OFFSET + SIGNATURE_SERIALIZED_SIZE; #[cfg(any(target_os = "solana", target_arch = "bpf"))] pinocchio::no_allocator!(); @@ -25,7 +26,14 @@ lazy_program_entrypoint!(process_instruction); /// Program entry point. /// /// Expects no accounts and instruction data encoded as -/// `public_key || signature || message`. +/// `public_key || signature || message`. The signature is verified under the +/// [ZIP-215] criteria ([`Ed25519Verifier::new`]). +/// +/// Programs needing a different verification variant should depend on +/// `solana-ed25519-verify` directly and build an [`Ed25519Verifier`] from the +/// desired `VerificationCriteria`. +/// +/// [ZIP-215]: solana_ed25519_verify::VerificationCriteria::zip215 pub fn process_instruction(context: InstructionContext) -> ProgramResult { if context.remaining() > 0 { return Err(ProgramError::InvalidArgument); @@ -36,7 +44,7 @@ pub fn process_instruction(context: InstructionContext) -> ProgramResult { return Err(ProgramError::InvalidInstructionData); } - let public_key = instruction_data[..PUBKEY_SERIALIZED_SIZE] + let public_key = instruction_data[PUBKEY_OFFSET..SIGNATURE_OFFSET] .try_into() .map_err(|_| ProgramError::InvalidInstructionData)?; let signature = instruction_data[SIGNATURE_OFFSET..MESSAGE_OFFSET] diff --git a/program/tests/mollusk.rs b/program/tests/mollusk.rs index b3dd489..2ad43f9 100644 --- a/program/tests/mollusk.rs +++ b/program/tests/mollusk.rs @@ -158,15 +158,6 @@ fn signed_instruction(program_id: Pubkey, message: &[u8]) -> Instruction { ed25519_verify_instruction(&program_id, &public_key, &signature, message) } -fn instruction_with_signature( - program_id: Pubkey, - message: &[u8], - signature: &[u8; SIGNATURE_SERIALIZED_SIZE], - public_key: &[u8; PUBKEY_SERIALIZED_SIZE], -) -> Instruction { - ed25519_verify_instruction(&program_id, public_key, signature, message) -} - #[test] fn verifies_single_signature_on_sbf_and_reports_compute_units() { let Some((mollusk, program_id)) = make_mollusk() else { @@ -195,11 +186,11 @@ fn accepts_zip215_small_order_public_key_vector_on_sbf() { let message = b"zip215 low-order public key vector"; let mut signature = [0; SIGNATURE_SERIALIZED_SIZE]; signature[..EDWARDS_IDENTITY_COMPRESSED.len()].copy_from_slice(&EDWARDS_IDENTITY_COMPRESSED); - let ix = instruction_with_signature( - program_id, - message, - &signature, + let ix = ed25519_verify_instruction( + &program_id, &SMALL_ORDER_PUBLIC_KEY_COMPRESSED, + &signature, + message, ); let result = mollusk.process_instruction(&ix, &[]);