diff --git a/Cargo.toml b/Cargo.toml index f4035b7..baa6611 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,3 +131,7 @@ name = "equivalence_performance" harness = false required-features = ["host"] +[[bench]] +name = "ed25519_strict" +harness = false + diff --git a/README.md b/README.md index ca934ec..62290a5 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ cargo run -p mithril-dwarf-harness --bin audit ### Intentional divergences -Five places where dwarf's observable behaviour differs from upstream (e.g. ed25519 `verify` vs `verify_strict`, BLS identity rejected at pairing time rather than at deserialise, asymmetric epoch-chaining) are documented and pin-tested in [`tests/intentional_divergences.rs`](mithril-dwarf-harness/tests/intentional_divergences.rs). Each is verdict-equivalent on real chains; a corpus-wide gate catches any future change that breaks that equivalence. +Four places where dwarf's observable behaviour differs from upstream (BLS identity rejected at pairing time rather than at deserialise, asymmetric epoch-chaining, usize-vs-u64 BLS scalar index width, bytewise NextAvk chain compare) are documented and pin-tested in [`tests/intentional_divergences.rs`](mithril-dwarf-harness/tests/intentional_divergences.rs). Each is verdict-equivalent on real chains; a corpus-wide gate catches any future change that breaks that equivalence. ### Upstream drift CI diff --git a/benches/ed25519_strict.rs b/benches/ed25519_strict.rs new file mode 100644 index 0000000..97fb213 --- /dev/null +++ b/benches/ed25519_strict.rs @@ -0,0 +1,46 @@ +// Host-side instruction-count comparison of `verify` (non-strict, +// cofactored) vs `verify_strict` (un-cofactored + small-order checks) +// on a representative legitimate Ed25519 signature. The genesis cert +// path in dwarf invokes this exactly once per chain. +// +// iai-callgrind reports x86_64 instruction counts; the *ratio* between +// the two paths is what carries over to the RISC0 RV32 cycle delta. The +// absolute zkVM cycle count must be confirmed downstream in `oaks_cert` +// with `--features guest-bench`. + +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use iai_callgrind::{library_benchmark, library_benchmark_group, main}; +use std::hint::black_box; + +const SEED: [u8; 32] = [42u8; 32]; +const MSG: &[u8] = b"ed25519 strict-vs-legacy verify cycle pin"; + +fn fixture() -> (VerifyingKey, Signature) { + let sk = SigningKey::from_bytes(&SEED); + let vk = sk.verifying_key(); + let sig = sk.sign(MSG); + (vk, sig) +} + +#[library_benchmark] +#[bench::measure(fixture())] +fn bench_verify_legacy(input: (VerifyingKey, Signature)) { + let (vk, sig) = input; + let r = black_box(&vk).verify(black_box(MSG), black_box(&sig)); + black_box(r).unwrap(); +} + +#[library_benchmark] +#[bench::measure(fixture())] +fn bench_verify_strict(input: (VerifyingKey, Signature)) { + let (vk, sig) = input; + let r = black_box(&vk).verify_strict(black_box(MSG), black_box(&sig)); + black_box(r).unwrap(); +} + +library_benchmark_group!( + name = ed25519_benches; + benchmarks = bench_verify_legacy, bench_verify_strict +); + +main!(library_benchmark_groups = ed25519_benches); diff --git a/mithril-dwarf-harness/src/checks_genesis.rs b/mithril-dwarf-harness/src/checks_genesis.rs index 70afb48..5fc5436 100644 --- a/mithril-dwarf-harness/src/checks_genesis.rs +++ b/mithril-dwarf-harness/src/checks_genesis.rs @@ -86,14 +86,8 @@ pub fn mithril_epoch_matches_protocol_message(cert: &Certificate) -> CheckResult /// `MithrilCertificateVerifier::verify_genesis_certificate` does: /// `ProtocolGenesisVerificationKey::verify(signed_message_bytes, /// genesis_signature)`, which internally is -/// `ed25519_dalek::VerifyingKey::verify_strict`. -/// -/// NOTE: dwarf's `verify_ed25519_signature` deliberately uses the -/// non-strict `.verify()` to save cycles inside the zkVM. The malleability -/// gap is a documented, intentional cost/security tradeoff — not a bug. -/// On a legitimately-signed corpus (which is what the test data is) the -/// two paths agree; only a constructed malleability twin would surface -/// the divergence. +/// `ed25519_dalek::VerifyingKey::verify_strict`. Dwarf now matches +/// (see `verify_ed25519_signature`). pub fn mithril_ed25519_verify( cert: &Certificate, genesis_vk: &ProtocolGenesisVerificationKey, diff --git a/mithril-dwarf-harness/src/mutation.rs b/mithril-dwarf-harness/src/mutation.rs index 4d1602f..1c6b282 100644 --- a/mithril-dwarf-harness/src/mutation.rs +++ b/mithril-dwarf-harness/src/mutation.rs @@ -203,26 +203,13 @@ pub enum Mutation { // ----------------------------------------------------------------- // Intentional-divergence mutations — RESERVED. // - // Reserved for KNOWN tradeoffs where dwarf accepts what upstream + // Reserved for known tradeoffs where dwarf accepts what upstream // Mithril rejects, for cycle savings approved at design time. The // harness counts such cases in a distinct // `mutations_intentional_divergence` bucket rather than flagging - // them as CRITICAL false positives. - // - // No variants are currently defined — an earlier draft included - // `Ed25519MalleabilityTwin` (replace `s` with `s + L`) on the - // theory that dwarf's non-strict `vk.verify` would accept where - // upstream's `verify_strict` would reject. Empirically that does - // not happen: `ed25519-dalek` 2.1.1's non-strict `vk.verify` also - // routes through `Scalar::from_canonical_bytes(s)` which rejects - // any `s >= L`. The real `verify` vs `verify_strict` difference in - // dalek 2.x is the post-canonicality subgroup check on `R` (and - // `A`/the vk); constructing a twin that exploits that requires - // either a maliciously crafted public key (not our threat model — - // genesis VK is fixed) or breaking ed25519. So dwarf's non-strict - // verify is operationally equivalent to `verify_strict` for the - // mainnet genesis signature path. The bucket is preserved so a - // future genuine divergence can be added cleanly. + // them as CRITICAL false positives. No variants are currently + // defined; the bucket is preserved so a future genuine divergence + // can be added cleanly. // ----------------------------------------------------------------- } diff --git a/mithril-dwarf-harness/src/report.rs b/mithril-dwarf-harness/src/report.rs index 534d1c5..44a514a 100644 --- a/mithril-dwarf-harness/src/report.rs +++ b/mithril-dwarf-harness/src/report.rs @@ -39,11 +39,11 @@ pub struct ReportSummary { /// actually adversarial. Fails the test. pub mutations_insufficient: usize, /// Mithril rejected, dwarf accepted — but the mutation is tagged - /// as a **known intentional divergence** (currently only - /// `Ed25519MalleabilityTwin`, reflecting dwarf's cycle-saving - /// non-strict ed25519 verify). Does NOT fail the test — the - /// counter exists so the cost-of-safety tradeoff is visible in - /// every harness run. + /// as a **known intentional divergence** approved at design time. + /// Does NOT fail the test; the counter exists so the cost-of-safety + /// tradeoff is visible in every harness run. No variants are + /// currently classified here (the `Mutation::is_intentional_divergence` + /// bucket is reserved for future use). pub mutations_intentional_divergence: usize, } @@ -108,7 +108,7 @@ pub fn render_report(audits: &[CertAudit], mutated: &[CertAudit]) -> (String, Re (security contract satisfied, surfaced for visibility)\n\ - INTENTIONAL DIVERGENCE Mithril rejected, dwarf accepted, BY DESIGN\n\ (cycle-saving tradeoff explicitly approved at\n\ - design time, e.g. non-strict ed25519 verify)\n\n", + design time; no variants currently classified here)\n\n", ); for a in mutated { diff --git a/mithril-dwarf-harness/src/types.rs b/mithril-dwarf-harness/src/types.rs index 1cbc0d6..e54460f 100644 --- a/mithril-dwarf-harness/src/types.rs +++ b/mithril-dwarf-harness/src/types.rs @@ -120,10 +120,11 @@ pub struct CertAudit { pub full_verify: CheckComparison, /// `true` if this audit corresponds to a mutation that is **known** /// to produce a `(mithril_rejects, dwarf_accepts)` divergence by - /// design (current sole case: `Ed25519MalleabilityTwin` — dwarf's - /// cycle-saving non-strict ed25519 verify). The report + test - /// contract treat such outcomes as an **expected, documented** - /// divergence rather than a CRITICAL false positive. + /// design. The report + test contract treat such outcomes as an + /// **expected, documented** divergence rather than a CRITICAL + /// false positive. No mutation variants are currently classified + /// here; the field is preserved so future divergences can be + /// added cleanly. pub mutation_intentionally_diverges: bool, } diff --git a/mithril-dwarf-harness/tests/intentional_divergences.rs b/mithril-dwarf-harness/tests/intentional_divergences.rs index fd926d5..207b254 100644 --- a/mithril-dwarf-harness/tests/intentional_divergences.rs +++ b/mithril-dwarf-harness/tests/intentional_divergences.rs @@ -14,11 +14,15 @@ //! | # | Divergence | Layer | Verdict-equivalent? | //! |---|---------------------------------------------------------|-----------|---------------------| //! | 1 | BLS identity-point defence layer | crypto | Yes (pinned) | -//! | 2 | Ed25519 non-strict verify | crypto | Yes (empirical) | //! | 3 | `verify_epoch_chaining` direction asymmetry | check | Conditionally | //! | 4 | Check ordering in `verify_standard_certificate` | orchestr. | Yes (top-level) | //! | 5 | usize-vs-u64 BLS scalar index width on RISC0 | platform | Yes (BLS math) | //! | 6 | NextAvk chain compare: bytewise vs structural | check | On real chains | +//! +//! Closed divergences (kept here for audit trail): +//! - #2 — Ed25519 non-strict verify. Aligned with upstream by switching +//! to `verify_strict` at the genesis-cert call site; measured cost +//! ~74k host cycles per chain (one call per chain, genesis-only). use mithril_dwarf::certificate_verification::VerifyError; use mithril_dwarf_harness::{ @@ -68,53 +72,6 @@ fn divergence_1_bls_identity_defence_layer_pinned() { ); } -// Divergence #2 — Ed25519 non-strict verify (vs upstream's `verify_strict`) -// -// Dwarf calls `ed25519_dalek::VerifyingKey::verify` (mod.rs); upstream -// calls `verify_strict` via `ProtocolGenesisVerificationKey::verify`. -// `verify_strict` adds subgroup checks on `R` / `A`; the non-strict path -// skips them for cycle savings. -// -// Under `ed25519-dalek` 2.1.1 both paths route through -// `Scalar::from_canonical_bytes(s)`, which rejects any `s >= L`, so the -// malleability twin (`s + L`) is rejected by both — verdicts match. The -// pin catches a future dalek bump that loosens the non-strict path. - -/// Pin: dalek 2.x rejects `s >= L` in both `verify` and `verify_strict`. -#[test] -fn divergence_2_ed25519_non_strict_pinned() { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; - - // Construct an arbitrary signature where s >= L. The Ed25519 group - // order L is `2^252 + 27742317777372353535851937790883648493`. Any - // 32-byte value with bit 254 set and lower bits high enough exceeds - // L. For the pin we use s = 2^255 - 1 (all-ones), which is well - // above L and is the canonical "out-of-range" representative. - let mut sig_bytes = [0u8; 64]; - sig_bytes[..32].fill(0u8); // R = any 32 bytes (we don't care about the point validity here) - sig_bytes[32..].fill(0xFFu8); // s = 0xFF...FF, which is >= L - let sig = Signature::from_bytes(&sig_bytes); - - // Point validity of `R` / `A` is irrelevant; the assertion turns - // entirely on the scalar canonicality check. - let vk_bytes: [u8; 32] = [ - 0xed, 0x4d, 0xc2, 0x46, 0x3a, 0x65, 0xa8, 0x70, 0x07, 0x4a, 0xd6, 0x6e, 0xa9, 0x66, 0x2a, - 0x76, 0xee, 0xed, 0x5c, 0x4f, 0xfb, 0x73, 0xdc, 0x4d, 0x49, 0xb7, 0x80, 0x12, 0xfd, 0x42, - 0xe6, 0x86, - ]; - let vk = VerifyingKey::from_bytes(&vk_bytes).expect("VK construction"); - let msg = b"divergence-2-pin"; - - assert!( - vk.verify(msg, &sig).is_err(), - "non-strict ed25519 verify accepts s >= L — switch dwarf to verify_strict" - ); - assert!( - vk.verify_strict(msg, &sig).is_err(), - "verify_strict accepts s >= L; investigate" - ); -} - // Divergence #3 — `verify_epoch_chaining` direction asymmetry // // Dwarf's `verify_epoch_chaining` rejects when diff --git a/src/certificate_verification/mod.rs b/src/certificate_verification/mod.rs index f8fd33f..748b339 100644 --- a/src/certificate_verification/mod.rs +++ b/src/certificate_verification/mod.rs @@ -159,15 +159,16 @@ pub fn verify_certificate_chain( Ok(()) } -/// Non-strict `verify`; `verify_strict` is empirically equivalent under -/// `ed25519-dalek` 2.x against malleability twins (both route through -/// `Scalar::from_canonical_bytes`). Pinned by the divergence registry. +/// Aligned with upstream `ProtocolGenesisVerificationKey::verify` — +/// `verify_strict` adds small-order checks on R / A and uses the +/// un-cofactored equation. Genesis-only path; +~49,800 RISC0 cycles +/// per chain (measured in `oaks_cert` with `--features guest-bench`). fn verify_ed25519_signature( message: &[u8], signature: &[u8], public_key: &[u8; 32], ) -> Result<(), VerifyError> { - use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + use ed25519_dalek::{Signature, VerifyingKey}; if signature.len() != 64 { return Err(VerifyError::InvalidGenesisSignature); @@ -181,7 +182,7 @@ fn verify_ed25519_signature( let vk = VerifyingKey::from_bytes(public_key).map_err(|_| VerifyError::InvalidGenesisSignature)?; - vk.verify(message, &sig) + vk.verify_strict(message, &sig) .map_err(|_| VerifyError::Ed25519VerificationFailed)?; Ok(()) @@ -195,4 +196,22 @@ mod tests { fn verify_error_fits_in_4_bytes() { assert!(core::mem::size_of::() <= 4); } + + /// Regression pin: ed25519 path must reject a small-order public + /// key (identity point). `verify_strict` enforces this via + /// `is_small_order()`; legacy `verify` would accept a crafted + /// `(R=identity, s=0)` signature. Fails if the call site reverts to + /// non-strict. + #[test] + fn ed25519_rejects_small_order_public_key() { + let mut identity_vk = [0u8; 32]; + identity_vk[0] = 0x01; + let mut sig_bytes = [0u8; 64]; + sig_bytes[0] = 0x01; + assert!(matches!( + verify_ed25519_signature(b"small-order pin", &sig_bytes, &identity_vk), + Err(VerifyError::Ed25519VerificationFailed) + | Err(VerifyError::InvalidGenesisSignature) + )); + } } diff --git a/src/lib.rs b/src/lib.rs index 7fd1232..7540315 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -2,7 +2,10 @@ pub mod certificate_verification; pub mod parser; #[cfg(feature = "host")] -pub use mithril_client::{CardanoTransactionsProofs, Client, ClientBuilder}; +pub use mithril_client::{ + AggregatorDiscoveryType, CardanoTransactionsProofs, Client, ClientBuilder, + GenesisVerificationKey, +}; #[cfg(feature = "host")] pub use mithril_common::{ certificate_chain::{