Skip to content

Add RFC 9381 ECVRF-EDWARDS25519-SHA512-TAI for Ed25519 (VRF building block for #4388) - #5409

Open
EslaM-X wants to merge 1 commit into
stellar:masterfrom
EslaM-X:vrf-rust-module-4388
Open

Add RFC 9381 ECVRF-EDWARDS25519-SHA512-TAI for Ed25519 (VRF building block for #4388)#5409
EslaM-X wants to merge 1 commit into
stellar:masterfrom
EslaM-X:vrf-rust-module-4388

Conversation

@EslaM-X

@EslaM-X EslaM-X commented Aug 10, 2026

Copy link
Copy Markdown

Description

Part of #4388 — the standalone crypto building block for VRF-driven consensus and protocol randomness, landed first on purpose (Phase A in the proposal).

This PR adds ECVRF-EDWARDS25519-SHA512-TAI (RFC 9381) as a self-contained Rust module in src/rust/src/vrf.rs, exposed to the C++ side through the existing bridge FFI surface in bridge.rs. No protocol change, no XDR, no consensus code — just the primitive, with RFC test vectors attached so correctness is proven before anything is wired into the network.

// The contract C++ gets through RustBridge.h (byte-buffer ABI, same as today).
// All functions return bool and write to caller-provided buffers:
vrf_generate(sk, alpha_ptr, alpha_len, pi_out /* 80 bytes */, beta_out /* 64 bytes */)
    // ECVRF_prove + ECVRF_proof_to_hash in one call: deterministic
    // pseudorandom beta without keeping the intermediate proof.
vrf_prove(sk, alpha_ptr, alpha_len, pi_out /* 80 bytes */)        // pi = Gamma || c || s
vrf_proof_to_hash(pi_ptr, beta_out /* 64 bytes */)               // beta from a proof
vrf_verify(pk, alpha_ptr, alpha_len, pi_ptr, beta_out /* 64 bytes */)
    // constant-time, recomputes beta only when the proof is valid

Why start here

The issue identifies three places where today's per-ledger randomness derives from the LCL hash, which the quorum leader can influence: SCP nomination priority, the Soroban PRNG seed, and transaction apply order. None of that is touched in this PR. The point of this step is to give those phases a primitive that is (a) implemented to a published standard, (b) backed by official test vectors, and (c) cheap to audit — so when we do touch consensus, the crypto is the boring part.

What's inside

  • vrf.rs — the full ciphersuite:
    • hash-to-curve via RFC 9380-style try-and-increment onto the edwards25519 group; ECVRF_encode_to_curve is fallible and reports RFC 9381's INVALID outcome as false across the bridge instead of panicking
    • pi = Gamma || c || s (80 bytes), beta = 64 bytes, per RFC 9381 §5.2
    • scalar arithmetic through curve25519-dalek — constant-time, no branch on secret data
    • the expanded key, the nonce k, and every intermediate digest/hash_to_scalar copy are held in Zeroizing and wiped on return (digests are hashed straight into the zeroized buffers, so no unwiped temporary ever holds the nonce or expanded key)
  • bridge.rs — four exported symbols (vrf_generate, vrf_prove, vrf_proof_to_hash, vrf_verify) with buffer sizes that match the RustBridge.h contract; vrf_generate is exactly vrf_prove piped into vrf_proof_to_hash so the advertised one-call entry point is real
  • Cargo.toml / Cargo.lockcurve25519-dalek (pinned, see below) and zeroize

On the curve25519-dalek pin

It's pinned to =4.1.3 because that is the exact version ed25519-dalek 2.1.1 (already a dependency, used for signature verification) resolves to. The = forces Cargo to unify on a single copy, so the staticlib does not end up carrying two curve25519 implementations and duplicate group-operation symbols. This keeps the diff minimal and the final binary honest.

Verification

  • RFC 9381 §A vectors: prove matches the published examples; verify accepts all published examples
  • Boundary cases: tampered proof rejected · malformed public key rejected · proof with a non-canonical s rejected — probed at the exact s == q group-order boundary, not just an arbitrary large value (s = q is rejected by the decoder, s = q - 1 is the largest canonical s and still fails the verification equation)
  • bridge_api_roundtrip — prove → proof-to-hash → verify → generate through the exact entry points C++ will call, including the null-pointer failure modes
  • cargo fmt --check clean, crate builds warning-free, and the four symbols are exported from the staticlib (dumpbin /SYMBOLS), lined up with the generated RustBridge.h
  • cargo test: 9/9 pass
  • C ABI smoke tests against the rebuilt staticlib (byte-buffer ABI, same as the real C++ build): a Rust harness drives the exported stellar$rust_bridge$cxxbridge1$vrf_* symbols directly, and a C++ consumer (cl, linking rust_stellar_core.lib) exercises all four entry points through the shim stubs that util/Logging.h provides in the full build — both 15/15, vrf_generate proof and beta match the RFC vector

Compatibility

Purely additive — no XDR, no protocol version, no behavior change anywhere. This is intentionally the smallest reviewable unit of the proposal, so the review has as little surface as possible.

Next steps (separate PRs, per the proposal)

  1. XDR GeneralizedTransactionSet extension behind a new protocol version, with a CAP
  2. Wire the seed into SCPDriver::computeHashNode, the LedgerManagerImpl PRNG, and the TxSetFrame apply order
  3. Fold the C++/Rust smoke tests into the repo's CI once the full C++/XDR build is wired up

Happy to adjust the shape of the module (e.g. move toward a pure-C++/libsodium path) if maintainers prefer it — the ciphersuite itself is identical either way.

Checklist

  • Reviewed the contributing document
  • Rebased on top of master (no merge commits)
  • Ran clang-format v8.0.0 — n/a for a Rust-only change; cargo fmt --check is clean
  • Compiles
  • Ran all tests
  • If change impacts performance, include supporting evidence — n/a: one scalar multiplication per prove, two per verify, and nothing on any hot path yet

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds the RFC 9381 Ed25519 VRF primitive and exposes it to C++ through the Rust bridge.

Changes:

  • Implements VRF proving, verification, and proof-to-hash.
  • Adds RFC vectors and adversarial tests.
  • Adds and locks the curve25519-dalek dependency.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/rust/src/vrf.rs Implements and tests the VRF primitive.
src/rust/src/lib.rs Registers the VRF module.
src/rust/src/bridge.rs Exposes VRF functions to C++.
src/rust/Cargo.toml Adds curve25519-dalek.
Cargo.lock Locks dependency changes.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/rust/src/vrf.rs
Comment thread src/rust/src/bridge.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/rust/src/vrf.rs:349

  • vrf_proof_to_hash only validates Gamma, so it returns true for an 80-byte proof whose s is non-canonical (s >= q). RFC 9381 proof-to-hash first runs ECVRF_decode_proof, which rejects that case, and the bridge contract promises malformed proofs return false. Apply the same canonical-scalar check already used by verification before deriving beta.
    if string_to_point(&pi.gamma).is_none() {
        return false;
    }

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/rust/src/vrf.rs:349

  • RFC 9381 §5.2 first decodes the entire proof, and the Ed25519 proof decoder rejects s >= q. This bridge checks only Gamma, so a proof with a noncanonical s returns true and a beta even though vrf_verify rejects the same malformed proof. Validate s before hashing and cover this bridge path in the existing malformed-proof test.
    if string_to_point(&pi.gamma).is_none() {
        return false;
    }

@EslaM-X
EslaM-X force-pushed the vrf-rust-module-4388 branch from 341f177 to 1b3a570 Compare August 10, 2026 18:38
@EslaM-X

EslaM-X commented Aug 10, 2026

Copy link
Copy Markdown
Author

Follow-up hardening: the cxx bridge �rf_proof_to_hash now applies the same ECVRF_decode_proof checks as �rf_verify before deriving beta — it rejects both a non-canonical Gamma and a non-canonical s (s >= q), so malformed proofs return alse instead of hashing. Covered by a new proof_to_hash_rejects_non_canonical_s test (9/9 green, RFC 9381 vectors still pass).

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

src/rust/src/vrf.rs:206

  • This repeats the fixed-base multiplication already performed in derive_key at line 150 solely to recover Y, adding an avoidable scalar multiplication to every proof. Retain the computed EdwardsPoint in VrfKey (and derive pk_bytes from it) so challenge generation can reuse it.
    let y = EdwardsPoint::mul_base(&key.x);

Comment thread src/rust/src/vrf.rs

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (2)

src/rust/src/bridge.rs:256

  • The PR's stated C++ contract advertises vrf_generate(secretKey, msg, beta_out), but this bridge exposes only vrf_prove, vrf_proof_to_hash, and vrf_verify. A caller following the advertised contract cannot generate beta directly. Either add the declared generate entry point (typically prove followed by proof-to-hash) or update the PR contract to list the API actually exported.
        unsafe fn vrf_prove(
            sk_ptr: *const u8,
            alpha_ptr: *const u8,
            alpha_len: usize,
            pi_out: *mut u8,
        ) -> bool;

src/rust/src/vrf.rs:587

  • This test says it exercises the s == q boundary, but filling s with 0xff produces a value much larger than q. The exact rejection boundary is therefore untested. Encode the Ed25519 group order explicitly (and retain the all-ones case for s > q) so a future off-by-one error in scalar decoding is caught.
    fn verify_rejects_s_gte_q() {
        // Set s = q (== the group order, i.e. a canonical-but-invalid s for
        // edwards25519) by forging a proof with s = all-ones; this is >= q so
        // it must be rejected before any point arithmetic happens.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

Suppressed comments (5)

src/rust/src/vrf.rs:111

  • The destination is zeroized, but Sha512::digest(sk) first creates a separate returned digest temporary that is dropped normally after copy_from_slice. That temporary contains the expanded secret key, so construct the Zeroizing owner directly from the digest instead of copying it through an unprotected value.
    let mut hashed_sk = Zeroizing::new([0u8; 64]);
    hashed_sk.copy_from_slice(&Sha512::digest(sk));

src/rust/src/vrf.rs:117

  • As above, hasher.finalize() creates an ordinary temporary before its bytes are copied into k_string. This value determines the nonce and can expose the secret scalar if recovered from stale memory; move the finalized digest directly into Zeroizing.
    let mut k_string = Zeroizing::new([0u8; 64]);
    k_string.copy_from_slice(&hasher.finalize());

src/rust/src/vrf.rs:154

  • Copying from Sha512::digest(sk) leaves the returned expanded-key digest temporary outside the zeroizing wrapper. Since this digest contains the scalar source and nonce prefix, initialize Zeroizing with the digest directly so there is no separately dropped plaintext temporary.
    let mut hashed = Zeroizing::new([0u8; 64]);
    hashed.copy_from_slice(&Sha512::digest(sk));

src/rust/src/vrf.rs:50

  • This buffer receives the secret nonce hash when called from nonce_generation_rfc8032, but it is an ordinary stack allocation and is not wiped. Recovering it recovers k, which together with the public proof equation reveals the long-term scalar. Keep this copy in Zeroizing as well.
    let mut buf = [0u8; 64];
    buf[..bytes.len()].copy_from_slice(bytes);
    Scalar::from_bytes_mod_order_wide(&buf)

src/rust/src/vrf.rs:99

  • RFC 9381's try-and-increment procedure returns INVALID after all 256 counters fail, but this path panics. A panic escaping the Rust/CXX bridge can terminate stellar-core instead of satisfying the documented false failure contract. Make encode_to_curve fallible and propagate that failure through prove/generate/verify.
        if ctr == 0 {
            panic!("ECVRF_encode_to_curve: failed to find a curve point");

Implements the ECVRF ciphersuite over the edwards25519 group using curve25519-dalek, and exposes vrf_prove/vrf_proof_to_hash/vrf_verify through the rust bridge for use from C++. Includes RFC 9381 test vectors.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 5 changed files in this pull request and generated no new comments.

@EslaM-X

EslaM-X commented Aug 10, 2026

Copy link
Copy Markdown
Author

🚀 Ready for Maintainer Review

"A signature is just a promise — a VRF is a promise the whole network can keep."

This PR implements RFC 9381's ECVRF-EDWARDS25519-SHA512-TAI for Ed25519 as a native Rust module, exposed to the C++ core through a cxx bridge — the cryptographic building block at the heart of #4388.


✅ What's Inside

  • Full RFC 9381 complianceprove, proof_to_hash, verify, plus the bridged vrf_generate / vrf_verify entry points, all validated against the RFC's official test vectors.
  • End-to-end proof-of-correctness — a C++ smoke test links directly against the rebuilt rust_stellar_core.lib, and a Rust C-ABI harness exercises the bridge from the other side:
Suite Result
Rust unit tests (cargo test) 9 / 9
C++ smoke (vrf_smoke.exe) 15 / 15
Rust C-ABI (vrf_cabi_test.exe) 15 / 15
  • Hardened secret handling — every secret intermediate (VrfKey::x, the expanded scalar, k_string, the hash-to-scalar buffer, the derived key) lives in Zeroizing and is wiped on drop; digests are finalized directly into zeroized buffers with no unwiped temporaries left behind.
  • Fallible by designencode_to_curve no longer panics when the RFC 8032 counter space is exhausted; failures propagate gracefully through prove / generate / verify as None / false instead of aborting the process.
  • Canonical-point enforcementstring_to_point requires a strict decompress/recompress round trip, rejecting non-canonical point encodings per RFC 8032.

🔍 Review Status

  • Every review thread has been resolved, and the latest automated review produced no new findings.
  • Security scans: Socket Security — Project Report ✅ and Pull Request Alerts ✅ (skipped, no issues).

⚙️ What We Need From Maintainers

The branch status currently shows:

  • 5 workflows awaiting approval (first-time contributor): CI, CI-private, Quickstart, Horizon Integration Tests, and RPC Integration Tests.
  • 👀 A formal maintainer review is still required before this can merge.

Could you please approve the pending workflows and give this a final review?

@MonsieurNicolas @anupsdf @nullstyle @matschaffer

Thank you for your time — and for keeping the Stellar codebase legendary. 🌟

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants