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
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

125 changes: 102 additions & 23 deletions crates/aggregator/src/publickey_aggregator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,11 @@ use anyhow::Result;
use e3_data::Persistable;
use e3_events::{
prelude::*, BusHandle, ComputeResponse, ComputeResponseKind, DKGRecursiveAggregationComplete,
Die, E3id, EnclaveEvent, EnclaveEventData, EventContext, KeyshareCreated, OrderedSet,
PartyProofsToVerify, PkAggregationProofPending, PkAggregationProofRequest,
PkAggregationProofSigned, Proof, PublicKeyAggregated, Seed, Sequenced,
ShareVerificationComplete, ShareVerificationDispatched, SignedProofPayload, TypedEvent,
VerificationKind, ZkResponse,
Die, E3Failed, E3Stage, E3id, EnclaveEvent, EnclaveEventData, EventContext, FailureReason,
KeyshareCreated, OrderedSet, PartyProofsToVerify, PkAggregationProofPending,
PkAggregationProofRequest, PkAggregationProofSigned, Proof, ProofType, ProofVerificationPassed,
PublicKeyAggregated, Seed, Sequenced, ShareVerificationComplete, ShareVerificationDispatched,
SignedProofFailed, SignedProofPayload, TypedEvent, VerificationKind, ZkResponse,
};
use e3_events::{trap, EType};
use e3_fhe::{Fhe, GetAggregatePublicKey};
Expand Down Expand Up @@ -246,6 +246,7 @@ impl PublicKeyAggregator {
let PublicKeyAggregatorState::VerifyingC1 {
submission_order,
threshold_m,
c1_proofs,
..
} = self
.state
Expand All @@ -257,22 +258,97 @@ impl PublicKeyAggregator {
));
};

let dishonest_parties = &msg.dishonest_parties;
let mut dishonest_parties = msg.dishonest_parties.clone();
let total_parties = submission_order.len();

// Filter out dishonest parties using submission_order (insertion-order indexed,
// matching the party IDs sent to dispatch_c1_verification).
let (honest_keyshares, honest_nodes): (Vec<ArcBytes>, Vec<String>) = submission_order
// Filter out parties that failed C1 ZK verification.
let mut honest_entries: Vec<(usize, (String, ArcBytes))> = submission_order
.into_iter()
.enumerate()
.filter(|(idx, _)| !dishonest_parties.contains(&(*idx as u64)))
.map(|(_, (node, ks))| (ks, node))
.collect();

// Cross-check: verify each party's keyshare matches their C1 pk_commitment.
// Parties that fail are marked dishonest and reported via SignedProofFailed.
let mut commitment_dishonest = Vec::new();
for (party_idx, (_node, ks)) in &honest_entries {
let signed_proof = match c1_proofs.get(*party_idx).and_then(|opt| opt.as_ref()) {
Some(proof) => proof,
None => {
// No C1 proof for this party — should already be in dishonest_parties.
// If not, treat as dishonest now (defensive).
warn!(
"Party {} has no C1 proof but was not marked dishonest",
party_idx
);
dishonest_parties.insert(*party_idx as u64);
continue;
}
};
let ok = match e3_zk_helpers::compute_pk_commitment_from_keyshare_bytes(
ks,
&self.fhe.params,
&self.fhe.crp,
) {
Ok(computed) => signed_proof
.payload
.proof
.extract_output("pk_commitment")
.map_or(false, |extracted| extracted[..] == computed[..]),
Err(e) => {
warn!(
"Failed to compute pk_commitment for party {}: {}",
party_idx, e
);
false
}
};
if !ok {
commitment_dishonest.push((*party_idx as u64, signed_proof.clone()));
}
}

// Emit SignedProofFailed for each commitment-mismatched party
for (party_idx, signed_proof) in &commitment_dishonest {
dishonest_parties.insert(*party_idx);
match signed_proof.recover_address() {
Ok(faulting_node) => {
if let Err(e) = self.bus.publish(
SignedProofFailed {
e3_id: self.e3_id.clone(),
faulting_node,
proof_type: ProofType::C1PkGeneration,
signed_payload: signed_proof.clone(),
},
ec.clone(),
) {
error!("Failed to publish SignedProofFailed: {e}");
}
}
Err(e) => warn!(
"Could not recover address from C1 proof for party {}: {e}",
party_idx
),
}
}

if !commitment_dishonest.is_empty() {
warn!(
"C1 commitment mismatch for {} parties — filtering before aggregation",
commitment_dishonest.len()
);
// Re-filter honest_entries after commitment check
honest_entries.retain(|(idx, _)| !dishonest_parties.contains(&(*idx as u64)));
}

let (honest_keyshares, honest_nodes): (Vec<ArcBytes>, Vec<String>) = honest_entries
.iter()
.map(|(_, (node, ks))| (ks.clone(), node.clone()))
.unzip();

