From e8b009e6ae1316f0f35a35a4b5856ab1255279dc Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:44:13 -0300 Subject: [PATCH 1/4] feat(parsigex): add eth2-based partial signature verifier Adds parsigex::new_eth2_verifier built on pluto_core::verify_eth2_signed_data (core/eth2signeddata, #501), porting Charon's parsigex.NewEth2Verifier: each inbound partial signature is verified against the sender's public share, looked up by the partial sig's share index. Adds pluto-eth2api and promotes pluto-crypto to a runtime dependency of parsigex (to name the beacon client + pubshare type). --- Cargo.lock | 2 + crates/parsigex/Cargo.toml | 4 +- crates/parsigex/src/behaviour.rs | 241 +++++++++++++++++++++++++++++++ crates/parsigex/src/lib.rs | 3 +- 4 files changed, 248 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8f13ed09..0bd5e6d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5923,6 +5923,8 @@ dependencies = [ "pluto-cluster", "pluto-core", "pluto-crypto", + "pluto-eth2api", + "pluto-eth2util", "pluto-p2p", "pluto-testutil", "pluto-tracing", diff --git a/crates/parsigex/Cargo.toml b/crates/parsigex/Cargo.toml index 453225e6..79f9d6de 100644 --- a/crates/parsigex/Cargo.toml +++ b/crates/parsigex/Cargo.toml @@ -15,6 +15,8 @@ thiserror.workspace = true tokio.workspace = true tracing.workspace = true pluto-core.workspace = true +pluto-crypto.workspace = true +pluto-eth2api.workspace = true pluto-p2p.workspace = true [dev-dependencies] @@ -23,7 +25,7 @@ clap.workspace = true hex.workspace = true k256.workspace = true pluto-cluster.workspace = true -pluto-crypto.workspace = true +pluto-eth2util.workspace = true pluto-testutil.workspace = true pluto-tracing.workspace = true tokio-util.workspace = true diff --git a/crates/parsigex/src/behaviour.rs b/crates/parsigex/src/behaviour.rs index a243ba72..6681733b 100644 --- a/crates/parsigex/src/behaviour.rs +++ b/crates/parsigex/src/behaviour.rs @@ -23,9 +23,12 @@ use libp2p::{ use tokio::sync::{RwLock, mpsc, oneshot}; use pluto_core::{ + eth2signeddata::{as_eth2_signed_data, verify_eth2_signed_data}, gater::DutyGaterFn, types::{Duty, ParSignedData, ParSignedDataSet, PubKey}, }; +use pluto_crypto::types::PublicKey; +use pluto_eth2api::EthBeaconNodeApiClient; use pluto_p2p::p2p_context::P2PContext; use super::{Handler, encode_message}; @@ -42,6 +45,50 @@ pub type VerifyFuture = pub type Verifier = Arc VerifyFuture + Send + Sync + 'static>; +/// Returns a [`Verifier`] that verifies each inbound partial signature against +/// the sending peer's public share, looked up by the partial signature's share +/// index. +/// +/// Ports Charon's `parsigex.NewEth2Verifier`: for a partial signature received +/// for `pubkey`, it looks up the validator's public shares +/// (`pub_shares_by_key[pubkey]`), selects the share for the partial signature's +/// [`share_idx`](ParSignedData::share_idx), and delegates to +/// [`verify_eth2_signed_data`], which derives the signing domain/epoch from the +/// [`SignedData`](pluto_core::types::SignedData) and verifies the eth2 BLS +/// signature. A missing public key or share index is rejected, mirroring +/// Charon. +pub fn new_eth2_verifier( + eth2_cl: EthBeaconNodeApiClient, + pub_shares_by_key: HashMap>, +) -> Verifier { + let pub_shares_by_key = Arc::new(pub_shares_by_key); + Arc::new(move |_duty, pubkey, par_signed_data| { + let eth2_cl = eth2_cl.clone(); + let pub_shares_by_key = pub_shares_by_key.clone(); + Box::pin(async move { + let pubshares = pub_shares_by_key + .get(&pubkey) + .ok_or(VerifyError::UnknownPubKey)?; + let pubshare = pubshares + .get(&par_signed_data.share_idx) + .ok_or(VerifyError::InvalidShareIndex)?; + + // `verify_eth2_signed_data` takes an already-upcast + // `&dyn Eth2SignedData`; the upcast failure (Charon's + // `data.(core.Eth2SignedData)` type assertion) maps to the + // "invalid signed data family" error. + let eth2_data = as_eth2_signed_data(par_signed_data.signed_data.as_ref()) + .ok_or(VerifyError::InvalidSignedDataFamily)?; + + // Charon wraps the remaining `VerifyEth2SignedData` failure as + // "invalid signature". + verify_eth2_signed_data(ð2_cl, eth2_data, pubshare) + .await + .map_err(|err| VerifyError::Other(err.to_string())) + }) + }) +} + /// Future returned by received subscriber callbacks. pub type ReceivedSubFuture = Pin + Send + 'static>>; @@ -533,3 +580,197 @@ impl NetworkBehaviour for Behaviour { Poll::Pending } } + +#[cfg(test)] +mod eth2_verifier_tests { + use std::collections::HashMap; + + use pluto_core::{ + signeddata::Attestation, + types::{Duty, ParSignedData, PubKey, SignedData}, + }; + use pluto_crypto::{ + blst_impl::BlstImpl, + tbls::Tbls, + types::{Index, PrivateKey, PublicKey}, + }; + use pluto_eth2api::{EthBeaconNodeApiClient, spec::phase0}; + use pluto_eth2util::signing::{DomainName, get_data_root}; + use pluto_testutil::BeaconMock; + + use super::new_eth2_verifier; + use crate::error::VerifyError; + + const TOTAL_SHARES: Index = 4; + const THRESHOLD: Index = 3; + + fn secret_key(hex_value: &str) -> PrivateKey { + let bytes = hex::decode(hex_value).unwrap(); + bytes.as_slice().try_into().unwrap() + } + + fn sample_attestation(target_epoch: phase0::Epoch) -> Attestation { + let data = phase0::AttestationData { + slot: 32, + index: 2, + beacon_block_root: [0x11; 32], + source: phase0::Checkpoint { + epoch: target_epoch.saturating_sub(1), + root: [0x22; 32], + }, + target: phase0::Checkpoint { + epoch: target_epoch, + root: [0x33; 32], + }, + }; + + Attestation::new(phase0::Attestation { + aggregation_bits: serde_json::from_str("\"0x0101\"").unwrap(), + data, + signature: [0; 96], + }) + } + + /// Signs the eth2 signing root of `data` for the given domain/epoch with + /// `secret`, returning a copy of `data` carrying that signature. + async fn sign( + client: &EthBeaconNodeApiClient, + secret: &PrivateKey, + data: &T, + domain: DomainName, + epoch: phase0::Epoch, + ) -> T + where + T: SignedData + Sized, + { + let message_root = data.message_root().unwrap(); + let signing_root = get_data_root(client, domain, epoch, message_root) + .await + .unwrap(); + let signature = BlstImpl.sign(secret, &signing_root).unwrap(); + data.set_signature(signature).unwrap() + } + + /// Splits `secret` into threshold BLS shares and returns each share's + /// private key alongside the public-share map keyed by 1-indexed share id. + fn split_shares(secret: &PrivateKey) -> (HashMap, HashMap) { + let shares = BlstImpl + .threshold_split(secret, TOTAL_SHARES, THRESHOLD) + .unwrap(); + let pub_shares = shares + .iter() + .map(|(idx, share)| (*idx, BlstImpl.secret_to_public_key(share).unwrap())) + .collect(); + (shares, pub_shares) + } + + fn attester_duty() -> Duty { + Duty::new_attester_duty(32.into()) + } + + #[tokio::test] + async fn accepts_partial_signature_against_correct_share() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let group_pubkey = PubKey::new(BlstImpl.secret_to_public_key(&secret).unwrap()); + let (shares, pub_shares) = split_shares(&secret); + + // Sign the attestation with the private share for index 2. + let share_idx: Index = 2; + let att = sample_attestation(4); + let signed = sign( + client, + &shares[&share_idx], + &att, + DomainName::BeaconAttester, + 4, + ) + .await; + let par = ParSignedData::new(signed, share_idx); + + let mut pub_shares_by_key = HashMap::new(); + pub_shares_by_key.insert(group_pubkey, pub_shares); + + let verifier = new_eth2_verifier(client.clone(), pub_shares_by_key); + verifier(attester_duty(), group_pubkey, par) + .await + .expect("partial signature against the correct public share verifies"); + } + + #[tokio::test] + async fn rejects_partial_signature_against_wrong_share() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let group_pubkey = PubKey::new(BlstImpl.secret_to_public_key(&secret).unwrap()); + let (shares, pub_shares) = split_shares(&secret); + + // Sign with share 2's secret but claim share index 3, so the verifier + // looks up share 3's public key and the signature fails to verify. + let att = sample_attestation(4); + let signed = sign(client, &shares[&2], &att, DomainName::BeaconAttester, 4).await; + let par = ParSignedData::new(signed, 3); + + let mut pub_shares_by_key = HashMap::new(); + pub_shares_by_key.insert(group_pubkey, pub_shares); + + let verifier = new_eth2_verifier(client.clone(), pub_shares_by_key); + let err = verifier(attester_duty(), group_pubkey, par) + .await + .expect_err("partial signature against the wrong public share is rejected"); + + assert!(matches!(err, VerifyError::Other(_))); + } + + #[tokio::test] + async fn rejects_unknown_pubkey() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let group_pubkey = PubKey::new(BlstImpl.secret_to_public_key(&secret).unwrap()); + let (shares, _pub_shares) = split_shares(&secret); + + let att = sample_attestation(4); + let signed = sign(client, &shares[&1], &att, DomainName::BeaconAttester, 4).await; + let par = ParSignedData::new(signed, 1); + + // Empty map: the validator public key is not part of the cluster lock. + let pub_shares_by_key = HashMap::new(); + + let verifier = new_eth2_verifier(client.clone(), pub_shares_by_key); + let err = verifier(attester_duty(), group_pubkey, par) + .await + .expect_err("partial signature for an unknown pubkey is rejected"); + + assert!(matches!(err, VerifyError::UnknownPubKey)); + } + + #[tokio::test] + async fn rejects_missing_share_index() { + let mock = BeaconMock::builder().build().await.unwrap(); + let client = mock.client(); + + let secret = secret_key("345768c0245f1dc702df9e50e811002f61ebb2680b3d5931527ef59f96cbaf9b"); + let group_pubkey = PubKey::new(BlstImpl.secret_to_public_key(&secret).unwrap()); + let (shares, pub_shares) = split_shares(&secret); + + let att = sample_attestation(4); + let signed = sign(client, &shares[&1], &att, DomainName::BeaconAttester, 4).await; + // Claim a share index that was never produced by the split. + let par = ParSignedData::new(signed, TOTAL_SHARES + 1); + + let mut pub_shares_by_key = HashMap::new(); + pub_shares_by_key.insert(group_pubkey, pub_shares); + + let verifier = new_eth2_verifier(client.clone(), pub_shares_by_key); + let err = verifier(attester_duty(), group_pubkey, par) + .await + .expect_err("partial signature with an unknown share index is rejected"); + + assert!(matches!(err, VerifyError::InvalidShareIndex)); + } +} diff --git a/crates/parsigex/src/lib.rs b/crates/parsigex/src/lib.rs index 4ff310ec..edc8caac 100644 --- a/crates/parsigex/src/lib.rs +++ b/crates/parsigex/src/lib.rs @@ -6,7 +6,8 @@ mod handler; mod protocol; pub use behaviour::{ - Behaviour, Config, Event, Handle, ReceivedSub, ReceivedSubFuture, Verifier, received_subscriber, + Behaviour, Config, Event, Handle, ReceivedSub, ReceivedSubFuture, Verifier, new_eth2_verifier, + received_subscriber, }; pub use error::{Error, Failure, Result, VerifyError}; pub use handler::Handler; From d0cbad5eefd2af258a21de2eb436ad411ff3bbb1 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Tue, 30 Jun 2026 19:07:59 -0300 Subject: [PATCH 2/4] Small tweaks - Use qualified imports - Simplify docs --- crates/parsigex/src/behaviour.rs | 26 ++++++++++++++------------ 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/crates/parsigex/src/behaviour.rs b/crates/parsigex/src/behaviour.rs index 6681733b..f5d9737c 100644 --- a/crates/parsigex/src/behaviour.rs +++ b/crates/parsigex/src/behaviour.rs @@ -23,7 +23,7 @@ use libp2p::{ use tokio::sync::{RwLock, mpsc, oneshot}; use pluto_core::{ - eth2signeddata::{as_eth2_signed_data, verify_eth2_signed_data}, + eth2signeddata, gater::DutyGaterFn, types::{Duty, ParSignedData, ParSignedDataSet, PubKey}, }; @@ -49,14 +49,15 @@ pub type Verifier = /// the sending peer's public share, looked up by the partial signature's share /// index. /// -/// Ports Charon's `parsigex.NewEth2Verifier`: for a partial signature received -/// for `pubkey`, it looks up the validator's public shares -/// (`pub_shares_by_key[pubkey]`), selects the share for the partial signature's -/// [`share_idx`](ParSignedData::share_idx), and delegates to -/// [`verify_eth2_signed_data`], which derives the signing domain/epoch from the -/// [`SignedData`](pluto_core::types::SignedData) and verifies the eth2 BLS -/// signature. A missing public key or share index is rejected, mirroring -/// Charon. +/// For a partial signature received for `pubkey`, it looks up the validator's +/// public shares (`pub_shares_by_key[pubkey]`), selects the share for the +/// partial signature's [`share_idx`](ParSignedData::share_idx), and delegates +/// to [`verify_eth2_signed_data`], which derives the signing domain/epoch from +/// the [`SignedData`](pluto_core::types::SignedData) and verifies the eth2 BLS +/// signature. +/// A missing public key or share index is rejected. +/// +/// Ports Charon's `parsigex.NewEth2Verifier` pub fn new_eth2_verifier( eth2_cl: EthBeaconNodeApiClient, pub_shares_by_key: HashMap>, @@ -77,12 +78,13 @@ pub fn new_eth2_verifier( // `&dyn Eth2SignedData`; the upcast failure (Charon's // `data.(core.Eth2SignedData)` type assertion) maps to the // "invalid signed data family" error. - let eth2_data = as_eth2_signed_data(par_signed_data.signed_data.as_ref()) - .ok_or(VerifyError::InvalidSignedDataFamily)?; + let eth2_data = + eth2signeddata::as_eth2_signed_data(par_signed_data.signed_data.as_ref()) + .ok_or(VerifyError::InvalidSignedDataFamily)?; // Charon wraps the remaining `VerifyEth2SignedData` failure as // "invalid signature". - verify_eth2_signed_data(ð2_cl, eth2_data, pubshare) + eth2signeddata::verify_eth2_signed_data(ð2_cl, eth2_data, pubshare) .await .map_err(|err| VerifyError::Other(err.to_string())) }) From 52ba319fdba86471632b7aba4ac0b05d4ebca187 Mon Sep 17 00:00:00 2001 From: Lautaro Emanuel Date: Tue, 30 Jun 2026 19:18:47 -0300 Subject: [PATCH 3/4] Carry underlying verification error - Preserves duty information --- crates/parsigex/src/behaviour.rs | 8 +++----- crates/parsigex/src/error.rs | 19 +++++++++++++++---- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/crates/parsigex/src/behaviour.rs b/crates/parsigex/src/behaviour.rs index f5d9737c..92bb37ab 100644 --- a/crates/parsigex/src/behaviour.rs +++ b/crates/parsigex/src/behaviour.rs @@ -63,7 +63,7 @@ pub fn new_eth2_verifier( pub_shares_by_key: HashMap>, ) -> Verifier { let pub_shares_by_key = Arc::new(pub_shares_by_key); - Arc::new(move |_duty, pubkey, par_signed_data| { + Arc::new(move |duty, pubkey, par_signed_data| { let eth2_cl = eth2_cl.clone(); let pub_shares_by_key = pub_shares_by_key.clone(); Box::pin(async move { @@ -82,11 +82,9 @@ pub fn new_eth2_verifier( eth2signeddata::as_eth2_signed_data(par_signed_data.signed_data.as_ref()) .ok_or(VerifyError::InvalidSignedDataFamily)?; - // Charon wraps the remaining `VerifyEth2SignedData` failure as - // "invalid signature". eth2signeddata::verify_eth2_signed_data(ð2_cl, eth2_data, pubshare) .await - .map_err(|err| VerifyError::Other(err.to_string())) + .map_err(|source| VerifyError::InvalidSignature { duty, source }) }) }) } @@ -724,7 +722,7 @@ mod eth2_verifier_tests { .await .expect_err("partial signature against the wrong public share is rejected"); - assert!(matches!(err, VerifyError::Other(_))); + assert!(matches!(err, VerifyError::InvalidSignature { .. })); } #[tokio::test] diff --git a/crates/parsigex/src/error.rs b/crates/parsigex/src/error.rs index fe835cae..7a0ddc28 100644 --- a/crates/parsigex/src/error.rs +++ b/crates/parsigex/src/error.rs @@ -1,6 +1,10 @@ //! Error types for the partial signature exchange protocol. -use pluto_core::{ParSigExCodecError, types::DutyTypeError}; +use pluto_core::{ + ParSigExCodecError, + eth2signeddata::Eth2SignedDataError, + types::{Duty, DutyTypeError}, +}; /// Result type for partial signature exchange. pub type Result = std::result::Result; @@ -53,9 +57,16 @@ pub enum VerifyError { /// Invalid signed-data family for the duty. #[error("invalid eth2 signed data")] InvalidSignedDataFamily, - /// Generic verification error. - #[error("{0}")] - Other(String), + /// The eth2 BLS signature failed to verify against the sender's public + /// share. + #[error("invalid signature for duty {duty}: {source}")] + InvalidSignature { + /// Duty whose partial signature failed verification. + duty: Duty, + /// Underlying verification failure. + #[source] + source: Eth2SignedDataError, + }, } /// Error type for partial signature exchange operations. From 681164a27ea9e4bf31ca54ab47bb0b1040d0f02b Mon Sep 17 00:00:00 2001 From: "emlautarom1-agent[bot]" <292495798+emlautarom1-agent[bot]@users.noreply.github.com> Date: Tue, 30 Jun 2026 19:29:19 -0300 Subject: [PATCH 4/4] docs(parsigex): fix broken intra-doc link to verify_eth2_signed_data The function lives in the `eth2signeddata` module, which is only in scope as a module path, so the bare link did not resolve. Qualify it with the module path. --- crates/parsigex/src/behaviour.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/parsigex/src/behaviour.rs b/crates/parsigex/src/behaviour.rs index 92bb37ab..94904533 100644 --- a/crates/parsigex/src/behaviour.rs +++ b/crates/parsigex/src/behaviour.rs @@ -52,7 +52,8 @@ pub type Verifier = /// For a partial signature received for `pubkey`, it looks up the validator's /// public shares (`pub_shares_by_key[pubkey]`), selects the share for the /// partial signature's [`share_idx`](ParSignedData::share_idx), and delegates -/// to [`verify_eth2_signed_data`], which derives the signing domain/epoch from +/// to [`verify_eth2_signed_data`](eth2signeddata::verify_eth2_signed_data), +/// which derives the signing domain/epoch from /// the [`SignedData`](pluto_core::types::SignedData) and verifies the eth2 BLS /// signature. /// A missing public key or share index is rejected.