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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion .config/spellcheck.dic
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: you should be able to combine these by doing dalek/P

de
deserializes
ed25519
encodings
entrypoint
libsodium
malleability
precompile
precompiles
runtime
selectable
syscall
syscalls
verifier
versa
5 changes: 4 additions & 1 deletion .config/spellcheck.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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…—"
99 changes: 68 additions & 31 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
111 changes: 111 additions & 0 deletions ed25519-verify/src/config.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
Comment on lines +107 to +111

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Default seems like a footgun for this type -- can we remove it?

6 changes: 5 additions & 1 deletion ed25519-verify/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions ed25519-verify/src/program.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down
53 changes: 51 additions & 2 deletions ed25519-verify/src/scalar.rs
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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];
Expand Down Expand Up @@ -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 => {}
Expand Down Expand Up @@ -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));
}
}
Loading
Loading