if !dishonest_parties.is_empty() {
warn!(
"Filtered out {} dishonest parties from C1 verification: {:?}",
dishonest_parties.len(),
"Total dishonest parties (ZK + commitment): {:?}",
dishonest_parties
);
}
Expand All @@ -283,11 +359,20 @@ impl PublicKeyAggregator {

// Need at least threshold + 1 honest parties for aggregation
if honest_keyshares.len() <= threshold_m {
return Err(anyhow::anyhow!(
"Not enough honest parties after C1 verification: {} (need at least {})",
error!(
"Not enough honest parties after filtering: {} (need > {})",
honest_keyshares.len(),
threshold_m + 1
));
threshold_m
);
self.bus.publish(
E3Failed {
e3_id: self.e3_id.clone(),
failed_at_stage: E3Stage::CommitteeFinalized,
reason: FailureReason::DKGInvalidShares,
},
ec,
)?;
return Ok(());
}

// Synchronous aggregation
Expand All @@ -301,12 +386,9 @@ impl PublicKeyAggregator {
})?;

let committee_h = honest_keyshares.len();
let honest_nodes_set = OrderedSet::from(honest_nodes);
let honest_nodes_set = OrderedSet::from(honest_nodes.clone());
let keyshare_bytes: Vec<_> = honest_keyshares_set.iter().cloned().collect();

// Publish pending event before transitioning state so a publish
// failure leaves us in VerifyingC1 (retryable) rather than
// GeneratingC5Proof (no retry path).
let pubkey = ArcBytes::from_bytes(&pubkey);
info!("Publishing PkAggregationProofPending for C5 proof generation...");
self.bus.publish(
Expand Down Expand Up @@ -782,9 +864,6 @@ impl Handler<EnclaveEvent> for PublicKeyAggregator {
EnclaveEventData::ComputeResponse(data) => {
self.notify_sync(ctx, TypedEvent::new(data, ec))
}
EnclaveEventData::ComputeRequestError(data) => {
error!("PublicKeyAggregator received ComputeRequestError: {}", data);
}
EnclaveEventData::E3RequestComplete(_) => self.notify_sync(ctx, Die),
EnclaveEventData::CommitteeMemberExpelled(data) => {
// Only process raw events from chain (party_id not yet resolved).
Expand Down
119 changes: 119 additions & 0 deletions crates/events/src/enclave_event/proof.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

use derivative::Derivative;
use e3_utils::utility_types::ArcBytes;
use e3_zk_helpers::{
CircuitOutputLayout, DKG_SHARE_DECRYPTION_OUTPUTS, PK_AGGREGATION_OUTPUTS, PK_BFV_OUTPUTS,
PK_GENERATION_OUTPUTS, SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS, SHARE_COMPUTATION_OUTPUTS,
};
use serde::{Deserialize, Serialize};
use std::fmt;

Expand Down Expand Up @@ -31,6 +35,18 @@ impl Proof {
public_signals: public_signals.into(),
}
}

/// Extract a named public output field from this proof's public signals.
///
/// Return values sit at the **end** of `public_signals`, after any `pub`
/// input parameters. The field name must match one declared in the
/// circuit's [`CircuitOutputLayout`].
pub fn extract_output(&self, field_name: &str) -> Option<ArcBytes> {
let layout = self.circuit.output_layout();
layout
.extract_field(&self.public_signals, field_name)
.map(ArcBytes::from_bytes)
}
}

/// Circuit variants determine the hash oracle used for VK generation and proving.
Expand Down Expand Up @@ -150,10 +166,113 @@ impl CircuitName {
pub fn wrapper_dir_path(&self) -> String {
format!("recursive_aggregation/wrapper/{}", self.dir_path())
}

/// Public output (return value) layout for this circuit.
pub fn output_layout(&self) -> CircuitOutputLayout {
match self {
CircuitName::PkBfv => CircuitOutputLayout::Fixed {
fields: PK_BFV_OUTPUTS,
},
CircuitName::PkGeneration => CircuitOutputLayout::Fixed {
fields: PK_GENERATION_OUTPUTS,
},
CircuitName::SkShareComputationBase | CircuitName::ESmShareComputationBase => {
CircuitOutputLayout::Dynamic
}
CircuitName::ShareComputationChunkBatch => CircuitOutputLayout::Fixed {
fields: SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS,
},
CircuitName::ShareComputation => CircuitOutputLayout::Fixed {
fields: SHARE_COMPUTATION_OUTPUTS,
},
CircuitName::DkgShareDecryption => CircuitOutputLayout::Fixed {
fields: DKG_SHARE_DECRYPTION_OUTPUTS,
},
CircuitName::PkAggregation => CircuitOutputLayout::Fixed {
fields: PK_AGGREGATION_OUTPUTS,
},
CircuitName::ShareComputationChunk
| CircuitName::ShareEncryption
| CircuitName::ThresholdShareDecryption
| CircuitName::DecryptedSharesAggregation => CircuitOutputLayout::None,
CircuitName::Fold => CircuitOutputLayout::None,
}
}
}

impl fmt::Display for CircuitName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.dir_path())
}
}

