[feat] impl legacy dalek verification method for ed25519 (#8)

* Initial commit

* skeleton

* refactor and merge curve and ed crates

* fmt

* ci

* fmt again

* ci

* Update bench.rs

* fix ubuntu

* implement dalek api

* clean up
This commit is contained in:
zz-sol 2026-03-31 07:46:24 -04:00 committed by GitHub
parent b801651332
commit bccf7e13c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 211 additions and 98 deletions

48
Cargo.lock generated
View file

@ -132,9 +132,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5"
[[package]]
name = "cc"
version = "1.2.57"
version = "1.2.58"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423"
checksum = "e1e928d4b69e3077709075a938a05ffbedfa53a84c8f766efbf8220bb1ff60e1"
dependencies = [
"find-msvc-tools",
"shlex",
@ -645,9 +645,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995"
dependencies = [
"once_cell",
"wasm-bindgen",
@ -807,9 +807,9 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
@ -1132,12 +1132,12 @@ dependencies = [
[[package]]
name = "sha2"
version = "0.11.0-rc.5"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7c5f3b1e2dc8aad28310d8410bd4d7e180eca65fca176c52ab00d364475d0024"
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"cpufeatures 0.3.0",
"digest 0.11.2",
]
@ -1191,7 +1191,7 @@ dependencies = [
"rustcrypto-ff",
"rustcrypto-group",
"serde",
"sha2 0.11.0-rc.5",
"sha2 0.11.0",
"subtle",
"zeroize",
]
@ -1338,9 +1338,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a"
dependencies = [
"cfg-if",
"once_cell",
@ -1351,9 +1351,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@ -1361,9 +1361,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf"
dependencies = [
"bumpalo",
"proc-macro2",
@ -1374,9 +1374,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.114"
version = "0.2.115"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93"
dependencies = [
"unicode-ident",
]
@ -1417,9 +1417,9 @@ dependencies = [
[[package]]
name = "web-sys"
version = "0.3.91"
version = "0.3.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94"
dependencies = [
"js-sys",
"wasm-bindgen",
@ -1633,18 +1633,18 @@ dependencies = [
[[package]]
name = "zerocopy"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
version = "0.8.47"
version = "0.8.48"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
dependencies = [
"proc-macro2",
"quote",

View file

@ -5,17 +5,11 @@ members = [
]
resolver = "2"
[profile.dev]
opt-level = 2
[workspace.dependencies]
bincode = "1"
cc = "1.0"
cfg-if = "1"
criterion = { version = "0.5", features = ["html_reports"] }
# Keep the dependency key as "curve25519" so that all existing `use curve25519::…`
# references in source files remain valid; the actual package is solanacurve25519.
curve25519 = { path = "curve25519/solana-ed25519", package = "solana-ed25519" }
curve25519-dalek-derive = "0.1.1"
cpufeatures = "0.2.17"
@ -38,4 +32,7 @@ serde = { version = "1.0", default-features = false, features = ["derive"] }
sha2 = { version = "0.11.0-rc.3", default-features = false }
subtle = { version = "2.6.0", default-features = false, features = ["const-generics"] }
which = "4.4"
zeroize = { version = "1", default-features = false, features = ["derive"] }
zeroize = { version = "1", default-features = false, features = ["derive"] }
[profile.dev]
opt-level = 2

View file

@ -1,11 +1,11 @@
# curve25519 (curve25519-sol)
# curve25519 (`solana-ed25519`)
**A pure-Rust implementation of group operations on Ristretto and Curve25519, forked from
[curve25519-dalek] with HEEA scalar decomposition and a reduced backend set.**
> For the original curve25519-dalek documentation see [README_dalek.md](README_dalek.md).
This crate is part of the [curve25519-sol](../README.md) workspace.
This crate is part of the [cryptography](https://github.com/anza-xyz/cryptography/) workspace.
---
@ -56,10 +56,10 @@ verification.
> For the original ed25519-zebra documentation see [README_zebra.md](README_zebra.md).
### `verify_heea`: fast-path signature verification
### `verify_zebra`: fast-path signature verification
A new method `VerificationKey::verify_heea` sits alongside the existing `verify`.
Both accept the same arguments and produce identical results — `verify_heea` is a
A new method `VerificationKey::verify_zebra` sits alongside the existing `verify`.
Both accept the same arguments and produce identical results — `verify_zebra` is a
**drop-in accelerated replacement** for `verify`.
The HEEA method (TCHES 2025) transforms the standard 2-point MSM:
@ -93,7 +93,39 @@ See [ZIP 215] for full details.
## Use
```toml
curve25519-sol = { git = "https://github.com/zz-sol/ed25519-sol" }
curve25519 = { package = "solana-ed25519", git = "https://github.com/anza-xyz/cryptography" }
```
### Ed25519 signing and verification
```rust,no_run
use core::convert::TryFrom;
use curve25519::ed_sigs::{SigningKey, VerificationKey};
let msg = b"solana-ed25519";
// Generate key and sign
let sk = SigningKey::new(rand::rng());
let sig = sk.sign(msg);
let vk = VerificationKey::from(&sk);
// Standard ZIP-215 verification (from ed25519-zebra)
vk.verify(&sig, msg).expect("valid signature");
// HEEA-accelerated verification (same result, ~15% faster)
vk.verify_zebra(&sig, msg).expect("valid signature");
```
### Batch verification
```rust,ignore
use curve25519::ed_sigs::batch;
let mut verifier = batch::Verifier::new();
for (vk_bytes, sig, msg) in items {
verifier.queue((vk_bytes, sig, msg));
}
verifier.verify(rand::rng()).expect("all valid");
```
### Ed25519 signing and verification
@ -109,11 +141,8 @@ let sk = SigningKey::new(rand::rng());
let sig = sk.sign(msg);
let vk = VerificationKey::from(&sk);
// Standard ZIP-215 verification (from ed25519-zebra)
// Standard ZIP-215 verification with heea acceleration
vk.verify(&sig, msg).expect("valid signature");
// HEEA-accelerated verification (same result, ~15% faster)
vk.verify_heea(&sig, msg).expect("valid signature");
```
### Batch verification

View file

@ -1,6 +1,10 @@
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use core::convert::TryFrom;
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use curve25519::ed_sigs::*;
use ed25519::signature::Verifier as _;
use ed25519_dalek::VerifyingKey as DalekVerifyingKey;
use ed25519_zebra::VerificationKey as ZebraVerificationKey;
fn sigs_with_distinct_pubkeys() -> impl Iterator<Item = (VerificationKeyBytes, Signature)> {
std::iter::repeat_with(|| {
@ -20,6 +24,23 @@ fn sigs_with_same_pubkey() -> impl Iterator<Item = (VerificationKeyBytes, Signat
})
}
fn single_verify_inputs() -> (
VerificationKey,
Signature,
ZebraVerificationKey,
DalekVerifyingKey,
) {
let sk = SigningKey::new(rand::rng());
let vk = VerificationKey::from(&sk);
let sig = sk.sign(b"");
let vk_bytes: [u8; 32] = vk.into();
let zebra_vk = ZebraVerificationKey::try_from(vk_bytes).expect("zebra verification key");
let dalek_vk = DalekVerifyingKey::from_bytes(&vk_bytes).expect("dalek verification key");
(vk, sig, zebra_vk, dalek_vk)
}
fn bench_batch_verify(c: &mut Criterion) {
let mut group = c.benchmark_group("Batch Verification");
for n in [8usize, 16, 24, 32, 40, 48, 56, 64].iter() {
@ -74,21 +95,31 @@ fn bench_batch_verify(c: &mut Criterion) {
fn bench_single_verify(c: &mut Criterion) {
let mut group = c.benchmark_group("Single Verification");
group.bench_function("ed25519", |b| {
let sk = SigningKey::new(rand::rng());
let vk = VerificationKey::from(&sk);
let sig = sk.sign(b"");
group.bench_function("local_verify_zebra", |b| {
let (vk, sig, _, _) = single_verify_inputs();
b.iter(|| {
let _ = vk.verify(&sig, b"");
let _ = vk.verify_zebra(&sig, b"");
})
});
group.bench_function("ed25519_hEEA", |b| {
let sk = SigningKey::new(rand::rng());
let vk = VerificationKey::from(&sk);
let sig = sk.sign(b"");
group.bench_function("local_verify_dalek", |b| {
let (vk, sig, _, _) = single_verify_inputs();
b.iter(|| {
let _ = vk.verify_heea(&sig, b"");
let _ = vk.verify_dalek(&sig, b"");
})
});
group.bench_function("crates_io_ed25519_zebra", |b| {
let (_, sig, zebra_vk, _) = single_verify_inputs();
b.iter(|| {
let _ = zebra_vk.verify(&sig, b"");
})
});
group.bench_function("crates_io_ed25519_dalek", |b| {
let (_, sig, _, dalek_vk) = single_verify_inputs();
b.iter(|| {
let _ = dalek_vk.verify(b"", &sig);
})
});

View file

@ -106,7 +106,7 @@ impl Item {
/// the message.
pub fn verify_single(self) -> Result<(), Error> {
VerificationKey::try_from(self.vk_bytes)
.and_then(|vk| vk.verify_prehashed(&self.sig, self.k))
.and_then(|vk| vk.verify_zebra_prehashed(&self.sig, self.k))
}
}

View file

@ -1,5 +1,11 @@
use crate::ed_sigs::SigningKey;
use crate::ed_sigs::VerificationKey;
#[cfg(feature = "std")]
use crate::ed_sigs::tests::small_order::SMALL_ORDER_SIGS;
#[cfg(feature = "std")]
use core::convert::TryFrom;
#[cfg(feature = "std")]
use ed25519::Signature;
#[test]
fn test_verify_heea_invalid_signature() {
@ -13,17 +19,21 @@ fn test_verify_heea_invalid_signature() {
// Try to verify with different message
let wrong_msg = b"Different message";
let result_standard = verification_key.verify(&signature, wrong_msg);
let result_heea = verification_key.verify_heea(&signature, wrong_msg);
let result_default = verification_key.verify(&signature, wrong_msg);
let result_zebra = verification_key.verify_zebra(&signature, wrong_msg);
let result_dalek = verification_key.verify_dalek(&signature, wrong_msg);
// Both should fail
assert!(
result_standard.is_err(),
"Standard verification should fail for wrong message"
result_default.is_err(),
"Default verification should fail for wrong message"
);
assert!(
result_heea.is_err(),
"heea verification should fail for wrong message"
result_zebra.is_err(),
"Zebra verification should fail for wrong message"
);
assert!(
result_dalek.is_err(),
"Dalek verification should fail for wrong message"
);
}
@ -38,18 +48,56 @@ fn test_verify_heea_multiple_signatures() {
let msg = format!("Message number {}", i);
let signature = signing_key.sign(msg.as_bytes());
let result_standard = verification_key.verify(&signature, msg.as_bytes());
let result_heea = verification_key.verify_heea(&signature, msg.as_bytes());
let result_default = verification_key.verify(&signature, msg.as_bytes());
let result_zebra = verification_key.verify_zebra(&signature, msg.as_bytes());
let result_dalek = verification_key.verify_dalek(&signature, msg.as_bytes());
assert!(
result_standard.is_ok(),
"Standard verification should succeed for signature {}",
result_default.is_ok(),
"Default verification should succeed for signature {}",
i
);
assert!(
result_heea.is_ok(),
"heea verification should succeed for signature {}",
result_zebra.is_ok(),
"Zebra verification should succeed for signature {}",
i
);
assert!(
result_dalek.is_ok(),
"Dalek verification should succeed for signature {}",
i
);
}
}
#[test]
fn test_default_verification_matches_zebra() {
let mut rng = rand::rng();
let signing_key = SigningKey::new(&mut rng);
let verification_key = VerificationKey::from(&signing_key);
let msg = b"default verification mode";
let signature = signing_key.sign(msg);
assert_eq!(
verification_key.verify(&signature, msg),
verification_key.verify_zebra(&signature, msg)
);
}
#[cfg(feature = "std")]
#[test]
fn test_verify_dalek_matches_legacy_edge_cases() {
for case in SMALL_ORDER_SIGS.iter() {
let sig = Signature::from(case.sig_bytes);
let vk = VerificationKey::try_from(case.vk_bytes).unwrap();
let result = vk.verify_dalek(&sig, b"Zcash");
assert_eq!(
result.is_ok(),
case.valid_legacy,
"dalek-compatible verification mismatch for vk={} sig={}",
hex::encode(case.vk_bytes),
hex::encode(case.sig_bytes)
);
}
}

View file

@ -237,8 +237,20 @@ impl Verifier<Signature> for VerificationKey {
}
impl VerificationKey {
fn challenge_scalar(&self, signature: &Signature, msg: &[u8]) -> Scalar {
Scalar::from_hash(
Sha512::default()
.chain(&signature.r_bytes()[..])
.chain(&self.A_bytes.0[..])
.chain(msg),
)
}
/// Verify a purported `signature` on the given `msg`.
///
/// This is the default verification mode and uses the HEEA-accelerated
/// verification path with Zebra / ZIP-215 semantics.
///
/// ## Zcash-specific consensus properties
///
/// Ed25519 checks are described in [§5.4.5][ps] of the Zcash protocol specification and in
@ -259,16 +271,10 @@ impl VerificationKey {
/// [ps]: https://zips.z.cash/protocol/protocol.pdf#concreteed25519
/// [ZIP215]: https://zips.z.cash/zip-0215
pub fn verify(&self, signature: &Signature, msg: &[u8]) -> Result<(), Error> {
let k = Scalar::from_hash(
Sha512::default()
.chain(&signature.r_bytes()[..])
.chain(&self.A_bytes.0[..])
.chain(msg),
);
self.verify_prehashed(signature, k)
self.verify_zebra(signature, msg)
}
/// Verify a signature using the heea half-size scalar optimization.
/// Verify a signature using HEEA with Zebra / ZIP-215 semantics.
///
/// This implements the algorithm from "Accelerating EdDSA Signature Verification
/// with Faster Scalar Size Halving" (TCHES 2025).
@ -285,15 +291,16 @@ impl VerificationKey {
/// τs_lo B + τs_hi (2^128 B) = τR + ρA
/// which can be done via 4-variable MSM with half-size scalars.
#[allow(non_snake_case)]
pub fn verify_heea(&self, signature: &Signature, msg: &[u8]) -> Result<(), Error> {
// Compute the hash scalar h (called k in the standard implementation)
let h = Scalar::from_hash(
Sha512::default()
.chain(&signature.r_bytes()[..])
.chain(&self.A_bytes.0[..])
.chain(msg),
);
pub fn verify_zebra(&self, signature: &Signature, msg: &[u8]) -> Result<(), Error> {
self.verify_zebra_prehashed(signature, self.challenge_scalar(signature, msg))
}
#[allow(non_snake_case)]
pub(crate) fn verify_zebra_prehashed(
&self,
signature: &Signature,
h: Scalar,
) -> Result<(), Error> {
// Generate half-size scalars ρ and τ such that ρ ≡ τh (mod )
// in order to have rho and tau approximately half the size of h
// it is possible that we compute ρ ≡ -τh (mod )
@ -323,7 +330,6 @@ impl VerificationKey {
// Compute the multi-scalar multiplication
let result = EdwardsPoint::vartime_triple_scalar_mul_basepoint(&tau, &neg_R, &rho, &A, &ts);
// Check if [8] τs B + [8] τ (-R) + [8] ρ (-A) == 0
if result.mul_by_cofactor().is_identity() {
Ok(())
} else {
@ -331,26 +337,28 @@ impl VerificationKey {
}
}
/// Verify a signature with a prehashed `k` value. Note that this is not the
/// same as "prehashing" in RFC8032.
/// Verify a signature with exact `ed25519-dalek`-style byte-level behavior.
///
/// This recomputes the expected canonical `R` encoding and compares it to the
/// signature's `R` bytes, matching dalek's ordinary verification behavior.
///
/// Note that exact dalek-compatible behavior is incompatible with the HEEA
/// transformed equation because the transformed check does not preserve the
/// original `R` encoding needed for the byte comparison.
#[allow(non_snake_case)]
pub(crate) fn verify_prehashed(&self, signature: &Signature, k: Scalar) -> Result<(), Error> {
// `s_bytes` MUST represent an integer less than the prime `l`.
pub fn verify_dalek(&self, signature: &Signature, msg: &[u8]) -> Result<(), Error> {
self.verify_dalek_prehashed(signature, self.challenge_scalar(signature, msg))
}
#[allow(non_snake_case)]
fn verify_dalek_prehashed(&self, signature: &Signature, h: Scalar) -> Result<(), Error> {
let s = Option::<Scalar>::from(Scalar::from_canonical_bytes(*signature.s_bytes()))
.ok_or(Error::InvalidSignature)?;
// `R_bytes` MUST be an encoding of a point on the twisted Edwards form of Curve25519.
let R = CompressedEdwardsY(*signature.r_bytes())
.decompress()
.ok_or(Error::InvalidSignature)?;
// We checked the encoding of A_bytes when constructing `self`.
// [8][s]B = [8]R + [8][k]A
// <=> [8]R = [8][s]B - [8][k]A
// <=> 0 = [8](R - ([s]B - [k]A))
// <=> 0 = [8](R - R') where R' = [s]B - [k]A
let R_prime = EdwardsPoint::vartime_double_scalar_mul_basepoint(&k, &self.minus_A, &s);
let expected_R =
EdwardsPoint::vartime_double_scalar_mul_basepoint(&h, &self.minus_A, &s).compress();
if (R - R_prime).mul_by_cofactor().is_identity() {
if expected_R.as_bytes() == signature.r_bytes() {
Ok(())
} else {
Err(Error::InvalidSignature)