#[cfg(test)]
mod tests {
use super::*;

fn make_proof(circuit: CircuitName, signals: &[u8]) -> Proof {
Proof::new(
circuit,
ArcBytes::from_bytes(&[0u8; 8]),
ArcBytes::from_bytes(signals),
)
}

#[test]
fn extract_c1_pk_commitment() {
// C1 has 3 outputs: sk_commitment, pk_commitment, e_sm_commitment
let mut signals = vec![0u8; 96];
signals[0..32].copy_from_slice(&[0x11; 32]); // sk_commitment
signals[32..64].copy_from_slice(&[0x22; 32]); // pk_commitment
signals[64..96].copy_from_slice(&[0x33; 32]); // e_sm_commitment

let proof = make_proof(CircuitName::PkGeneration, &signals);
assert_eq!(
&*proof.extract_output("pk_commitment").unwrap(),
&[0x22; 32]
);
assert_eq!(
&*proof.extract_output("sk_commitment").unwrap(),
&[0x11; 32]
);
assert_eq!(
&*proof.extract_output("e_sm_commitment").unwrap(),
&[0x33; 32]
);
}

#[test]
fn extract_c5_commitment_after_pub_inputs() {
// C5 has H pub input fields + 1 output. Simulate H=2 → 96 bytes total.
let mut signals = vec![0xAA; 96];
signals[64..96].copy_from_slice(&[0xFF; 32]); // commitment (last output)

let proof = make_proof(CircuitName::PkAggregation, &signals);
assert_eq!(&*proof.extract_output("commitment").unwrap(), &[0xFF; 32]);
}

#[test]
fn extract_nonexistent_field() {
let proof = make_proof(CircuitName::PkBfv, &[0u8; 32]);
assert!(proof.extract_output("nonexistent").is_none());
}

#[test]
fn extract_from_void_circuit() {
let proof = make_proof(CircuitName::ShareEncryption, &[0u8; 64]);
assert!(proof.extract_output("commitment").is_none());
}

#[test]
fn extract_signals_too_short() {
// C1 needs 96 bytes for outputs, only 64 available
let proof = make_proof(CircuitName::PkGeneration, &[0u8; 64]);
assert!(proof.extract_output("pk_commitment").is_none());
}

#[test]
fn extract_empty_signals() {
let proof = make_proof(CircuitName::PkGeneration, &[]);
assert!(proof.extract_output("pk_commitment").is_none());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ pub struct ProofVerificationPassed {
/// keccak256 hash of the received data + proof bytes — for equivocation detection.
pub data_hash: [u8; 32],
/// Raw public signals from the verified proof — for commitment consistency checks.
pub public_outputs: ArcBytes,
pub public_signals: ArcBytes,
}

impl Display for ProofVerificationPassed {
Expand Down
20 changes: 18 additions & 2 deletions crates/multithread/src/multithread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ use ndarray::Array2;
use num_bigint::BigInt;
use rand::rngs::OsRng;
use rand::Rng;
use tracing::{error, info};
use tracing::{error, info, warn};

/// Multithread actor
pub struct Multithread {
Expand Down Expand Up @@ -313,7 +313,19 @@ fn handle_pk_aggregation_proof(
// 2. Create deterministic CRP
let crp = create_deterministic_crp_from_default_seed(&threshold_params);

// 3. Deserialize each keyshare as PublicKeyShare and extract pk0
// 3. Validate keyshare count before deserialization
if req.keyshare_bytes.len() != req.committee_h {
return Err(make_zk_error(
&request,
format!(
"keyshare_bytes length {} != committee_h {}",
req.keyshare_bytes.len(),
req.committee_h
),
));
}

// 4. Deserialize each keyshare as PublicKeyShare and extract pk0
let mut pk0_shares = Vec::with_capacity(req.keyshare_bytes.len());
for (i, ks_bytes) in req.keyshare_bytes.iter().enumerate() {
let pk_share = PublicKeyShare::deserialize(ks_bytes, &threshold_params, crp.clone())
Expand Down Expand Up @@ -344,6 +356,10 @@ fn handle_pk_aggregation_proof(
a,
};

// C1 commitment consistency is verified by the PublicKeyAggregator before
// dispatching this request (pre-aggregation check). By the time we reach
// the prover, all keyshares are guaranteed to match their C1 proofs.

// 7. Generate proof via Provable trait (C5 is always EVM-targeted for on-chain verification)
let circuit = PkAggregationCircuit;
let e3_id_str = request.e3_id.to_string();
Expand Down
2 changes: 2 additions & 0 deletions crates/tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@ e3-bfv-client = { workspace = true }
e3-fhe-params = { workspace = true }
e3-utils = { workspace = true }
e3-zk-prover = { workspace = true }
e3-zk-helpers = { workspace = true }
e3-polynomial = { workspace = true }
fhe-traits = { workspace = true }
fhe-util = { workspace = true }
fhe = { workspace = true }
Expand Down
Loading
Loading