From 3389b144e0465cf90d519dc775eda03131c21bb1 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 19 Mar 2026 17:10:42 +0000 Subject: [PATCH 01/14] feat: connect c1 and c5 --- crates/aggregator/src/publickey_aggregator.rs | 225 +++++++++++++++++- .../src/enclave_event/compute_request/zk.rs | 13 + 2 files changed, 237 insertions(+), 1 deletion(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 09791cb83a..42781ef585 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -25,6 +25,17 @@ use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use tracing::{error, info, warn}; +/// Derive c1_commitments from signed proofs by extracting pk_commitment from each. +fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec { + signed_proofs + .iter() + .filter_map(|opt| { + opt.as_ref() + .and_then(|sp| sp.payload.proof.extract_output("pk_commitment")) + }) + .collect() +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum PublicKeyAggregatorState { Collecting { @@ -53,6 +64,11 @@ pub enum PublicKeyAggregatorState { GeneratingC5Proof { public_key: ArcBytes, keyshare_bytes: Vec, + /// Signed C1 proofs from honest parties, aligned with `keyshare_bytes`. + /// Commitments are extracted on the fly via `extract_output("pk_commitment")`. + /// Retained for fault attribution if a commitment mismatch is detected later. + #[serde(default)] + c1_signed_proofs: Vec>, nodes: OrderedSet, /// DKG recursive proofs per party (restart-critical). dkg_node_proofs: HashMap>, @@ -346,6 +362,12 @@ impl PublicKeyAggregator { .map(|(_, (node, ks))| (ks.clone(), node.clone())) .unzip(); + // Collect signed C1 proofs from honest parties (commitments derived on the fly) + let c1_signed_proofs: Vec> = honest_entries + .iter() + .map(|(idx, _)| c1_proofs.get(*idx).and_then(|opt| opt.clone())) + .collect(); + if !dishonest_parties.is_empty() { warn!( "Total dishonest parties (ZK + commitment): {:?}", @@ -401,6 +423,7 @@ impl PublicKeyAggregator { committee_n: committee_h, committee_h, committee_threshold: 0, + c1_commitments: derive_c1_commitments(&c1_signed_proofs), }, public_key: pubkey.clone(), nodes: honest_nodes_set.clone(), @@ -411,7 +434,9 @@ impl PublicKeyAggregator { self.state.try_mutate(&ec, |_| { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key: pubkey.clone(), - keyshare_bytes, + public_key_hash, + keyshare_bytes: honest_keyshares, + c1_signed_proofs, nodes: honest_nodes_set, dkg_node_proofs: HashMap::new(), honest_party_ids: honest_party_ids.clone(), @@ -459,6 +484,7 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -472,6 +498,7 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -526,6 +553,7 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, mut dkg_node_proofs, honest_party_ids, @@ -541,6 +569,7 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -598,6 +627,7 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -623,6 +653,7 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -738,6 +769,7 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -760,6 +792,7 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, + c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -774,6 +807,196 @@ impl PublicKeyAggregator { Ok(()) } + fn handle_c1_commitment_mismatch( + &mut self, + mismatched_indices: &[usize], + ec: EventContext, + ) -> Result<()> { + let PublicKeyAggregatorState::GeneratingC5Proof { + keyshare_bytes, + c1_signed_proofs: stored_c1_signed_proofs, + nodes, + dkg_node_proofs, + honest_party_ids, + dishonest_parties, + .. + } = self + .state + .get() + .ok_or_else(|| anyhow::anyhow!("Expected GeneratingC5Proof state"))? + else { + return Err(anyhow::anyhow!( + "handle_c1_commitment_mismatch called outside GeneratingC5Proof state" + )); + }; + + // Map keyshare-order indices to original party IDs. + // keyshare_bytes[i] corresponds to the i-th element in honest_party_ids (sorted). + let honest_ids_sorted: Vec = honest_party_ids.iter().copied().collect(); + let mut newly_dishonest: BTreeSet = BTreeSet::new(); + for &idx in mismatched_indices { + if let Some(&party_id) = honest_ids_sorted.get(idx) { + warn!( + "C1 commitment mismatch for party {} (index {}) — marking as dishonest", + party_id, idx + ); + newly_dishonest.insert(party_id); + } else { + warn!( + "C1 commitment mismatch index {} out of range (honest parties: {})", + idx, + honest_ids_sorted.len() + ); + } + } + + if newly_dishonest.is_empty() { + return Err(anyhow::anyhow!( + "C1 commitment mismatch reported but no valid party indices" + )); + } + + // Emit SignedProofFailed for each mismatched party that has a signed C1 proof + for &idx in mismatched_indices { + if let Some(Some(signed_payload)) = stored_c1_signed_proofs.get(idx) { + match signed_payload.recover_address() { + Ok(faulting_node) => { + if let Err(err) = self.bus.publish( + SignedProofFailed { + e3_id: self.e3_id.clone(), + faulting_node, + proof_type: ProofType::C1PkGeneration, + signed_payload: signed_payload.clone(), + }, + ec.clone(), + ) { + error!("Failed to publish SignedProofFailed for C1 mismatch at index {}: {err}", idx); + } + } + Err(err) => { + warn!( + "Could not recover address from C1 signed proof at index {}: {err}", + idx + ); + } + } + } + } + + // Filter out the newly dishonest parties + let remaining_keyshares: Vec = keyshare_bytes + .iter() + .enumerate() + .filter(|(i, _)| !mismatched_indices.contains(i)) + .map(|(_, ks)| ks.clone()) + .collect(); + + let remaining_ids: BTreeSet = honest_party_ids + .iter() + .copied() + .filter(|id| !newly_dishonest.contains(id)) + .collect(); + + let remaining_nodes: OrderedSet = { + let nodes_vec: Vec = nodes + .iter() + .enumerate() + .filter(|(i, _)| !mismatched_indices.contains(i)) + .map(|(_, n)| n.clone()) + .collect(); + OrderedSet::from(nodes_vec) + }; + + let mut all_dishonest = dishonest_parties.clone(); + all_dishonest.extend(newly_dishonest.iter()); + + // Check if enough honest parties remain + let remaining_count = remaining_keyshares.len(); + // We need > 0 honest parties; the circuit enforces the threshold check + if remaining_count == 0 { + return Err(anyhow::anyhow!( + "No honest parties remaining after C1 commitment mismatch filtering" + )); + } + + info!( + "Re-aggregating public key from {} remaining honest parties (removed {} dishonest)", + remaining_count, + newly_dishonest.len() + ); + + // Re-aggregate the public key without the dishonest parties + let remaining_keyshares_set = OrderedSet::from(remaining_keyshares.clone()); + let pubkey = self.fhe.get_aggregate_public_key(GetAggregatePublicKey { + keyshares: remaining_keyshares_set, + })?; + + let public_key_hash = compute_pk_commitment( + pubkey.clone(), + self.fhe.params.degree(), + self.fhe.params.plaintext(), + self.fhe.params.moduli().to_vec(), + )?; + + let committee_h = remaining_count; + let pubkey = ArcBytes::from_bytes(&pubkey); + + // Filter c1_signed_proofs to match remaining honest parties + let remaining_c1_signed_proofs: Vec> = stored_c1_signed_proofs + .iter() + .enumerate() + .filter(|(i, _)| !mismatched_indices.contains(i)) + .map(|(_, sp)| sp.clone()) + .collect(); + + // Publish new PkAggregationProofPending + self.bus.publish( + PkAggregationProofPending { + e3_id: self.e3_id.clone(), + proof_request: PkAggregationProofRequest { + keyshare_bytes: remaining_keyshares.clone(), + aggregated_pk_bytes: pubkey.clone(), + params_preset: self.params_preset.clone(), + committee_n: committee_h, + committee_h, + committee_threshold: 0, + c1_commitments: derive_c1_commitments(&remaining_c1_signed_proofs), + }, + public_key: pubkey.clone(), + public_key_hash, + nodes: remaining_nodes.clone(), + }, + ec.clone(), + )?; + + // Keep DKG proofs from remaining honest parties — they won't be re-delivered. + let remaining_dkg_proofs: HashMap = dkg_node_proofs + .into_iter() + .filter(|(pid, _)| remaining_ids.contains(pid)) + .collect(); + + // Transition state: reset fold but preserve honest DKG proofs + self.state.try_mutate(&ec, |_| { + Ok(PublicKeyAggregatorState::GeneratingC5Proof { + public_key: pubkey.clone(), + public_key_hash, + keyshare_bytes: remaining_keyshares, + c1_signed_proofs: remaining_c1_signed_proofs, + nodes: remaining_nodes, + dkg_node_proofs: remaining_dkg_proofs, + honest_party_ids: remaining_ids, + dishonest_parties: all_dishonest, + cross_node_fold: ProofFoldState::new(), + c5_proof_pending: None, + last_ec: Some(ec.clone()), + }) + })?; + + self.try_start_cross_node_fold(&ec)?; + + Ok(()) + } + pub fn handle_member_expelled( &mut self, node: &str, diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index d20e70923f..7a5b3384d6 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -61,6 +61,8 @@ pub struct PkAggregationProofRequest { pub committee_h: usize, /// Threshold (T). pub committee_threshold: usize, + /// C1 commitments extracted from honest parties' signed C1 proofs. + pub c1_commitments: Vec, } /// Request to generate a proof for share computation (C2a or C2b). @@ -446,6 +448,12 @@ pub enum ZkError { WitnessGenerationFailed(String), /// Invalid parameters. InvalidParams(String), + /// C1 commitment mismatch: the commitment extracted from a party's C1 proof + /// does not match the commitment computed from their keyshare data. + C1CommitmentMismatch { + /// Indices (into `keyshare_bytes` / `c1_commitments`) of mismatched parties. + mismatched_indices: Vec, + }, } impl std::fmt::Display for ZkError { @@ -456,6 +464,11 @@ impl std::fmt::Display for ZkError { write!(f, "Witness generation failed: {}", msg) } ZkError::InvalidParams(msg) => write!(f, "Invalid parameters: {}", msg), + ZkError::C1CommitmentMismatch { mismatched_indices } => write!( + f, + "C1 commitment mismatch at indices: {:?}", + mismatched_indices + ), } } } From e6905ef6fdfae5932c355b9ea6e0f8c7f21d58a6 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Mon, 23 Mar 2026 15:48:22 +0000 Subject: [PATCH 02/14] chore: update computation for c1 --- crates/aggregator/src/publickey_aggregator.rs | 54 +++++++++++++------ 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 42781ef585..fa1b79cc64 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -29,9 +29,31 @@ use tracing::{error, info, warn}; fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec { signed_proofs .iter() - .filter_map(|opt| { - opt.as_ref() - .and_then(|sp| sp.payload.proof.extract_output("pk_commitment")) + .enumerate() + .filter_map(|(i, opt)| { + let sp = opt.as_ref()?; + let proof = &sp.payload.proof; + tracing::info!( + "C1 proof[{}]: circuit={:?}, public_signals_len={}, signals_hex={}", + i, + proof.circuit, + proof.public_signals.len(), + proof.public_signals[..std::cmp::min(128, proof.public_signals.len())] + .iter() + .map(|b| format!("{:02x}", b)) + .collect::() + ); + let commitment = proof.extract_output("pk_commitment"); + if let Some(ref c) = commitment { + tracing::info!( + "C1 proof[{}]: extracted pk_commitment={}", + i, + c.iter().map(|b| format!("{:02x}", b)).collect::() + ); + } else { + tracing::warn!("C1 proof[{}]: failed to extract pk_commitment", i); + } + commitment }) .collect() } @@ -67,7 +89,6 @@ pub enum PublicKeyAggregatorState { /// Signed C1 proofs from honest parties, aligned with `keyshare_bytes`. /// Commitments are extracted on the fly via `extract_output("pk_commitment")`. /// Retained for fault attribution if a commitment mismatch is detected later. - #[serde(default)] c1_signed_proofs: Vec>, nodes: OrderedSet, /// DKG recursive proofs per party (restart-critical). @@ -410,6 +431,19 @@ impl PublicKeyAggregator { let committee_h = honest_keyshares.len(); let honest_nodes_set = OrderedSet::from(honest_nodes.clone()); let keyshare_bytes: Vec<_> = honest_keyshares_set.iter().cloned().collect(); + let c1_signed_proofs = { + // Build a map from keyshare → c1 proof, then iterate in OrderedSet order. + let ks_to_proof: std::collections::HashMap, &Option> = + honest_keyshares + .iter() + .zip(c1_signed_proofs.iter()) + .map(|(ks, proof)| (ks.to_vec(), proof)) + .collect(); + keyshare_bytes + .iter() + .map(|ks| ks_to_proof.get(&ks.to_vec()).and_then(|opt| (*opt).clone())) + .collect::>() + }; let pubkey = ArcBytes::from_bytes(&pubkey); info!("Publishing PkAggregationProofPending for C5 proof generation..."); @@ -434,7 +468,6 @@ impl PublicKeyAggregator { self.state.try_mutate(&ec, |_| { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key: pubkey.clone(), - public_key_hash, keyshare_bytes: honest_keyshares, c1_signed_proofs, nodes: honest_nodes_set, @@ -931,13 +964,6 @@ impl PublicKeyAggregator { keyshares: remaining_keyshares_set, })?; - let public_key_hash = compute_pk_commitment( - pubkey.clone(), - self.fhe.params.degree(), - self.fhe.params.plaintext(), - self.fhe.params.moduli().to_vec(), - )?; - let committee_h = remaining_count; let pubkey = ArcBytes::from_bytes(&pubkey); @@ -963,14 +989,13 @@ impl PublicKeyAggregator { c1_commitments: derive_c1_commitments(&remaining_c1_signed_proofs), }, public_key: pubkey.clone(), - public_key_hash, nodes: remaining_nodes.clone(), }, ec.clone(), )?; // Keep DKG proofs from remaining honest parties — they won't be re-delivered. - let remaining_dkg_proofs: HashMap = dkg_node_proofs + let remaining_dkg_proofs: HashMap> = dkg_node_proofs .into_iter() .filter(|(pid, _)| remaining_ids.contains(pid)) .collect(); @@ -979,7 +1004,6 @@ impl PublicKeyAggregator { self.state.try_mutate(&ec, |_| { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key: pubkey.clone(), - public_key_hash, keyshare_bytes: remaining_keyshares, c1_signed_proofs: remaining_c1_signed_proofs, nodes: remaining_nodes, From 0ff4a4d8cca4a8e1a6f9e11292d31c9791823b91 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:51:22 +0000 Subject: [PATCH 03/14] chore: pr comments --- crates/aggregator/src/publickey_aggregator.rs | 67 +++++++++++-------- 1 file changed, 40 insertions(+), 27 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index fa1b79cc64..82a33e5ffc 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -26,31 +26,14 @@ use std::sync::Arc; use tracing::{error, info, warn}; /// Derive c1_commitments from signed proofs by extracting pk_commitment from each. -fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec { +fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec> { signed_proofs .iter() .enumerate() - .filter_map(|(i, opt)| { + .map(|(i, opt)| { let sp = opt.as_ref()?; - let proof = &sp.payload.proof; - tracing::info!( - "C1 proof[{}]: circuit={:?}, public_signals_len={}, signals_hex={}", - i, - proof.circuit, - proof.public_signals.len(), - proof.public_signals[..std::cmp::min(128, proof.public_signals.len())] - .iter() - .map(|b| format!("{:02x}", b)) - .collect::() - ); - let commitment = proof.extract_output("pk_commitment"); - if let Some(ref c) = commitment { - tracing::info!( - "C1 proof[{}]: extracted pk_commitment={}", - i, - c.iter().map(|b| format!("{:02x}", b)).collect::() - ); - } else { + let commitment = sp.payload.proof.extract_output("pk_commitment"); + if commitment.is_none() { tracing::warn!("C1 proof[{}]: failed to extract pk_commitment", i); } commitment @@ -58,6 +41,18 @@ fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec>` to `Vec`, substituting 32 zero bytes +/// for any `None` entry. The C5 prover will detect these as commitment mismatches. +fn unwrap_c1_commitments(commitments: &[Option]) -> Vec { + commitments + .iter() + .map(|opt| { + opt.clone() + .unwrap_or_else(|| ArcBytes::from_bytes(&[0u8; 32])) + }) + .collect() +} + #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum PublicKeyAggregatorState { Collecting { @@ -98,6 +93,8 @@ pub enum PublicKeyAggregatorState { cross_node_fold: ProofFoldState, c5_proof_pending: Option, last_ec: Option>, + /// Cryptographic threshold M, carried from Collecting for re-aggregation checks. + threshold_m: usize, }, Complete { public_key: ArcBytes, @@ -457,7 +454,9 @@ impl PublicKeyAggregator { committee_n: committee_h, committee_h, committee_threshold: 0, - c1_commitments: derive_c1_commitments(&c1_signed_proofs), + c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( + &c1_signed_proofs, + )), }, public_key: pubkey.clone(), nodes: honest_nodes_set.clone(), @@ -477,6 +476,7 @@ impl PublicKeyAggregator { cross_node_fold: ProofFoldState::new(), c5_proof_pending: None, last_ec: Some(ec.clone()), + threshold_m, }) })?; @@ -523,6 +523,7 @@ impl PublicKeyAggregator { honest_party_ids, dishonest_parties, cross_node_fold, + threshold_m, .. } = state else { @@ -539,6 +540,7 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending: Some(c5_proof), last_ec: Some(ec.clone()), + threshold_m, }) })?; self.try_publish_complete() @@ -594,6 +596,7 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec: _, + threshold_m, } = state else { return Ok(state); @@ -610,6 +613,7 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec: Some(ec.clone()), + threshold_m, }) })?; @@ -668,6 +672,7 @@ impl PublicKeyAggregator { mut cross_node_fold, c5_proof_pending, last_ec, + threshold_m, } = state else { return Ok(state); @@ -694,6 +699,7 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec, + threshold_m, }) })?; self.try_publish_complete() @@ -810,6 +816,7 @@ impl PublicKeyAggregator { mut cross_node_fold, c5_proof_pending, last_ec, + threshold_m, } = state else { return Ok(state); @@ -833,6 +840,7 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec, + threshold_m, }) })?; self.try_publish_complete()?; @@ -852,6 +860,7 @@ impl PublicKeyAggregator { dkg_node_proofs, honest_party_ids, dishonest_parties, + threshold_m, .. } = self .state @@ -945,10 +954,11 @@ impl PublicKeyAggregator { // Check if enough honest parties remain let remaining_count = remaining_keyshares.len(); - // We need > 0 honest parties; the circuit enforces the threshold check - if remaining_count == 0 { + if remaining_count <= threshold_m { return Err(anyhow::anyhow!( - "No honest parties remaining after C1 commitment mismatch filtering" + "Not enough honest parties after C1 commitment mismatch filtering: {} (need at least {})", + remaining_count, + threshold_m + 1 )); } @@ -985,8 +995,10 @@ impl PublicKeyAggregator { params_preset: self.params_preset.clone(), committee_n: committee_h, committee_h, - committee_threshold: 0, - c1_commitments: derive_c1_commitments(&remaining_c1_signed_proofs), + committee_threshold: threshold_m, + c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( + &remaining_c1_signed_proofs, + )), }, public_key: pubkey.clone(), nodes: remaining_nodes.clone(), @@ -1013,6 +1025,7 @@ impl PublicKeyAggregator { cross_node_fold: ProofFoldState::new(), c5_proof_pending: None, last_ec: Some(ec.clone()), + threshold_m, }) })?; From 702d70c70865b046b251874adf05e25e5025f0d2 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:28:14 +0000 Subject: [PATCH 04/14] chore: throw error in unwrap signal --- crates/aggregator/src/publickey_aggregator.rs | 22 ++++++++++++------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 82a33e5ffc..3f8dbbd014 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -41,14 +41,20 @@ fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec>` to `Vec`, substituting 32 zero bytes -/// for any `None` entry. The C5 prover will detect these as commitment mismatches. -fn unwrap_c1_commitments(commitments: &[Option]) -> Vec { +/// Convert `Vec>` to `Vec`, failing if any entry is `None`. +/// A `None` means pk_commitment extraction failed for that party's C1 proof — +/// this is a bug or a corrupted proof, not a normal mismatch. +fn unwrap_c1_commitments(commitments: &[Option]) -> Result> { commitments .iter() - .map(|opt| { - opt.clone() - .unwrap_or_else(|| ArcBytes::from_bytes(&[0u8; 32])) + .enumerate() + .map(|(i, opt)| { + opt.clone().ok_or_else(|| { + anyhow::anyhow!( + "Failed to extract pk_commitment from C1 proof at index {}", + i + ) + }) }) .collect() } @@ -456,7 +462,7 @@ impl PublicKeyAggregator { committee_threshold: 0, c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( &c1_signed_proofs, - )), + ))?, }, public_key: pubkey.clone(), nodes: honest_nodes_set.clone(), @@ -998,7 +1004,7 @@ impl PublicKeyAggregator { committee_threshold: threshold_m, c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( &remaining_c1_signed_proofs, - )), + ))?, }, public_key: pubkey.clone(), nodes: remaining_nodes.clone(), From 201d0cf5879691f3df7e90917548805323cab13e Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 09:26:17 +0000 Subject: [PATCH 05/14] chore: pr comments --- crates/aggregator/src/publickey_aggregator.rs | 146 ++---------------- 1 file changed, 9 insertions(+), 137 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 3f8dbbd014..e8c4e5b833 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -473,7 +473,7 @@ impl PublicKeyAggregator { self.state.try_mutate(&ec, |_| { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key: pubkey.clone(), - keyshare_bytes: honest_keyshares, + keyshare_bytes, c1_signed_proofs, nodes: honest_nodes_set, dkg_node_proofs: HashMap::new(), @@ -860,13 +860,8 @@ impl PublicKeyAggregator { ec: EventContext, ) -> Result<()> { let PublicKeyAggregatorState::GeneratingC5Proof { - keyshare_bytes, c1_signed_proofs: stored_c1_signed_proofs, - nodes, - dkg_node_proofs, honest_party_ids, - dishonest_parties, - threshold_m, .. } = self .state @@ -878,34 +873,17 @@ impl PublicKeyAggregator { )); }; - // Map keyshare-order indices to original party IDs. - // keyshare_bytes[i] corresponds to the i-th element in honest_party_ids (sorted). + // Map keyshare-order indices to party IDs for logging let honest_ids_sorted: Vec = honest_party_ids.iter().copied().collect(); - let mut newly_dishonest: BTreeSet = BTreeSet::new(); - for &idx in mismatched_indices { - if let Some(&party_id) = honest_ids_sorted.get(idx) { - warn!( - "C1 commitment mismatch for party {} (index {}) — marking as dishonest", - party_id, idx - ); - newly_dishonest.insert(party_id); - } else { - warn!( - "C1 commitment mismatch index {} out of range (honest parties: {})", - idx, - honest_ids_sorted.len() - ); - } - } - - if newly_dishonest.is_empty() { - return Err(anyhow::anyhow!( - "C1 commitment mismatch reported but no valid party indices" - )); - } - // Emit SignedProofFailed for each mismatched party that has a signed C1 proof + // Emit SignedProofFailed for each mismatched party for &idx in mismatched_indices { + let party_id = honest_ids_sorted.get(idx).copied().unwrap_or(u64::MAX); + warn!( + "C1 commitment mismatch for party {} (index {}) — reporting fault", + party_id, idx + ); + if let Some(Some(signed_payload)) = stored_c1_signed_proofs.get(idx) { match signed_payload.recover_address() { Ok(faulting_node) => { @@ -931,112 +909,6 @@ impl PublicKeyAggregator { } } - // Filter out the newly dishonest parties - let remaining_keyshares: Vec = keyshare_bytes - .iter() - .enumerate() - .filter(|(i, _)| !mismatched_indices.contains(i)) - .map(|(_, ks)| ks.clone()) - .collect(); - - let remaining_ids: BTreeSet = honest_party_ids - .iter() - .copied() - .filter(|id| !newly_dishonest.contains(id)) - .collect(); - - let remaining_nodes: OrderedSet = { - let nodes_vec: Vec = nodes - .iter() - .enumerate() - .filter(|(i, _)| !mismatched_indices.contains(i)) - .map(|(_, n)| n.clone()) - .collect(); - OrderedSet::from(nodes_vec) - }; - - let mut all_dishonest = dishonest_parties.clone(); - all_dishonest.extend(newly_dishonest.iter()); - - // Check if enough honest parties remain - let remaining_count = remaining_keyshares.len(); - if remaining_count <= threshold_m { - return Err(anyhow::anyhow!( - "Not enough honest parties after C1 commitment mismatch filtering: {} (need at least {})", - remaining_count, - threshold_m + 1 - )); - } - - info!( - "Re-aggregating public key from {} remaining honest parties (removed {} dishonest)", - remaining_count, - newly_dishonest.len() - ); - - // Re-aggregate the public key without the dishonest parties - let remaining_keyshares_set = OrderedSet::from(remaining_keyshares.clone()); - let pubkey = self.fhe.get_aggregate_public_key(GetAggregatePublicKey { - keyshares: remaining_keyshares_set, - })?; - - let committee_h = remaining_count; - let pubkey = ArcBytes::from_bytes(&pubkey); - - // Filter c1_signed_proofs to match remaining honest parties - let remaining_c1_signed_proofs: Vec> = stored_c1_signed_proofs - .iter() - .enumerate() - .filter(|(i, _)| !mismatched_indices.contains(i)) - .map(|(_, sp)| sp.clone()) - .collect(); - - // Publish new PkAggregationProofPending - self.bus.publish( - PkAggregationProofPending { - e3_id: self.e3_id.clone(), - proof_request: PkAggregationProofRequest { - keyshare_bytes: remaining_keyshares.clone(), - aggregated_pk_bytes: pubkey.clone(), - params_preset: self.params_preset.clone(), - committee_n: committee_h, - committee_h, - committee_threshold: threshold_m, - c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( - &remaining_c1_signed_proofs, - ))?, - }, - public_key: pubkey.clone(), - nodes: remaining_nodes.clone(), - }, - ec.clone(), - )?; - - // Keep DKG proofs from remaining honest parties — they won't be re-delivered. - let remaining_dkg_proofs: HashMap> = dkg_node_proofs - .into_iter() - .filter(|(pid, _)| remaining_ids.contains(pid)) - .collect(); - - // Transition state: reset fold but preserve honest DKG proofs - self.state.try_mutate(&ec, |_| { - Ok(PublicKeyAggregatorState::GeneratingC5Proof { - public_key: pubkey.clone(), - keyshare_bytes: remaining_keyshares, - c1_signed_proofs: remaining_c1_signed_proofs, - nodes: remaining_nodes, - dkg_node_proofs: remaining_dkg_proofs, - honest_party_ids: remaining_ids, - dishonest_parties: all_dishonest, - cross_node_fold: ProofFoldState::new(), - c5_proof_pending: None, - last_ec: Some(ec.clone()), - threshold_m, - }) - })?; - - self.try_start_cross_node_fold(&ec)?; - Ok(()) } From 7d9595e600728df1f8a5069c0fb2737bb1f5228e Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 10:39:22 +0000 Subject: [PATCH 06/14] chore: pr comments --- crates/aggregator/src/publickey_aggregator.rs | 32 +++-- crates/tests/tests/integration.rs | 132 ++++++++++++++++++ 2 files changed, 153 insertions(+), 11 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index e8c4e5b833..21728709a7 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -99,8 +99,6 @@ pub enum PublicKeyAggregatorState { cross_node_fold: ProofFoldState, c5_proof_pending: Option, last_ec: Option>, - /// Cryptographic threshold M, carried from Collecting for re-aggregation checks. - threshold_m: usize, }, Complete { public_key: ArcBytes, @@ -482,7 +480,6 @@ impl PublicKeyAggregator { cross_node_fold: ProofFoldState::new(), c5_proof_pending: None, last_ec: Some(ec.clone()), - threshold_m, }) })?; @@ -529,7 +526,6 @@ impl PublicKeyAggregator { honest_party_ids, dishonest_parties, cross_node_fold, - threshold_m, .. } = state else { @@ -546,7 +542,6 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending: Some(c5_proof), last_ec: Some(ec.clone()), - threshold_m, }) })?; self.try_publish_complete() @@ -602,7 +597,6 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec: _, - threshold_m, } = state else { return Ok(state); @@ -619,7 +613,6 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec: Some(ec.clone()), - threshold_m, }) })?; @@ -678,7 +671,6 @@ impl PublicKeyAggregator { mut cross_node_fold, c5_proof_pending, last_ec, - threshold_m, } = state else { return Ok(state); @@ -705,7 +697,6 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec, - threshold_m, }) })?; self.try_publish_complete() @@ -822,7 +813,6 @@ impl PublicKeyAggregator { mut cross_node_fold, c5_proof_pending, last_ec, - threshold_m, } = state else { return Ok(state); @@ -846,7 +836,6 @@ impl PublicKeyAggregator { cross_node_fold, c5_proof_pending, last_ec, - threshold_m, }) })?; self.try_publish_complete()?; @@ -1150,3 +1139,24 @@ impl Handler for PublicKeyAggregator { ctx.stop(); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unwrap_c1_commitments_succeeds_when_all_present() { + let commitments = vec![ + Some(ArcBytes::from_bytes(&[0x11; 32])), + Some(ArcBytes::from_bytes(&[0x22; 32])), + ]; + assert_eq!(unwrap_c1_commitments(&commitments).unwrap().len(), 2); + } + + #[test] + fn unwrap_c1_commitments_fails_on_missing_entry() { + let commitments = vec![Some(ArcBytes::from_bytes(&[0x11; 32])), None]; + let err = unwrap_c1_commitments(&commitments).unwrap_err(); + assert!(err.to_string().contains("index 1")); + } +} diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 22962d6a01..714055fd27 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -562,6 +562,138 @@ impl Report { } } +/// Test C1→C5 commitment mismatch detection. +/// +/// Verifies the full flow: when the C5 prover detects that C1 pk_commitments +/// don't match the computed commitments from keyshare data, it returns a +/// C1CommitmentMismatch error, which causes the aggregator to emit +/// SignedProofFailed for the faulting parties and fail the E3. +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_commitment_mismatch() -> Result<()> { + use e3_events::{CircuitName, GetEvents, ProofPayload, ProofType, SignedProofPayload}; + use e3_utils::utility_types::ArcBytes; + + let _guard = with_tracing("info"); + let (bus, _rng, _seed, _params, _crp, _errors, history) = + e3_test_helpers::get_common_setup(None)?; + + let e3_id = E3id::new("99", 1); + + // Create mock C1 proofs with known pk_commitments + let make_c1_proof = |pk: &[u8; 32]| { + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment + signals[32..64].copy_from_slice(pk); // pk_commitment + signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment + e3_events::Proof::new( + CircuitName::PkGeneration, + ArcBytes::from_bytes(&[0u8; 8]), + ArcBytes::from_bytes(&signals), + ) + }; + + let sign_proof = |proof: e3_events::Proof| -> SignedProofPayload { + let signer = PrivateKeySigner::random(); + let payload = ProofPayload { + e3_id: e3_id.clone(), + proof_type: ProofType::C1PkGeneration, + proof, + }; + SignedProofPayload::sign(payload, &signer).unwrap() + }; + + // Verify extraction works: derive pk_commitments from signed C1 proofs + let c1_proofs = vec![ + Some(sign_proof(make_c1_proof(&[0x11; 32]))), + Some(sign_proof(make_c1_proof(&[0x22; 32]))), + Some(sign_proof(make_c1_proof(&[0x33; 32]))), + ]; + + // Extract pk_commitments — should match what we put in + for (i, proof_opt) in c1_proofs.iter().enumerate() { + let proof = &proof_opt.as_ref().unwrap().payload.proof; + let extracted = proof.extract_output("pk_commitment"); + assert!( + extracted.is_some(), + "Failed to extract pk_commitment from C1 proof[{}]", + i + ); + } + + // Verify that mismatched commitments are detectable: + // If C5 prover computes commitment X for party 0, but C1 proof has commitment Y, + // the comparison fails. + let c1_commitment_from_proof = c1_proofs[0] + .as_ref() + .unwrap() + .payload + .proof + .extract_output("pk_commitment") + .unwrap(); + let wrong_commitment = ArcBytes::from_bytes(&[0xFF; 32]); + assert_ne!( + c1_commitment_from_proof[..], + wrong_commitment[..], + "Sanity check: commitments should differ" + ); + + // Verify the ComputeRequestError with C1CommitmentMismatch can be constructed + // and carries the correct indices + let error = e3_events::ComputeRequestError::new( + e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { + mismatched_indices: vec![0, 2], + }), + e3_events::ComputeRequest::zk( + e3_events::ZkRequest::PkAggregation(e3_events::PkAggregationProofRequest { + keyshare_bytes: vec![], + aggregated_pk_bytes: ArcBytes::from_bytes(&[]), + params_preset: e3_fhe_params::BfvPreset::InsecureThreshold512, + committee_n: 3, + committee_h: 3, + committee_threshold: 1, + c1_commitments: vec![], + }), + e3_events::CorrelationId::new(), + e3_id.clone(), + ), + ); + + // Publish the error on the bus + bus.publish_without_context(error.clone())?; + + // Give actix time to process + tokio::time::sleep(std::time::Duration::from_millis(200)).await; + + // The error should be visible in history as ComputeRequestError + let events = history.send(GetEvents::new()).await?; + let error_events: Vec<_> = events + .iter() + .filter(|e| e.event_type() == "ComputeRequestError") + .collect(); + assert!( + !error_events.is_empty(), + "Expected ComputeRequestError in history, got: {:?}", + events.iter().map(|e| e.event_type()).collect::>() + ); + + // Verify the error kind is C1CommitmentMismatch + if let EnclaveEventData::ComputeRequestError(ref err) = error_events[0].get_data() { + match err.get_err() { + e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { + ref mismatched_indices, + }) => { + assert_eq!(mismatched_indices, &[0, 2]); + } + other => bail!("Expected C1CommitmentMismatch, got: {:?}", other), + } + } else { + bail!("Expected ComputeRequestError event data"); + } + + Ok(()) +} + /// Test trbfv #[actix::test] #[serial_test::serial] From 951ccebfde2a56858366c85f611a2e1f6e7e286c Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:56:03 +0000 Subject: [PATCH 07/14] refactor: simplify and ensure we retry aggregation --- crates/aggregator/src/publickey_aggregator.rs | 236 +++++++-------- .../src/enclave_event/compute_request/zk.rs | 18 +- crates/tests/tests/integration.rs | 274 ++++++++++++------ 3 files changed, 310 insertions(+), 218 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 21728709a7..08790fe3c8 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -25,40 +25,6 @@ use std::collections::{BTreeSet, HashMap}; use std::sync::Arc; use tracing::{error, info, warn}; -/// Derive c1_commitments from signed proofs by extracting pk_commitment from each. -fn derive_c1_commitments(signed_proofs: &[Option]) -> Vec> { - signed_proofs - .iter() - .enumerate() - .map(|(i, opt)| { - let sp = opt.as_ref()?; - let commitment = sp.payload.proof.extract_output("pk_commitment"); - if commitment.is_none() { - tracing::warn!("C1 proof[{}]: failed to extract pk_commitment", i); - } - commitment - }) - .collect() -} - -/// Convert `Vec>` to `Vec`, failing if any entry is `None`. -/// A `None` means pk_commitment extraction failed for that party's C1 proof — -/// this is a bug or a corrupted proof, not a normal mismatch. -fn unwrap_c1_commitments(commitments: &[Option]) -> Result> { - commitments - .iter() - .enumerate() - .map(|(i, opt)| { - opt.clone().ok_or_else(|| { - anyhow::anyhow!( - "Failed to extract pk_commitment from C1 proof at index {}", - i - ) - }) - }) - .collect() -} - #[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub enum PublicKeyAggregatorState { Collecting { @@ -87,10 +53,6 @@ pub enum PublicKeyAggregatorState { GeneratingC5Proof { public_key: ArcBytes, keyshare_bytes: Vec, - /// Signed C1 proofs from honest parties, aligned with `keyshare_bytes`. - /// Commitments are extracted on the fly via `extract_output("pk_commitment")`. - /// Retained for fault attribution if a commitment mismatch is detected later. - c1_signed_proofs: Vec>, nodes: OrderedSet, /// DKG recursive proofs per party (restart-critical). dkg_node_proofs: HashMap>, @@ -384,12 +346,22 @@ impl PublicKeyAggregator { .map(|(_, (node, ks))| (ks.clone(), node.clone())) .unzip(); - // Collect signed C1 proofs from honest parties (commitments derived on the fly) - let c1_signed_proofs: Vec> = honest_entries + // Collect signed C1 proofs from honest parties (submission order). + // All honest parties should have a signed C1 proof (parties without + // proofs are marked dishonest in dispatch_c1_verification). + let c1_signed_proofs_submission_order: Vec = honest_entries .iter() - .map(|(idx, _)| c1_proofs.get(*idx).and_then(|opt| opt.clone())) + .filter_map(|(idx, _)| c1_proofs.get(*idx).and_then(|opt| opt.clone())) .collect(); + if c1_signed_proofs_submission_order.len() != honest_keyshares.len() { + return Err(anyhow::anyhow!( + "C1 proof count ({}) != honest keyshare count ({}) — data misalignment", + c1_signed_proofs_submission_order.len(), + honest_keyshares.len() + )); + } + if !dishonest_parties.is_empty() { warn!( "Total dishonest parties (ZK + commitment): {:?}", @@ -432,18 +404,17 @@ impl PublicKeyAggregator { let committee_h = honest_keyshares.len(); let honest_nodes_set = OrderedSet::from(honest_nodes.clone()); let keyshare_bytes: Vec<_> = honest_keyshares_set.iter().cloned().collect(); - let c1_signed_proofs = { - // Build a map from keyshare → c1 proof, then iterate in OrderedSet order. - let ks_to_proof: std::collections::HashMap, &Option> = + let c1_signed_proofs: Vec = { + let ks_to_proof: std::collections::HashMap, &SignedProofPayload> = honest_keyshares .iter() - .zip(c1_signed_proofs.iter()) - .map(|(ks, proof)| (ks.to_vec(), proof)) + .zip(c1_signed_proofs_submission_order.iter()) + .map(|(ks, sp)| (ks.to_vec(), sp)) .collect(); keyshare_bytes .iter() - .map(|ks| ks_to_proof.get(&ks.to_vec()).and_then(|opt| (*opt).clone())) - .collect::>() + .filter_map(|ks| ks_to_proof.get(&ks.to_vec()).map(|sp| (*sp).clone())) + .collect() }; let pubkey = ArcBytes::from_bytes(&pubkey); @@ -458,9 +429,7 @@ impl PublicKeyAggregator { committee_n: committee_h, committee_h, committee_threshold: 0, - c1_commitments: unwrap_c1_commitments(&derive_c1_commitments( - &c1_signed_proofs, - ))?, + c1_signed_proofs, }, public_key: pubkey.clone(), nodes: honest_nodes_set.clone(), @@ -472,7 +441,6 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key: pubkey.clone(), keyshare_bytes, - c1_signed_proofs, nodes: honest_nodes_set, dkg_node_proofs: HashMap::new(), honest_party_ids: honest_party_ids.clone(), @@ -520,7 +488,6 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -534,7 +501,6 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -589,7 +555,6 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, mut dkg_node_proofs, honest_party_ids, @@ -605,7 +570,6 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -663,7 +627,6 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -689,7 +652,6 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -805,7 +767,6 @@ impl PublicKeyAggregator { let PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -828,7 +789,6 @@ impl PublicKeyAggregator { Ok(PublicKeyAggregatorState::GeneratingC5Proof { public_key, keyshare_bytes, - c1_signed_proofs, nodes, dkg_node_proofs, honest_party_ids, @@ -843,61 +803,106 @@ impl PublicKeyAggregator { Ok(()) } - fn handle_c1_commitment_mismatch( + /// Handle C5 proof error. On C1CommitmentMismatch, re-aggregate without + /// the faulting parties and re-dispatch C5. On other errors, just log. + fn handle_c5_error( &mut self, - mismatched_indices: &[usize], + error: ComputeRequestError, ec: EventContext, ) -> Result<()> { - let PublicKeyAggregatorState::GeneratingC5Proof { - c1_signed_proofs: stored_c1_signed_proofs, - honest_party_ids, - .. - } = self - .state - .get() - .ok_or_else(|| anyhow::anyhow!("Expected GeneratingC5Proof state"))? + let ComputeRequestErrorKind::Zk(ZkError::C1CommitmentMismatch { + ref mismatched_indices, + }) = error.get_err() else { - return Err(anyhow::anyhow!( - "handle_c1_commitment_mismatch called outside GeneratingC5Proof state" - )); + error!( + "PublicKeyAggregator received ComputeRequestError: {}", + error + ); + return Ok(()); }; - // Map keyshare-order indices to party IDs for logging - let honest_ids_sorted: Vec = honest_party_ids.iter().copied().collect(); + // Extract the original request from the error + let pk_req = match &error.request().request { + ComputeRequestKind::Zk(ZkRequest::PkAggregation(req)) => req.clone(), + _ => { + error!("C1CommitmentMismatch error with non-PkAggregation request"); + return Ok(()); + } + }; - // Emit SignedProofFailed for each mismatched party - for &idx in mismatched_indices { - let party_id = honest_ids_sorted.get(idx).copied().unwrap_or(u64::MAX); - warn!( - "C1 commitment mismatch for party {} (index {}) — reporting fault", - party_id, idx - ); + warn!( + "C1 commitment mismatch at indices {:?} — re-aggregating without faulting parties", + mismatched_indices + ); - if let Some(Some(signed_payload)) = stored_c1_signed_proofs.get(idx) { - match signed_payload.recover_address() { - Ok(faulting_node) => { - if let Err(err) = self.bus.publish( - SignedProofFailed { - e3_id: self.e3_id.clone(), - faulting_node, - proof_type: ProofType::C1PkGeneration, - signed_payload: signed_payload.clone(), - }, - ec.clone(), - ) { - error!("Failed to publish SignedProofFailed for C1 mismatch at index {}: {err}", idx); - } - } - Err(err) => { - warn!( - "Could not recover address from C1 signed proof at index {}: {err}", - idx - ); - } - } - } + // Filter out mismatched parties + let remaining_keyshares: Vec = pk_req + .keyshare_bytes + .iter() + .enumerate() + .filter(|(i, _)| !mismatched_indices.contains(i)) + .map(|(_, ks)| ks.clone()) + .collect(); + let remaining_c1_proofs: Vec = pk_req + .c1_signed_proofs + .iter() + .enumerate() + .filter(|(i, _)| !mismatched_indices.contains(i)) + .map(|(_, sp)| sp.clone()) + .collect(); + + if remaining_keyshares.len() <= pk_req.committee_threshold { + error!( + "Not enough honest parties after C1 commitment mismatch filtering: {} (need > {})", + remaining_keyshares.len(), + pk_req.committee_threshold + ); + self.bus.publish( + e3_events::E3Failed { + e3_id: self.e3_id.clone(), + failed_at_stage: e3_events::E3Stage::CommitteeFinalized, + reason: e3_events::FailureReason::DKGInvalidShares, + }, + ec, + )?; + return Ok(()); } + // Re-aggregate the public key from remaining honest keyshares + let remaining_set = OrderedSet::from(remaining_keyshares.clone()); + let pubkey = self.fhe.get_aggregate_public_key(GetAggregatePublicKey { + keyshares: remaining_set, + })?; + + let committee_h = remaining_keyshares.len(); + let pubkey = ArcBytes::from_bytes(&pubkey); + + // Re-dispatch C5 with the filtered data + self.bus.publish( + PkAggregationProofPending { + e3_id: self.e3_id.clone(), + proof_request: PkAggregationProofRequest { + keyshare_bytes: remaining_keyshares, + aggregated_pk_bytes: pubkey.clone(), + params_preset: self.params_preset.clone(), + committee_n: committee_h, + committee_h, + committee_threshold: 0, + c1_signed_proofs: remaining_c1_proofs, + }, + public_key: pubkey, + nodes: self + .state + .get() + .map(|s| match s { + PublicKeyAggregatorState::GeneratingC5Proof { nodes, .. } => nodes, + _ => OrderedSet::new(), + }) + .unwrap_or_default(), + }, + ec, + )?; + Ok(()) } @@ -1139,24 +1144,3 @@ impl Handler for PublicKeyAggregator { ctx.stop(); } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn unwrap_c1_commitments_succeeds_when_all_present() { - let commitments = vec![ - Some(ArcBytes::from_bytes(&[0x11; 32])), - Some(ArcBytes::from_bytes(&[0x22; 32])), - ]; - assert_eq!(unwrap_c1_commitments(&commitments).unwrap().len(), 2); - } - - #[test] - fn unwrap_c1_commitments_fails_on_missing_entry() { - let commitments = vec![Some(ArcBytes::from_bytes(&[0x11; 32])), None]; - let err = unwrap_c1_commitments(&commitments).unwrap_err(); - assert!(err.to_string().contains("index 1")); - } -} diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index 7a5b3384d6..ffdb2213b8 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -61,8 +61,10 @@ pub struct PkAggregationProofRequest { pub committee_h: usize, /// Threshold (T). pub committee_threshold: usize, - /// C1 commitments extracted from honest parties' signed C1 proofs. - pub c1_commitments: Vec, + /// Signed C1 proofs per party, aligned with `keyshare_bytes`. + /// The C5 prover extracts pk_commitment from each proof for cross-checking, + /// and returns mismatched indices for fault attribution. + pub c1_signed_proofs: Vec, } /// Request to generate a proof for share computation (C2a or C2b). @@ -448,12 +450,10 @@ pub enum ZkError { WitnessGenerationFailed(String), /// Invalid parameters. InvalidParams(String), - /// C1 commitment mismatch: the commitment extracted from a party's C1 proof - /// does not match the commitment computed from their keyshare data. - C1CommitmentMismatch { - /// Indices (into `keyshare_bytes` / `c1_commitments`) of mismatched parties. - mismatched_indices: Vec, - }, + /// C1 commitment mismatch: not enough honest parties remain after filtering. + /// The mismatched indices identify which parties' C1 proofs are inconsistent + /// with their keyshare data. + C1CommitmentMismatch { mismatched_indices: Vec }, } impl std::fmt::Display for ZkError { @@ -466,7 +466,7 @@ impl std::fmt::Display for ZkError { ZkError::InvalidParams(msg) => write!(f, "Invalid parameters: {}", msg), ZkError::C1CommitmentMismatch { mismatched_indices } => write!( f, - "C1 commitment mismatch at indices: {:?}", + "C1 commitment mismatch at indices {:?} — not enough honest parties", mismatched_indices ), } diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 714055fd27..298ba1a364 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -562,87 +562,155 @@ impl Report { } } -/// Test C1→C5 commitment mismatch detection. +// ── C1→C5 Commitment Connection Tests ──────────────────────────────────────── +// +// These tests verify the C1→C5 proof connection: ensuring the keyshare data +// used in C5 (PK aggregation) matches what each party committed to in their +// C1 (PK generation) proof. +// +// The flow: +// 1. Aggregator extracts pk_commitment from each party's signed C1 proof +// 2. Passes signed C1 proofs to the C5 prover via PkAggregationProofRequest +// 3. C5 prover computes expected commitments from keyshare data and compares +// 4. On mismatch: returns C1CommitmentMismatch error → ProofRequestActor +// emits SignedProofFailed → aggregator re-aggregates and retries +// 5. On success: proof is generated with the honest subset + +/// Helper: build a mock C1 proof with known (sk, pk, esm) commitments. +/// C1 has 3 output fields of 32 bytes each in public_signals. +fn make_c1_proof(e3_id: &E3id, pk_commitment: &[u8; 32]) -> e3_events::SignedProofPayload { + use e3_events::{CircuitName, ProofPayload, ProofType, SignedProofPayload}; + + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment + signals[32..64].copy_from_slice(pk_commitment); // pk_commitment + signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment + let proof = e3_events::Proof::new( + CircuitName::PkGeneration, + ArcBytes::from_bytes(&[0u8; 8]), + ArcBytes::from_bytes(&signals), + ); + let signer = PrivateKeySigner::random(); + let payload = ProofPayload { + e3_id: e3_id.clone(), + proof_type: ProofType::C1PkGeneration, + proof, + }; + SignedProofPayload::sign(payload, &signer).unwrap() +} + +/// Scenario 1: All C1 commitments match — happy path. /// -/// Verifies the full flow: when the C5 prover detects that C1 pk_commitments -/// don't match the computed commitments from keyshare data, it returns a -/// C1CommitmentMismatch error, which causes the aggregator to emit -/// SignedProofFailed for the faulting parties and fail the E3. +/// When all parties' C1 pk_commitments match the computed commitments from +/// their keyshare data, the C5 proof is generated successfully. #[actix::test] #[serial_test::serial] -async fn test_c1_c5_commitment_mismatch() -> Result<()> { - use e3_events::{CircuitName, GetEvents, ProofPayload, ProofType, SignedProofPayload}; - use e3_utils::utility_types::ArcBytes; - +async fn test_c1_c5_all_commitments_match() -> Result<()> { let _guard = with_tracing("info"); - let (bus, _rng, _seed, _params, _crp, _errors, history) = - e3_test_helpers::get_common_setup(None)?; + let e3_id = E3id::new("100", 1); - let e3_id = E3id::new("99", 1); - - // Create mock C1 proofs with known pk_commitments - let make_c1_proof = |pk: &[u8; 32]| { - let mut signals = vec![0u8; 96]; - signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment - signals[32..64].copy_from_slice(pk); // pk_commitment - signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment - e3_events::Proof::new( - CircuitName::PkGeneration, - ArcBytes::from_bytes(&[0u8; 8]), - ArcBytes::from_bytes(&signals), - ) - }; - - let sign_proof = |proof: e3_events::Proof| -> SignedProofPayload { - let signer = PrivateKeySigner::random(); - let payload = ProofPayload { - e3_id: e3_id.clone(), - proof_type: ProofType::C1PkGeneration, - proof, - }; - SignedProofPayload::sign(payload, &signer).unwrap() - }; - - // Verify extraction works: derive pk_commitments from signed C1 proofs - let c1_proofs = vec![ - Some(sign_proof(make_c1_proof(&[0x11; 32]))), - Some(sign_proof(make_c1_proof(&[0x22; 32]))), - Some(sign_proof(make_c1_proof(&[0x33; 32]))), + // Build 3 signed C1 proofs with distinct pk_commitments + let proofs = vec![ + make_c1_proof(&e3_id, &[0x11; 32]), + make_c1_proof(&e3_id, &[0x22; 32]), + make_c1_proof(&e3_id, &[0x33; 32]), ]; - // Extract pk_commitments — should match what we put in - for (i, proof_opt) in c1_proofs.iter().enumerate() { - let proof = &proof_opt.as_ref().unwrap().payload.proof; - let extracted = proof.extract_output("pk_commitment"); + // Verify extract_output("pk_commitment") returns the correct values + for (i, sp) in proofs.iter().enumerate() { + let extracted = sp.payload.proof.extract_output("pk_commitment"); assert!( extracted.is_some(), - "Failed to extract pk_commitment from C1 proof[{}]", + "extract_output failed for proof[{}]", i ); } + assert_eq!( + proofs[0] + .payload + .proof + .extract_output("pk_commitment") + .unwrap()[..], + [0x11; 32] + ); + assert_eq!( + proofs[1] + .payload + .proof + .extract_output("pk_commitment") + .unwrap()[..], + [0x22; 32] + ); + assert_eq!( + proofs[2] + .payload + .proof + .extract_output("pk_commitment") + .unwrap()[..], + [0x33; 32] + ); - // Verify that mismatched commitments are detectable: - // If C5 prover computes commitment X for party 0, but C1 proof has commitment Y, - // the comparison fails. - let c1_commitment_from_proof = c1_proofs[0] - .as_ref() - .unwrap() - .payload - .proof - .extract_output("pk_commitment") - .unwrap(); - let wrong_commitment = ArcBytes::from_bytes(&[0xFF; 32]); - assert_ne!( - c1_commitment_from_proof[..], - wrong_commitment[..], - "Sanity check: commitments should differ" + Ok(()) +} + +/// Scenario 2: Partial mismatch — C1CommitmentMismatch error carries indices. +/// +/// When some parties' C1 pk_commitments don't match, the C5 prover returns +/// a C1CommitmentMismatch error with the specific faulting indices. The +/// ProofRequestActor uses these indices to emit SignedProofFailed for each +/// faulting party, and the aggregator re-aggregates without them. +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_partial_mismatch_error() -> Result<()> { + let _guard = with_tracing("info"); + + // Construct a C1CommitmentMismatch error with indices [0, 2] + let mismatch_err = e3_events::ZkError::C1CommitmentMismatch { + mismatched_indices: vec![0, 2], + }; + + // Error message should contain the indices for debugging + let msg = mismatch_err.to_string(); + assert!( + msg.contains("[0, 2]"), + "Error should contain indices: {}", + msg + ); + + // ComputeRequestErrorKind should wrap it correctly + let kind = e3_events::ComputeRequestErrorKind::Zk(mismatch_err); + assert!( + matches!( + kind, + e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { .. }) + ), + "Should match C1CommitmentMismatch variant" ); - // Verify the ComputeRequestError with C1CommitmentMismatch can be constructed - // and carries the correct indices + Ok(()) +} + +/// Scenario 3: Total mismatch — E3 fails when not enough honest parties. +/// +/// When ALL parties have mismatched commitments, the aggregator cannot +/// re-aggregate (0 remaining ≤ threshold). It publishes E3Failed to +/// properly clean up the E3. +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_total_mismatch_fails_e3() -> Result<()> { + use e3_events::GetEvents; + + let _guard = with_tracing("info"); + let (bus, _rng, _seed, _params, _crp, _errors, history) = + e3_test_helpers::get_common_setup(None)?; + + let e3_id = E3id::new("101", 1); + + // Publish a C1CommitmentMismatch ComputeRequestError on the bus. + // In the real flow, this comes from the multithread prover. let error = e3_events::ComputeRequestError::new( e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { - mismatched_indices: vec![0, 2], + mismatched_indices: vec![0, 1, 2], }), e3_events::ComputeRequest::zk( e3_events::ZkRequest::PkAggregation(e3_events::PkAggregationProofRequest { @@ -652,20 +720,17 @@ async fn test_c1_c5_commitment_mismatch() -> Result<()> { committee_n: 3, committee_h: 3, committee_threshold: 1, - c1_commitments: vec![], + c1_signed_proofs: vec![], }), e3_events::CorrelationId::new(), e3_id.clone(), ), ); + bus.publish_without_context(error)?; - // Publish the error on the bus - bus.publish_without_context(error.clone())?; + tokio::time::sleep(Duration::from_millis(200)).await; - // Give actix time to process - tokio::time::sleep(std::time::Duration::from_millis(200)).await; - - // The error should be visible in history as ComputeRequestError + // The error should be visible in history let events = history.send(GetEvents::new()).await?; let error_events: Vec<_> = events .iter() @@ -677,18 +742,61 @@ async fn test_c1_c5_commitment_mismatch() -> Result<()> { events.iter().map(|e| e.event_type()).collect::>() ); - // Verify the error kind is C1CommitmentMismatch - if let EnclaveEventData::ComputeRequestError(ref err) = error_events[0].get_data() { - match err.get_err() { - e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { - ref mismatched_indices, - }) => { - assert_eq!(mismatched_indices, &[0, 2]); - } - other => bail!("Expected C1CommitmentMismatch, got: {:?}", other), - } - } else { - bail!("Expected ComputeRequestError event data"); + Ok(()) +} + +/// Scenario 4: Signed C1 proofs are carried in PkAggregationProofRequest. +/// +/// The request carries signed C1 proofs alongside keyshare bytes so that: +/// - The C5 prover can extract pk_commitment from each proof for cross-checking +/// - The ProofRequestActor can emit SignedProofFailed with full evidence +/// (address recovery + signed proof) when mismatches are detected +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_signed_proofs_in_request() -> Result<()> { + let _guard = with_tracing("info"); + let e3_id = E3id::new("102", 1); + + let proofs = vec![ + make_c1_proof(&e3_id, &[0x11; 32]), + make_c1_proof(&e3_id, &[0x22; 32]), + ]; + + // Build a PkAggregationProofRequest with signed proofs + let request = e3_events::PkAggregationProofRequest { + keyshare_bytes: vec![ + ArcBytes::from_bytes(&[1u8; 32]), + ArcBytes::from_bytes(&[2u8; 32]), + ], + aggregated_pk_bytes: ArcBytes::from_bytes(&[0u8; 32]), + params_preset: e3_fhe_params::BfvPreset::InsecureThreshold512, + committee_n: 2, + committee_h: 2, + committee_threshold: 1, + c1_signed_proofs: proofs.clone(), + }; + + // Proofs are aligned with keyshare_bytes + assert_eq!(request.c1_signed_proofs.len(), request.keyshare_bytes.len()); + + // Each proof's pk_commitment is extractable + for (i, sp) in request.c1_signed_proofs.iter().enumerate() { + let commitment = sp.payload.proof.extract_output("pk_commitment"); + assert!( + commitment.is_some(), + "Should extract pk_commitment from proof[{}]", + i + ); + } + + // Addresses are recoverable from signed proofs (for fault attribution) + for (i, sp) in request.c1_signed_proofs.iter().enumerate() { + let addr = sp.recover_address(); + assert!( + addr.is_ok(), + "Should recover address from signed proof[{}]", + i + ); } Ok(()) From 4de61832efefa190febf5ac851b7926d6f7e8f69 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 17:10:26 +0000 Subject: [PATCH 08/14] chore: shorten test timeout --- crates/tests/tests/integration.rs | 240 ------------------------------ 1 file changed, 240 deletions(-) diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 298ba1a364..22962d6a01 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -562,246 +562,6 @@ impl Report { } } -// ── C1→C5 Commitment Connection Tests ──────────────────────────────────────── -// -// These tests verify the C1→C5 proof connection: ensuring the keyshare data -// used in C5 (PK aggregation) matches what each party committed to in their -// C1 (PK generation) proof. -// -// The flow: -// 1. Aggregator extracts pk_commitment from each party's signed C1 proof -// 2. Passes signed C1 proofs to the C5 prover via PkAggregationProofRequest -// 3. C5 prover computes expected commitments from keyshare data and compares -// 4. On mismatch: returns C1CommitmentMismatch error → ProofRequestActor -// emits SignedProofFailed → aggregator re-aggregates and retries -// 5. On success: proof is generated with the honest subset - -/// Helper: build a mock C1 proof with known (sk, pk, esm) commitments. -/// C1 has 3 output fields of 32 bytes each in public_signals. -fn make_c1_proof(e3_id: &E3id, pk_commitment: &[u8; 32]) -> e3_events::SignedProofPayload { - use e3_events::{CircuitName, ProofPayload, ProofType, SignedProofPayload}; - - let mut signals = vec![0u8; 96]; - signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment - signals[32..64].copy_from_slice(pk_commitment); // pk_commitment - signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment - let proof = e3_events::Proof::new( - CircuitName::PkGeneration, - ArcBytes::from_bytes(&[0u8; 8]), - ArcBytes::from_bytes(&signals), - ); - let signer = PrivateKeySigner::random(); - let payload = ProofPayload { - e3_id: e3_id.clone(), - proof_type: ProofType::C1PkGeneration, - proof, - }; - SignedProofPayload::sign(payload, &signer).unwrap() -} - -/// Scenario 1: All C1 commitments match — happy path. -/// -/// When all parties' C1 pk_commitments match the computed commitments from -/// their keyshare data, the C5 proof is generated successfully. -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_all_commitments_match() -> Result<()> { - let _guard = with_tracing("info"); - let e3_id = E3id::new("100", 1); - - // Build 3 signed C1 proofs with distinct pk_commitments - let proofs = vec![ - make_c1_proof(&e3_id, &[0x11; 32]), - make_c1_proof(&e3_id, &[0x22; 32]), - make_c1_proof(&e3_id, &[0x33; 32]), - ]; - - // Verify extract_output("pk_commitment") returns the correct values - for (i, sp) in proofs.iter().enumerate() { - let extracted = sp.payload.proof.extract_output("pk_commitment"); - assert!( - extracted.is_some(), - "extract_output failed for proof[{}]", - i - ); - } - assert_eq!( - proofs[0] - .payload - .proof - .extract_output("pk_commitment") - .unwrap()[..], - [0x11; 32] - ); - assert_eq!( - proofs[1] - .payload - .proof - .extract_output("pk_commitment") - .unwrap()[..], - [0x22; 32] - ); - assert_eq!( - proofs[2] - .payload - .proof - .extract_output("pk_commitment") - .unwrap()[..], - [0x33; 32] - ); - - Ok(()) -} - -/// Scenario 2: Partial mismatch — C1CommitmentMismatch error carries indices. -/// -/// When some parties' C1 pk_commitments don't match, the C5 prover returns -/// a C1CommitmentMismatch error with the specific faulting indices. The -/// ProofRequestActor uses these indices to emit SignedProofFailed for each -/// faulting party, and the aggregator re-aggregates without them. -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_partial_mismatch_error() -> Result<()> { - let _guard = with_tracing("info"); - - // Construct a C1CommitmentMismatch error with indices [0, 2] - let mismatch_err = e3_events::ZkError::C1CommitmentMismatch { - mismatched_indices: vec![0, 2], - }; - - // Error message should contain the indices for debugging - let msg = mismatch_err.to_string(); - assert!( - msg.contains("[0, 2]"), - "Error should contain indices: {}", - msg - ); - - // ComputeRequestErrorKind should wrap it correctly - let kind = e3_events::ComputeRequestErrorKind::Zk(mismatch_err); - assert!( - matches!( - kind, - e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { .. }) - ), - "Should match C1CommitmentMismatch variant" - ); - - Ok(()) -} - -/// Scenario 3: Total mismatch — E3 fails when not enough honest parties. -/// -/// When ALL parties have mismatched commitments, the aggregator cannot -/// re-aggregate (0 remaining ≤ threshold). It publishes E3Failed to -/// properly clean up the E3. -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_total_mismatch_fails_e3() -> Result<()> { - use e3_events::GetEvents; - - let _guard = with_tracing("info"); - let (bus, _rng, _seed, _params, _crp, _errors, history) = - e3_test_helpers::get_common_setup(None)?; - - let e3_id = E3id::new("101", 1); - - // Publish a C1CommitmentMismatch ComputeRequestError on the bus. - // In the real flow, this comes from the multithread prover. - let error = e3_events::ComputeRequestError::new( - e3_events::ComputeRequestErrorKind::Zk(e3_events::ZkError::C1CommitmentMismatch { - mismatched_indices: vec![0, 1, 2], - }), - e3_events::ComputeRequest::zk( - e3_events::ZkRequest::PkAggregation(e3_events::PkAggregationProofRequest { - keyshare_bytes: vec![], - aggregated_pk_bytes: ArcBytes::from_bytes(&[]), - params_preset: e3_fhe_params::BfvPreset::InsecureThreshold512, - committee_n: 3, - committee_h: 3, - committee_threshold: 1, - c1_signed_proofs: vec![], - }), - e3_events::CorrelationId::new(), - e3_id.clone(), - ), - ); - bus.publish_without_context(error)?; - - tokio::time::sleep(Duration::from_millis(200)).await; - - // The error should be visible in history - let events = history.send(GetEvents::new()).await?; - let error_events: Vec<_> = events - .iter() - .filter(|e| e.event_type() == "ComputeRequestError") - .collect(); - assert!( - !error_events.is_empty(), - "Expected ComputeRequestError in history, got: {:?}", - events.iter().map(|e| e.event_type()).collect::>() - ); - - Ok(()) -} - -/// Scenario 4: Signed C1 proofs are carried in PkAggregationProofRequest. -/// -/// The request carries signed C1 proofs alongside keyshare bytes so that: -/// - The C5 prover can extract pk_commitment from each proof for cross-checking -/// - The ProofRequestActor can emit SignedProofFailed with full evidence -/// (address recovery + signed proof) when mismatches are detected -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_signed_proofs_in_request() -> Result<()> { - let _guard = with_tracing("info"); - let e3_id = E3id::new("102", 1); - - let proofs = vec![ - make_c1_proof(&e3_id, &[0x11; 32]), - make_c1_proof(&e3_id, &[0x22; 32]), - ]; - - // Build a PkAggregationProofRequest with signed proofs - let request = e3_events::PkAggregationProofRequest { - keyshare_bytes: vec![ - ArcBytes::from_bytes(&[1u8; 32]), - ArcBytes::from_bytes(&[2u8; 32]), - ], - aggregated_pk_bytes: ArcBytes::from_bytes(&[0u8; 32]), - params_preset: e3_fhe_params::BfvPreset::InsecureThreshold512, - committee_n: 2, - committee_h: 2, - committee_threshold: 1, - c1_signed_proofs: proofs.clone(), - }; - - // Proofs are aligned with keyshare_bytes - assert_eq!(request.c1_signed_proofs.len(), request.keyshare_bytes.len()); - - // Each proof's pk_commitment is extractable - for (i, sp) in request.c1_signed_proofs.iter().enumerate() { - let commitment = sp.payload.proof.extract_output("pk_commitment"); - assert!( - commitment.is_some(), - "Should extract pk_commitment from proof[{}]", - i - ); - } - - // Addresses are recoverable from signed proofs (for fault attribution) - for (i, sp) in request.c1_signed_proofs.iter().enumerate() { - let addr = sp.recover_address(); - assert!( - addr.is_ok(), - "Should recover address from signed proof[{}]", - i - ); - } - - Ok(()) -} - /// Test trbfv #[actix::test] #[serial_test::serial] From a89def3991640c74d068d1c5bb4d176277c155fc Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 18:17:51 +0000 Subject: [PATCH 09/14] test: add partially mocked tests --- crates/tests/tests/integration.rs | 445 ++++++++++++++++++++++++++++++ 1 file changed, 445 insertions(+) diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 22962d6a01..1e4aebba5d 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -562,6 +562,451 @@ impl Report { } } +// ── C1->C5 Commitment Connection Tests +// +// These tests verify the C1→C5 proof connection end-to-end through real actors: +// 1. Generate real BFV threshold keyshares +// 2. Compute correct pk_commitments from keyshare data +// 3. Build signed C1 proofs with correct or tampered commitments +// 4. Dispatch PkAggregationProofPending through the event bus +// 5. Verify the Multithread prover detects mismatches +// 6. Verify ProofRequestActor emits SignedProofFailed for faulting parties +// 7. Verify E3Failed is NOT emitted on mismatch (aggregator retries) + +/// Generate BFV threshold keyshares and compute the correct pk_commitment +/// for each party, matching what the C5 prover expects. +fn generate_keyshares_and_commitments( + num_parties: usize, +) -> Result<( + Vec, // keyshare_bytes (serialized PublicKeyShare) + ArcBytes, // aggregated_pk_bytes + Vec<[u8; 32]>, // correct pk_commitments per party + Arc, +)> { + let preset = BfvPreset::InsecureThreshold512; + let (threshold_params, _) = + build_pair_for_preset(preset).map_err(|e| anyhow::anyhow!("{e}"))?; + let crp = create_deterministic_crp_from_default_seed(&threshold_params); + + let mut keyshare_bytes = Vec::with_capacity(num_parties); + let mut pk_shares = Vec::with_capacity(num_parties); + + for _ in 0..num_parties { + let sk = SecretKey::random(&threshold_params, &mut OsRng); + let pk_share = PublicKeyShare::new(&sk, crp.clone(), &mut OsRng)?; + keyshare_bytes.push(ArcBytes::from_bytes(&pk_share.to_bytes())); + pk_shares.push(pk_share); + } + + let public_key: PublicKey = pk_shares.iter().cloned().aggregate()?; + let aggregated_pk_bytes = ArcBytes::from_bytes(&public_key.to_bytes()); + + // Compute correct pk_commitments using the same logic as PkAggInputs::compute + let bit_pk = compute_modulus_bit(&threshold_params); + let mut commitments = Vec::with_capacity(num_parties); + + for pk_share in &pk_shares { + let mut pk0 = CrtPolynomial::from_fhe_polynomial(&pk_share.p0_share()); + let mut pk1 = CrtPolynomial::from_fhe_polynomial(&crp.poly()); + pk0.reverse(); + pk0.center(threshold_params.moduli()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + pk1.reverse(); + pk1.center(threshold_params.moduli()) + .map_err(|e| anyhow::anyhow!("{e}"))?; + + let commitment = compute_threshold_pk_commitment(&pk0, &pk1, bit_pk); + let (_, be_bytes) = commitment.to_bytes_be(); + let mut padded = [0u8; 32]; + let start = 32usize.saturating_sub(be_bytes.len()); + padded[start..].copy_from_slice(&be_bytes[..be_bytes.len().min(32)]); + commitments.push(padded); + } + + Ok(( + keyshare_bytes, + aggregated_pk_bytes, + commitments, + threshold_params, + )) +} + +/// Build a signed C1 proof with a specific pk_commitment in public_signals. +/// C1 (PkGeneration) has 3 output fields of 32 bytes each: +/// [sk_commitment, pk_commitment, e_sm_commitment] +fn build_signed_c1_proof( + e3_id: &E3id, + pk_commitment: &[u8; 32], + signer: &PrivateKeySigner, +) -> e3_events::SignedProofPayload { + use e3_events::{CircuitName, ProofPayload, ProofType, SignedProofPayload}; + + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment (dummy) + signals[32..64].copy_from_slice(pk_commitment); // pk_commitment (real) + signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment (dummy) + + let proof = e3_events::Proof::new( + CircuitName::PkGeneration, + ArcBytes::from_bytes(&[0u8; 8]), + ArcBytes::from_bytes(&signals), + ); + let payload = ProofPayload { + e3_id: e3_id.clone(), + proof_type: ProofType::C1PkGeneration, + proof, + }; + SignedProofPayload::sign(payload, signer).unwrap() +} + +/// Set up the minimal actor stack for C1->C5 tests: +/// event bus + Multithread (with dummy ZkBackend) + ProofRequestActor + HistoryCollector. +async fn setup_c1_c5_actors() -> Result<( + BusHandle, + actix::Addr>, + tempfile::TempDir, // keep alive so paths stay valid +)> { + let system = EventSystem::new().with_fresh_bus(); + let bus = system.handle()?.enable("c1c5test"); + + let history = HistoryCollector::::new().start(); + bus.subscribe(EventType::All, history.clone().recipient()); + + // Dummy ZkBackend — the commitment check runs before any proof generation, + // so the bb binary and circuit files are never accessed on the mismatch path. + let temp = tempfile::tempdir()?; + let temp_path = temp.path(); + let dummy_backend = ZkBackend::new( + BBPath::Default(temp_path.join("bb")), + temp_path.join("circuits"), + temp_path.join("work"), + ); + + // Multithread actor with ZK prover + let rng = create_shared_rng_from_u64(99); + let cipher = Arc::new(Cipher::from_password("c1c5-test-key").await?); + let task_pool = Multithread::create_taskpool(2, 1); + Multithread::attach_with_zk(&bus, rng, cipher, task_pool, None, &dummy_backend); + + // ProofRequestActor handles PkAggregationProofPending → ComputeRequest dispatch, + // and ComputeRequestError → SignedProofFailed emission. + let signer = PrivateKeySigner::random(); + ProofRequestActor::setup(&bus, signer); + + // Enable effects so Multithread subscribes to ComputeRequest + bus.publish_without_context(EffectsEnabled::new())?; + sleep(Duration::from_millis(50)).await; + + Ok((bus, history, temp)) +} + +/// C1->C5: Tampered commitment is detected as C1CommitmentMismatch. +/// +/// Generates 3 real BFV keyshares, builds C1 proofs with correct commitments +/// for parties 0 and 1 but a WRONG commitment for party 2, dispatches +/// PkAggregationProofPending through the actor system, and verifies: +/// - ComputeRequestError with C1CommitmentMismatch appears in history +/// - The mismatched index includes party 2 +/// - SignedProofFailed is emitted for the faulting party +/// - E3Failed is NOT emitted (aggregator can retry with honest subset) +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_tampered_commitment_detected() -> Result<()> { + let _guard = with_tracing("info"); + let (bus, history, _temp) = setup_c1_c5_actors().await?; + + let e3_id = E3id::new("9001", 1); + let num_parties = 3; + + // Generate real BFV keyshares and correct commitments + let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = + generate_keyshares_and_commitments(num_parties)?; + + // Build signed C1 proofs: parties 0,1 get correct commitments, party 2 gets a tampered one + let signers: Vec<_> = (0..num_parties) + .map(|_| PrivateKeySigner::random()) + .collect(); + let mut c1_proofs = Vec::with_capacity(num_parties); + for i in 0..num_parties { + let commitment = if i == 2 { + [0xFFu8; 32] // tampered commitment + } else { + correct_commitments[i] + }; + c1_proofs.push(build_signed_c1_proof(&e3_id, &commitment, &signers[i])); + } + + // Dispatch PkAggregationProofPending + let proof_request = PkAggregationProofRequest { + keyshare_bytes, + aggregated_pk_bytes, + params_preset: BfvPreset::InsecureThreshold512, + committee_n: num_parties, + committee_h: num_parties, + committee_threshold: 1, + c1_signed_proofs: c1_proofs, + }; + + bus.publish_without_context(PkAggregationProofPending { + e3_id: e3_id.clone(), + proof_request, + public_key: ArcBytes::from_bytes(&[]), + nodes: OrderedSet::from(vec![ + signers[0].address().to_string(), + signers[1].address().to_string(), + signers[2].address().to_string(), + ]), + })?; + + // Wait for the async actor chain to complete: + // PkAggregationProofPending -> ProofRequestActor -> ComputeRequest + // → Multithread -> ComputeRequestError -> ProofRequestActor -> SignedProofFailed + sleep(Duration::from_secs(5)).await; + + let events = history.send(GetEvents::new()).await?; + let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); + + // 1. ComputeRequestError should be in history (from Multithread) + assert!( + event_types.contains(&"ComputeRequestError".to_string()), + "Expected ComputeRequestError in history, got: {:?}", + event_types + ); + + // 2. SignedProofFailed should be emitted for the faulting party + let signed_proof_failed: Vec<_> = events + .iter() + .filter(|e| e.event_type() == "SignedProofFailed") + .collect(); + assert!( + !signed_proof_failed.is_empty(), + "Expected SignedProofFailed in history, got: {:?}", + event_types + ); + + // 3. ProofRequestActor should NOT emit E3Failed for C1CommitmentMismatch — + // it suppresses E3Failed so the PublicKeyAggregator can retry with honest parties. + assert!( + !event_types.contains(&"E3Failed".to_string()), + "ProofRequestActor should not emit E3Failed on C1CommitmentMismatch" + ); + + Ok(()) +} + +/// C1->C5: All commitments correct passes the check (gets past commitment verification). +/// +/// With all correct commitments, the commitment check passes and the prover +/// proceeds to actual proof generation — which fails because we use a dummy +/// ZkBackend. We verify the error is NOT a C1CommitmentMismatch. +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_correct_commitments_pass_check() -> Result<()> { + let _guard = with_tracing("info"); + let (bus, history, _temp) = setup_c1_c5_actors().await?; + + let e3_id = E3id::new("9002", 1); + let num_parties = 3; + + let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = + generate_keyshares_and_commitments(num_parties)?; + + // All C1 proofs get correct commitments + let signers: Vec<_> = (0..num_parties) + .map(|_| PrivateKeySigner::random()) + .collect(); + let c1_proofs: Vec<_> = (0..num_parties) + .map(|i| build_signed_c1_proof(&e3_id, &correct_commitments[i], &signers[i])) + .collect(); + + let proof_request = PkAggregationProofRequest { + keyshare_bytes, + aggregated_pk_bytes, + params_preset: BfvPreset::InsecureThreshold512, + committee_n: num_parties, + committee_h: num_parties, + committee_threshold: 1, + c1_signed_proofs: c1_proofs, + }; + + bus.publish_without_context(PkAggregationProofPending { + e3_id: e3_id.clone(), + proof_request, + public_key: ArcBytes::from_bytes(&[]), + nodes: OrderedSet::from( + signers + .iter() + .map(|s| s.address().to_string()) + .collect::>(), + ), + })?; + + // Wait for processing — the commitment check passes but proof generation + // fails (dummy backend, no bb binary). We just check it's NOT a mismatch. + sleep(Duration::from_secs(5)).await; + + let events = history.send(GetEvents::new()).await?; + let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); + + // Should NOT have SignedProofFailed (no commitment mismatch) + assert!( + !event_types.contains(&"SignedProofFailed".to_string()), + "SignedProofFailed should NOT appear when all commitments match, got: {:?}", + event_types + ); + + Ok(()) +} + +/// C1->C5: All commitments tampered — all indices detected. +/// +/// When every party's C1 commitment is wrong, the prover returns +/// C1CommitmentMismatch with all indices. SignedProofFailed is emitted +/// for each party. +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_all_tampered_all_detected() -> Result<()> { + let _guard = with_tracing("info"); + let (bus, history, _temp) = setup_c1_c5_actors().await?; + + let e3_id = E3id::new("9003", 1); + let num_parties = 3; + + let (keyshare_bytes, aggregated_pk_bytes, _correct_commitments, _params) = + generate_keyshares_and_commitments(num_parties)?; + + // ALL C1 proofs get wrong commitments + let signers: Vec<_> = (0..num_parties) + .map(|_| PrivateKeySigner::random()) + .collect(); + let c1_proofs: Vec<_> = (0..num_parties) + .map(|i| { + let bad_commitment = [(i + 1) as u8; 32]; // unique wrong value per party + build_signed_c1_proof(&e3_id, &bad_commitment, &signers[i]) + }) + .collect(); + + let proof_request = PkAggregationProofRequest { + keyshare_bytes, + aggregated_pk_bytes, + params_preset: BfvPreset::InsecureThreshold512, + committee_n: num_parties, + committee_h: num_parties, + committee_threshold: 1, + c1_signed_proofs: c1_proofs, + }; + + bus.publish_without_context(PkAggregationProofPending { + e3_id: e3_id.clone(), + proof_request, + public_key: ArcBytes::from_bytes(&[]), + nodes: OrderedSet::from( + signers + .iter() + .map(|s| s.address().to_string()) + .collect::>(), + ), + })?; + + sleep(Duration::from_secs(5)).await; + + let events = history.send(GetEvents::new()).await?; + let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); + + // All 3 parties should have SignedProofFailed events + let failed_count = event_types + .iter() + .filter(|t| *t == "SignedProofFailed") + .count(); + assert_eq!( + failed_count, num_parties, + "Expected {} SignedProofFailed events (one per tampered party), got {}. Events: {:?}", + num_parties, failed_count, event_types + ); + + // NOTE: In the full system, PublicKeyAggregator would receive the + // ComputeRequestError, determine 0 honest parties remain, and emit + // E3Failed. This test only covers ProofRequestActor + Multithread + // (no aggregator), so E3Failed emission from the retry-or-fail path + // is not exercised here. + + Ok(()) +} + +/// C1->C5: Fault attribution recovers the correct signer address. +/// +/// Verifies that the SignedProofFailed event contains the correct faulting +/// node address (recovered from the ECDSA signature on the tampered C1 proof). +#[actix::test] +#[serial_test::serial] +async fn test_c1_c5_fault_attribution_address_recovery() -> Result<()> { + let _guard = with_tracing("info"); + let (bus, history, _temp) = setup_c1_c5_actors().await?; + + let e3_id = E3id::new("9004", 1); + let num_parties = 2; + + let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = + generate_keyshares_and_commitments(num_parties)?; + + // Party 0 correct, party 1 tampered + let faulting_signer = PrivateKeySigner::random(); + let honest_signer = PrivateKeySigner::random(); + let expected_faulting_addr = faulting_signer.address(); + + let c1_proofs = vec![ + build_signed_c1_proof(&e3_id, &correct_commitments[0], &honest_signer), + build_signed_c1_proof(&e3_id, &[0xBB; 32], &faulting_signer), // tampered + ]; + + let proof_request = PkAggregationProofRequest { + keyshare_bytes, + aggregated_pk_bytes, + params_preset: BfvPreset::InsecureThreshold512, + committee_n: num_parties, + committee_h: num_parties, + committee_threshold: 1, + c1_signed_proofs: c1_proofs, + }; + + bus.publish_without_context(PkAggregationProofPending { + e3_id: e3_id.clone(), + proof_request, + public_key: ArcBytes::from_bytes(&[]), + nodes: OrderedSet::from(vec![ + honest_signer.address().to_string(), + faulting_signer.address().to_string(), + ]), + })?; + + sleep(Duration::from_secs(5)).await; + + let events = history.send(GetEvents::new()).await?; + + // Find SignedProofFailed and verify the recovered address + let failed_events: Vec<_> = events + .iter() + .filter_map(|e| match e.clone().into_data() { + EnclaveEventData::SignedProofFailed(spf) => Some(spf), + _ => None, + }) + .collect(); + + assert_eq!( + failed_events.len(), + 1, + "Expected exactly 1 SignedProofFailed, got {}", + failed_events.len() + ); + + assert_eq!( + failed_events[0].faulting_node, expected_faulting_addr, + "SignedProofFailed should contain the faulting party's address" + ); + + Ok(()) +} + /// Test trbfv #[actix::test] #[serial_test::serial] From cf7d0e39f47d47e98620a83fc0517dfac55011d2 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Wed, 25 Mar 2026 21:18:42 +0000 Subject: [PATCH 10/14] refactor: commitment checks before c5 --- crates/aggregator/src/publickey_aggregator.rs | 132 ------ .../src/enclave_event/compute_request/zk.rs | 13 - crates/tests/tests/integration.rs | 445 ------------------ 3 files changed, 590 deletions(-) diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 08790fe3c8..09791cb83a 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -346,22 +346,6 @@ impl PublicKeyAggregator { .map(|(_, (node, ks))| (ks.clone(), node.clone())) .unzip(); - // Collect signed C1 proofs from honest parties (submission order). - // All honest parties should have a signed C1 proof (parties without - // proofs are marked dishonest in dispatch_c1_verification). - let c1_signed_proofs_submission_order: Vec = honest_entries - .iter() - .filter_map(|(idx, _)| c1_proofs.get(*idx).and_then(|opt| opt.clone())) - .collect(); - - if c1_signed_proofs_submission_order.len() != honest_keyshares.len() { - return Err(anyhow::anyhow!( - "C1 proof count ({}) != honest keyshare count ({}) — data misalignment", - c1_signed_proofs_submission_order.len(), - honest_keyshares.len() - )); - } - if !dishonest_parties.is_empty() { warn!( "Total dishonest parties (ZK + commitment): {:?}", @@ -404,18 +388,6 @@ impl PublicKeyAggregator { let committee_h = honest_keyshares.len(); let honest_nodes_set = OrderedSet::from(honest_nodes.clone()); let keyshare_bytes: Vec<_> = honest_keyshares_set.iter().cloned().collect(); - let c1_signed_proofs: Vec = { - let ks_to_proof: std::collections::HashMap, &SignedProofPayload> = - honest_keyshares - .iter() - .zip(c1_signed_proofs_submission_order.iter()) - .map(|(ks, sp)| (ks.to_vec(), sp)) - .collect(); - keyshare_bytes - .iter() - .filter_map(|ks| ks_to_proof.get(&ks.to_vec()).map(|sp| (*sp).clone())) - .collect() - }; let pubkey = ArcBytes::from_bytes(&pubkey); info!("Publishing PkAggregationProofPending for C5 proof generation..."); @@ -429,7 +401,6 @@ impl PublicKeyAggregator { committee_n: committee_h, committee_h, committee_threshold: 0, - c1_signed_proofs, }, public_key: pubkey.clone(), nodes: honest_nodes_set.clone(), @@ -803,109 +774,6 @@ impl PublicKeyAggregator { Ok(()) } - /// Handle C5 proof error. On C1CommitmentMismatch, re-aggregate without - /// the faulting parties and re-dispatch C5. On other errors, just log. - fn handle_c5_error( - &mut self, - error: ComputeRequestError, - ec: EventContext, - ) -> Result<()> { - let ComputeRequestErrorKind::Zk(ZkError::C1CommitmentMismatch { - ref mismatched_indices, - }) = error.get_err() - else { - error!( - "PublicKeyAggregator received ComputeRequestError: {}", - error - ); - return Ok(()); - }; - - // Extract the original request from the error - let pk_req = match &error.request().request { - ComputeRequestKind::Zk(ZkRequest::PkAggregation(req)) => req.clone(), - _ => { - error!("C1CommitmentMismatch error with non-PkAggregation request"); - return Ok(()); - } - }; - - warn!( - "C1 commitment mismatch at indices {:?} — re-aggregating without faulting parties", - mismatched_indices - ); - - // Filter out mismatched parties - let remaining_keyshares: Vec = pk_req - .keyshare_bytes - .iter() - .enumerate() - .filter(|(i, _)| !mismatched_indices.contains(i)) - .map(|(_, ks)| ks.clone()) - .collect(); - let remaining_c1_proofs: Vec = pk_req - .c1_signed_proofs - .iter() - .enumerate() - .filter(|(i, _)| !mismatched_indices.contains(i)) - .map(|(_, sp)| sp.clone()) - .collect(); - - if remaining_keyshares.len() <= pk_req.committee_threshold { - error!( - "Not enough honest parties after C1 commitment mismatch filtering: {} (need > {})", - remaining_keyshares.len(), - pk_req.committee_threshold - ); - self.bus.publish( - e3_events::E3Failed { - e3_id: self.e3_id.clone(), - failed_at_stage: e3_events::E3Stage::CommitteeFinalized, - reason: e3_events::FailureReason::DKGInvalidShares, - }, - ec, - )?; - return Ok(()); - } - - // Re-aggregate the public key from remaining honest keyshares - let remaining_set = OrderedSet::from(remaining_keyshares.clone()); - let pubkey = self.fhe.get_aggregate_public_key(GetAggregatePublicKey { - keyshares: remaining_set, - })?; - - let committee_h = remaining_keyshares.len(); - let pubkey = ArcBytes::from_bytes(&pubkey); - - // Re-dispatch C5 with the filtered data - self.bus.publish( - PkAggregationProofPending { - e3_id: self.e3_id.clone(), - proof_request: PkAggregationProofRequest { - keyshare_bytes: remaining_keyshares, - aggregated_pk_bytes: pubkey.clone(), - params_preset: self.params_preset.clone(), - committee_n: committee_h, - committee_h, - committee_threshold: 0, - c1_signed_proofs: remaining_c1_proofs, - }, - public_key: pubkey, - nodes: self - .state - .get() - .map(|s| match s { - PublicKeyAggregatorState::GeneratingC5Proof { nodes, .. } => nodes, - _ => OrderedSet::new(), - }) - .unwrap_or_default(), - }, - ec, - )?; - - Ok(()) - } - pub fn handle_member_expelled( &mut self, node: &str, diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index ffdb2213b8..d20e70923f 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -61,10 +61,6 @@ pub struct PkAggregationProofRequest { pub committee_h: usize, /// Threshold (T). pub committee_threshold: usize, - /// Signed C1 proofs per party, aligned with `keyshare_bytes`. - /// The C5 prover extracts pk_commitment from each proof for cross-checking, - /// and returns mismatched indices for fault attribution. - pub c1_signed_proofs: Vec, } /// Request to generate a proof for share computation (C2a or C2b). @@ -450,10 +446,6 @@ pub enum ZkError { WitnessGenerationFailed(String), /// Invalid parameters. InvalidParams(String), - /// C1 commitment mismatch: not enough honest parties remain after filtering. - /// The mismatched indices identify which parties' C1 proofs are inconsistent - /// with their keyshare data. - C1CommitmentMismatch { mismatched_indices: Vec }, } impl std::fmt::Display for ZkError { @@ -464,11 +456,6 @@ impl std::fmt::Display for ZkError { write!(f, "Witness generation failed: {}", msg) } ZkError::InvalidParams(msg) => write!(f, "Invalid parameters: {}", msg), - ZkError::C1CommitmentMismatch { mismatched_indices } => write!( - f, - "C1 commitment mismatch at indices {:?} — not enough honest parties", - mismatched_indices - ), } } } diff --git a/crates/tests/tests/integration.rs b/crates/tests/tests/integration.rs index 1e4aebba5d..22962d6a01 100644 --- a/crates/tests/tests/integration.rs +++ b/crates/tests/tests/integration.rs @@ -562,451 +562,6 @@ impl Report { } } -// ── C1->C5 Commitment Connection Tests -// -// These tests verify the C1→C5 proof connection end-to-end through real actors: -// 1. Generate real BFV threshold keyshares -// 2. Compute correct pk_commitments from keyshare data -// 3. Build signed C1 proofs with correct or tampered commitments -// 4. Dispatch PkAggregationProofPending through the event bus -// 5. Verify the Multithread prover detects mismatches -// 6. Verify ProofRequestActor emits SignedProofFailed for faulting parties -// 7. Verify E3Failed is NOT emitted on mismatch (aggregator retries) - -/// Generate BFV threshold keyshares and compute the correct pk_commitment -/// for each party, matching what the C5 prover expects. -fn generate_keyshares_and_commitments( - num_parties: usize, -) -> Result<( - Vec, // keyshare_bytes (serialized PublicKeyShare) - ArcBytes, // aggregated_pk_bytes - Vec<[u8; 32]>, // correct pk_commitments per party - Arc, -)> { - let preset = BfvPreset::InsecureThreshold512; - let (threshold_params, _) = - build_pair_for_preset(preset).map_err(|e| anyhow::anyhow!("{e}"))?; - let crp = create_deterministic_crp_from_default_seed(&threshold_params); - - let mut keyshare_bytes = Vec::with_capacity(num_parties); - let mut pk_shares = Vec::with_capacity(num_parties); - - for _ in 0..num_parties { - let sk = SecretKey::random(&threshold_params, &mut OsRng); - let pk_share = PublicKeyShare::new(&sk, crp.clone(), &mut OsRng)?; - keyshare_bytes.push(ArcBytes::from_bytes(&pk_share.to_bytes())); - pk_shares.push(pk_share); - } - - let public_key: PublicKey = pk_shares.iter().cloned().aggregate()?; - let aggregated_pk_bytes = ArcBytes::from_bytes(&public_key.to_bytes()); - - // Compute correct pk_commitments using the same logic as PkAggInputs::compute - let bit_pk = compute_modulus_bit(&threshold_params); - let mut commitments = Vec::with_capacity(num_parties); - - for pk_share in &pk_shares { - let mut pk0 = CrtPolynomial::from_fhe_polynomial(&pk_share.p0_share()); - let mut pk1 = CrtPolynomial::from_fhe_polynomial(&crp.poly()); - pk0.reverse(); - pk0.center(threshold_params.moduli()) - .map_err(|e| anyhow::anyhow!("{e}"))?; - pk1.reverse(); - pk1.center(threshold_params.moduli()) - .map_err(|e| anyhow::anyhow!("{e}"))?; - - let commitment = compute_threshold_pk_commitment(&pk0, &pk1, bit_pk); - let (_, be_bytes) = commitment.to_bytes_be(); - let mut padded = [0u8; 32]; - let start = 32usize.saturating_sub(be_bytes.len()); - padded[start..].copy_from_slice(&be_bytes[..be_bytes.len().min(32)]); - commitments.push(padded); - } - - Ok(( - keyshare_bytes, - aggregated_pk_bytes, - commitments, - threshold_params, - )) -} - -/// Build a signed C1 proof with a specific pk_commitment in public_signals. -/// C1 (PkGeneration) has 3 output fields of 32 bytes each: -/// [sk_commitment, pk_commitment, e_sm_commitment] -fn build_signed_c1_proof( - e3_id: &E3id, - pk_commitment: &[u8; 32], - signer: &PrivateKeySigner, -) -> e3_events::SignedProofPayload { - use e3_events::{CircuitName, ProofPayload, ProofType, SignedProofPayload}; - - let mut signals = vec![0u8; 96]; - signals[0..32].copy_from_slice(&[0xAA; 32]); // sk_commitment (dummy) - signals[32..64].copy_from_slice(pk_commitment); // pk_commitment (real) - signals[64..96].copy_from_slice(&[0xCC; 32]); // e_sm_commitment (dummy) - - let proof = e3_events::Proof::new( - CircuitName::PkGeneration, - ArcBytes::from_bytes(&[0u8; 8]), - ArcBytes::from_bytes(&signals), - ); - let payload = ProofPayload { - e3_id: e3_id.clone(), - proof_type: ProofType::C1PkGeneration, - proof, - }; - SignedProofPayload::sign(payload, signer).unwrap() -} - -/// Set up the minimal actor stack for C1->C5 tests: -/// event bus + Multithread (with dummy ZkBackend) + ProofRequestActor + HistoryCollector. -async fn setup_c1_c5_actors() -> Result<( - BusHandle, - actix::Addr>, - tempfile::TempDir, // keep alive so paths stay valid -)> { - let system = EventSystem::new().with_fresh_bus(); - let bus = system.handle()?.enable("c1c5test"); - - let history = HistoryCollector::::new().start(); - bus.subscribe(EventType::All, history.clone().recipient()); - - // Dummy ZkBackend — the commitment check runs before any proof generation, - // so the bb binary and circuit files are never accessed on the mismatch path. - let temp = tempfile::tempdir()?; - let temp_path = temp.path(); - let dummy_backend = ZkBackend::new( - BBPath::Default(temp_path.join("bb")), - temp_path.join("circuits"), - temp_path.join("work"), - ); - - // Multithread actor with ZK prover - let rng = create_shared_rng_from_u64(99); - let cipher = Arc::new(Cipher::from_password("c1c5-test-key").await?); - let task_pool = Multithread::create_taskpool(2, 1); - Multithread::attach_with_zk(&bus, rng, cipher, task_pool, None, &dummy_backend); - - // ProofRequestActor handles PkAggregationProofPending → ComputeRequest dispatch, - // and ComputeRequestError → SignedProofFailed emission. - let signer = PrivateKeySigner::random(); - ProofRequestActor::setup(&bus, signer); - - // Enable effects so Multithread subscribes to ComputeRequest - bus.publish_without_context(EffectsEnabled::new())?; - sleep(Duration::from_millis(50)).await; - - Ok((bus, history, temp)) -} - -/// C1->C5: Tampered commitment is detected as C1CommitmentMismatch. -/// -/// Generates 3 real BFV keyshares, builds C1 proofs with correct commitments -/// for parties 0 and 1 but a WRONG commitment for party 2, dispatches -/// PkAggregationProofPending through the actor system, and verifies: -/// - ComputeRequestError with C1CommitmentMismatch appears in history -/// - The mismatched index includes party 2 -/// - SignedProofFailed is emitted for the faulting party -/// - E3Failed is NOT emitted (aggregator can retry with honest subset) -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_tampered_commitment_detected() -> Result<()> { - let _guard = with_tracing("info"); - let (bus, history, _temp) = setup_c1_c5_actors().await?; - - let e3_id = E3id::new("9001", 1); - let num_parties = 3; - - // Generate real BFV keyshares and correct commitments - let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = - generate_keyshares_and_commitments(num_parties)?; - - // Build signed C1 proofs: parties 0,1 get correct commitments, party 2 gets a tampered one - let signers: Vec<_> = (0..num_parties) - .map(|_| PrivateKeySigner::random()) - .collect(); - let mut c1_proofs = Vec::with_capacity(num_parties); - for i in 0..num_parties { - let commitment = if i == 2 { - [0xFFu8; 32] // tampered commitment - } else { - correct_commitments[i] - }; - c1_proofs.push(build_signed_c1_proof(&e3_id, &commitment, &signers[i])); - } - - // Dispatch PkAggregationProofPending - let proof_request = PkAggregationProofRequest { - keyshare_bytes, - aggregated_pk_bytes, - params_preset: BfvPreset::InsecureThreshold512, - committee_n: num_parties, - committee_h: num_parties, - committee_threshold: 1, - c1_signed_proofs: c1_proofs, - }; - - bus.publish_without_context(PkAggregationProofPending { - e3_id: e3_id.clone(), - proof_request, - public_key: ArcBytes::from_bytes(&[]), - nodes: OrderedSet::from(vec![ - signers[0].address().to_string(), - signers[1].address().to_string(), - signers[2].address().to_string(), - ]), - })?; - - // Wait for the async actor chain to complete: - // PkAggregationProofPending -> ProofRequestActor -> ComputeRequest - // → Multithread -> ComputeRequestError -> ProofRequestActor -> SignedProofFailed - sleep(Duration::from_secs(5)).await; - - let events = history.send(GetEvents::new()).await?; - let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); - - // 1. ComputeRequestError should be in history (from Multithread) - assert!( - event_types.contains(&"ComputeRequestError".to_string()), - "Expected ComputeRequestError in history, got: {:?}", - event_types - ); - - // 2. SignedProofFailed should be emitted for the faulting party - let signed_proof_failed: Vec<_> = events - .iter() - .filter(|e| e.event_type() == "SignedProofFailed") - .collect(); - assert!( - !signed_proof_failed.is_empty(), - "Expected SignedProofFailed in history, got: {:?}", - event_types - ); - - // 3. ProofRequestActor should NOT emit E3Failed for C1CommitmentMismatch — - // it suppresses E3Failed so the PublicKeyAggregator can retry with honest parties. - assert!( - !event_types.contains(&"E3Failed".to_string()), - "ProofRequestActor should not emit E3Failed on C1CommitmentMismatch" - ); - - Ok(()) -} - -/// C1->C5: All commitments correct passes the check (gets past commitment verification). -/// -/// With all correct commitments, the commitment check passes and the prover -/// proceeds to actual proof generation — which fails because we use a dummy -/// ZkBackend. We verify the error is NOT a C1CommitmentMismatch. -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_correct_commitments_pass_check() -> Result<()> { - let _guard = with_tracing("info"); - let (bus, history, _temp) = setup_c1_c5_actors().await?; - - let e3_id = E3id::new("9002", 1); - let num_parties = 3; - - let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = - generate_keyshares_and_commitments(num_parties)?; - - // All C1 proofs get correct commitments - let signers: Vec<_> = (0..num_parties) - .map(|_| PrivateKeySigner::random()) - .collect(); - let c1_proofs: Vec<_> = (0..num_parties) - .map(|i| build_signed_c1_proof(&e3_id, &correct_commitments[i], &signers[i])) - .collect(); - - let proof_request = PkAggregationProofRequest { - keyshare_bytes, - aggregated_pk_bytes, - params_preset: BfvPreset::InsecureThreshold512, - committee_n: num_parties, - committee_h: num_parties, - committee_threshold: 1, - c1_signed_proofs: c1_proofs, - }; - - bus.publish_without_context(PkAggregationProofPending { - e3_id: e3_id.clone(), - proof_request, - public_key: ArcBytes::from_bytes(&[]), - nodes: OrderedSet::from( - signers - .iter() - .map(|s| s.address().to_string()) - .collect::>(), - ), - })?; - - // Wait for processing — the commitment check passes but proof generation - // fails (dummy backend, no bb binary). We just check it's NOT a mismatch. - sleep(Duration::from_secs(5)).await; - - let events = history.send(GetEvents::new()).await?; - let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); - - // Should NOT have SignedProofFailed (no commitment mismatch) - assert!( - !event_types.contains(&"SignedProofFailed".to_string()), - "SignedProofFailed should NOT appear when all commitments match, got: {:?}", - event_types - ); - - Ok(()) -} - -/// C1->C5: All commitments tampered — all indices detected. -/// -/// When every party's C1 commitment is wrong, the prover returns -/// C1CommitmentMismatch with all indices. SignedProofFailed is emitted -/// for each party. -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_all_tampered_all_detected() -> Result<()> { - let _guard = with_tracing("info"); - let (bus, history, _temp) = setup_c1_c5_actors().await?; - - let e3_id = E3id::new("9003", 1); - let num_parties = 3; - - let (keyshare_bytes, aggregated_pk_bytes, _correct_commitments, _params) = - generate_keyshares_and_commitments(num_parties)?; - - // ALL C1 proofs get wrong commitments - let signers: Vec<_> = (0..num_parties) - .map(|_| PrivateKeySigner::random()) - .collect(); - let c1_proofs: Vec<_> = (0..num_parties) - .map(|i| { - let bad_commitment = [(i + 1) as u8; 32]; // unique wrong value per party - build_signed_c1_proof(&e3_id, &bad_commitment, &signers[i]) - }) - .collect(); - - let proof_request = PkAggregationProofRequest { - keyshare_bytes, - aggregated_pk_bytes, - params_preset: BfvPreset::InsecureThreshold512, - committee_n: num_parties, - committee_h: num_parties, - committee_threshold: 1, - c1_signed_proofs: c1_proofs, - }; - - bus.publish_without_context(PkAggregationProofPending { - e3_id: e3_id.clone(), - proof_request, - public_key: ArcBytes::from_bytes(&[]), - nodes: OrderedSet::from( - signers - .iter() - .map(|s| s.address().to_string()) - .collect::>(), - ), - })?; - - sleep(Duration::from_secs(5)).await; - - let events = history.send(GetEvents::new()).await?; - let event_types: Vec = events.iter().map(|e| e.event_type()).collect(); - - // All 3 parties should have SignedProofFailed events - let failed_count = event_types - .iter() - .filter(|t| *t == "SignedProofFailed") - .count(); - assert_eq!( - failed_count, num_parties, - "Expected {} SignedProofFailed events (one per tampered party), got {}. Events: {:?}", - num_parties, failed_count, event_types - ); - - // NOTE: In the full system, PublicKeyAggregator would receive the - // ComputeRequestError, determine 0 honest parties remain, and emit - // E3Failed. This test only covers ProofRequestActor + Multithread - // (no aggregator), so E3Failed emission from the retry-or-fail path - // is not exercised here. - - Ok(()) -} - -/// C1->C5: Fault attribution recovers the correct signer address. -/// -/// Verifies that the SignedProofFailed event contains the correct faulting -/// node address (recovered from the ECDSA signature on the tampered C1 proof). -#[actix::test] -#[serial_test::serial] -async fn test_c1_c5_fault_attribution_address_recovery() -> Result<()> { - let _guard = with_tracing("info"); - let (bus, history, _temp) = setup_c1_c5_actors().await?; - - let e3_id = E3id::new("9004", 1); - let num_parties = 2; - - let (keyshare_bytes, aggregated_pk_bytes, correct_commitments, _params) = - generate_keyshares_and_commitments(num_parties)?; - - // Party 0 correct, party 1 tampered - let faulting_signer = PrivateKeySigner::random(); - let honest_signer = PrivateKeySigner::random(); - let expected_faulting_addr = faulting_signer.address(); - - let c1_proofs = vec![ - build_signed_c1_proof(&e3_id, &correct_commitments[0], &honest_signer), - build_signed_c1_proof(&e3_id, &[0xBB; 32], &faulting_signer), // tampered - ]; - - let proof_request = PkAggregationProofRequest { - keyshare_bytes, - aggregated_pk_bytes, - params_preset: BfvPreset::InsecureThreshold512, - committee_n: num_parties, - committee_h: num_parties, - committee_threshold: 1, - c1_signed_proofs: c1_proofs, - }; - - bus.publish_without_context(PkAggregationProofPending { - e3_id: e3_id.clone(), - proof_request, - public_key: ArcBytes::from_bytes(&[]), - nodes: OrderedSet::from(vec![ - honest_signer.address().to_string(), - faulting_signer.address().to_string(), - ]), - })?; - - sleep(Duration::from_secs(5)).await; - - let events = history.send(GetEvents::new()).await?; - - // Find SignedProofFailed and verify the recovered address - let failed_events: Vec<_> = events - .iter() - .filter_map(|e| match e.clone().into_data() { - EnclaveEventData::SignedProofFailed(spf) => Some(spf), - _ => None, - }) - .collect(); - - assert_eq!( - failed_events.len(), - 1, - "Expected exactly 1 SignedProofFailed, got {}", - failed_events.len() - ); - - assert_eq!( - failed_events[0].faulting_node, expected_faulting_addr, - "SignedProofFailed should contain the faulting party's address" - ); - - Ok(()) -} - /// Test trbfv #[actix::test] #[serial_test::serial] From 25f55b46fbda920ba34208fdae84b1a722e1e282 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Mon, 23 Mar 2026 17:12:49 +0000 Subject: [PATCH 11/14] feat: connect c4 and c6 --- .../src/enclave_event/compute_request/zk.rs | 3 + crates/keyshare/src/threshold_keyshare.rs | 1 + .../src/actors/share_verification.rs | 131 +++++++++++++++++- 3 files changed, 132 insertions(+), 3 deletions(-) diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index d20e70923f..4624a65109 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -244,6 +244,9 @@ pub struct ThresholdShareDecryptionProofRequest { /// When false, skip wrapper proofs for recursive C6 folding (mirrors DKG `proof_aggregation_enabled`). #[serde(default = "default_proof_aggregation_enabled")] pub proof_aggregation_enabled: bool, + /// C5 output commitment — used to verify the aggregated PK matches what C5 certified. + #[serde(default)] + pub c5_pk_commitment: Option, } fn default_proof_aggregation_enabled() -> bool { diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index f1f4f14b3a..c99992332a 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -2151,6 +2151,7 @@ impl ThresholdKeyshare { d_share_bytes: d_share_poly.clone(), params_preset: threshold_preset, proof_aggregation_enabled: state.proof_aggregation_enabled, + c5_pk_commitment: None, }, }, ec.clone(), diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 38a24c9b83..db50470358 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -37,8 +37,18 @@ use e3_events::{ }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; +use e3_zk_helpers::circuits::output_layout::FIELD_BYTE_LEN; use tracing::{error, info, warn}; +/// Cached C4 return commitments for a single party. +#[derive(Debug, Clone)] +struct C4Commitments { + /// C4a: commitment to aggregated SK shares. + sk_commitment: ArcBytes, + /// C4b: commitment(s) to aggregated ESM shares, one per ESI. + e_sm_commitments: Vec, +} + /// Trait for party types whose signed proofs can be ECDSA-validated and ZK-verified. trait VerifiableParty: Clone { fn party_id(&self) -> u64; @@ -101,6 +111,9 @@ pub struct ShareVerificationActor { bus: BusHandle, /// Tracks pending verifications by correlation ID. pending: HashMap, + /// Cached C4 return commitments per party, keyed by E3 ID. + /// Populated after C4 verification passes; consumed during C6 verification. + c4_cache: HashMap>, } impl ShareVerificationActor { @@ -108,6 +121,7 @@ impl ShareVerificationActor { Self { bus: bus.clone(), pending: HashMap::new(), + c4_cache: HashMap::new(), } } @@ -132,9 +146,7 @@ impl ShareVerificationActor { ); match msg.kind { - VerificationKind::ShareProofs - | VerificationKind::ThresholdDecryptionProofs - | VerificationKind::PkGenerationProofs => { + VerificationKind::ShareProofs | VerificationKind::PkGenerationProofs => { let kind = msg.kind.clone(); self.verify_proofs( e3_id, @@ -153,7 +165,44 @@ impl ShareVerificationActor { }, ); } + VerificationKind::ThresholdDecryptionProofs => { + // C4→C6 cross-check: compare C6 expected commitments against cached C4 values. + // Mismatched parties are added to pre_dishonest before ZK dispatch. + let mut pre_dishonest = msg.pre_dishonest; + for party in &msg.share_proofs { + if let Some(mismatch_signed) = self.check_c6_party_against_c4(&e3_id, party) { + pre_dishonest.insert(party.sender_party_id); + self.emit_signed_proof_failed( + &e3_id, + &mismatch_signed, + None, + party.sender_party_id, + &ec, + ); + } + } + + self.verify_proofs( + e3_id, + VerificationKind::ThresholdDecryptionProofs, + msg.share_proofs, + pre_dishonest, + ec, + |passed, corr_id, e3| { + ComputeRequest::zk( + ZkRequest::VerifyShareProofs(VerifyShareProofsRequest { + party_proofs: passed, + }), + corr_id, + e3, + ) + }, + ); + } VerificationKind::DecryptionProofs => { + // Cache C4 return commitments for later C6 cross-check. + self.cache_c4_commitments(&e3_id, &msg.decryption_proofs); + self.verify_proofs( e3_id, VerificationKind::DecryptionProofs, @@ -374,6 +423,82 @@ impl ShareVerificationActor { } } + /// Cache C4 return commitments from decryption proofs for later C6 cross-check. + fn cache_c4_commitments( + &mut self, + e3_id: &E3id, + parties: &[PartyShareDecryptionProofsToVerify], + ) { + let cache = self.c4_cache.entry(e3_id.clone()).or_default(); + for party in parties { + let sk = party + .signed_sk_decryption_proof + .payload + .proof + .extract_output("commitment"); + let esm: Vec = party + .signed_e_sm_decryption_proofs + .iter() + .filter_map(|p| p.payload.proof.extract_output("commitment")) + .collect(); + + if let Some(sk_commitment) = sk { + cache.insert( + party.sender_party_id, + C4Commitments { + sk_commitment, + e_sm_commitments: esm, + }, + ); + } + } + info!( + "Cached C4 commitments for {} parties (E3 {})", + cache.len(), + e3_id + ); + } + + /// Check one party's C6 expected commitments against cached C4 return values. + /// Returns the first mismatched signed proof (for fault attribution), or None if OK. + fn check_c6_party_against_c4( + &self, + e3_id: &E3id, + party: &PartyProofsToVerify, + ) -> Option { + let c4_cache = self.c4_cache.get(e3_id)?; + let c4 = c4_cache.get(&party.sender_party_id)?; + let first_proof = party.signed_proofs.first()?; + + let ps = &first_proof.payload.proof.public_signals; + if ps.len() < 2 * FIELD_BYTE_LEN { + return None; + } + + let c6_sk = &ps[..FIELD_BYTE_LEN]; + let c6_esm = &ps[FIELD_BYTE_LEN..2 * FIELD_BYTE_LEN]; + + if c4.sk_commitment[..] != c6_sk[..] { + warn!( + "C4→C6 SK commitment mismatch for party {}", + party.sender_party_id + ); + return Some(first_proof.clone()); + } + + if let Some(c4_esm) = c4.e_sm_commitments.first() { + if c4_esm[..] != c6_esm[..] { + warn!( + "C4→C6 ESM commitment mismatch for party {}", + party.sender_party_id + ); + return Some(first_proof.clone()); + } + } + + None + } + /// Handle ZK verification response from multithread. fn handle_compute_response(&mut self, msg: TypedEvent) { let (msg, _ec) = msg.into_components(); From a78801f3cc7f0780a8ac8962e11cb4d99b39bd9d Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:26:01 +0000 Subject: [PATCH 12/14] chore: pr review --- crates/zk-prover/src/actors/share_verification.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index db50470358..fca31f7206 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -182,10 +182,18 @@ impl ShareVerificationActor { } } + // Filter out parties already marked dishonest by the cross-check + // to avoid wasting ZK verification on them. + let share_proofs: Vec<_> = msg + .share_proofs + .into_iter() + .filter(|p| !pre_dishonest.contains(&p.sender_party_id)) + .collect(); + self.verify_proofs( e3_id, VerificationKind::ThresholdDecryptionProofs, - msg.share_proofs, + share_proofs, pre_dishonest, ec, |passed, corr_id, e3| { From 6f694296323b1a9d95136a33f0cb3552b4c91650 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Mon, 23 Mar 2026 21:27:33 +0000 Subject: [PATCH 13/14] chore: trigger ci --- crates/events/src/enclave_event/proof.rs | 29 +++++++++++++- .../zk-helpers/src/circuits/output_layout.rs | 40 +++++++++++++++++++ .../src/actors/share_verification.rs | 12 ++---- 3 files changed, 71 insertions(+), 10 deletions(-) diff --git a/crates/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index 22300ebc62..33a35e23cd 100644 --- a/crates/events/src/enclave_event/proof.rs +++ b/crates/events/src/enclave_event/proof.rs @@ -3,8 +3,9 @@ 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, + CircuitInputLayout, CircuitOutputLayout, DKG_SHARE_DECRYPTION_OUTPUTS, PK_AGGREGATION_OUTPUTS, + PK_BFV_OUTPUTS, PK_GENERATION_OUTPUTS, SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS, + SHARE_COMPUTATION_OUTPUTS, SHARE_ENCRYPTION_INPUTS, THRESHOLD_SHARE_DECRYPTION_INPUTS, THRESHOLD_SHARE_DECRYPTION_OUTPUTS, }; use serde::{Deserialize, Serialize}; @@ -48,6 +49,17 @@ impl Proof { .extract_field(&self.public_signals, field_name) .map(ArcBytes::from_bytes) } + + /// Extract a named public input field from this proof's public signals. + /// + /// Public inputs sit at the **start** of `public_signals`, before any + /// return values. + pub fn extract_input(&self, field_name: &str) -> Option { + let layout = self.circuit.input_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. @@ -202,6 +214,19 @@ impl CircuitName { CircuitName::Fold => CircuitOutputLayout::None, } } + + /// Public input layout for this circuit (fields at the start of public_signals). + pub fn input_layout(&self) -> CircuitInputLayout { + match self { + CircuitName::ShareEncryption => CircuitInputLayout::Fixed { + fields: SHARE_ENCRYPTION_INPUTS, + }, + CircuitName::ThresholdShareDecryption => CircuitInputLayout::Fixed { + fields: THRESHOLD_SHARE_DECRYPTION_INPUTS, + }, + _ => CircuitInputLayout::None, + } + } } impl fmt::Display for CircuitName { diff --git a/crates/zk-helpers/src/circuits/output_layout.rs b/crates/zk-helpers/src/circuits/output_layout.rs index 58118dd4ee..c5822f6488 100644 --- a/crates/zk-helpers/src/circuits/output_layout.rs +++ b/crates/zk-helpers/src/circuits/output_layout.rs @@ -110,6 +110,46 @@ impl CircuitOutputLayout { } } +// ── Public input layout (fields at the HEAD of public_signals) ────────────── + +/// Describes the public input fields of a circuit. +/// Inputs sit at the **start** of `public_signals`, before any return values. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum CircuitInputLayout { + /// Fixed number of named `Field`-sized inputs, known at compile time. + Fixed { fields: &'static [OutputField] }, + /// No known public input layout. + None, +} + +impl CircuitInputLayout { + /// Extract a named public input field from raw `public_signals` bytes. + /// Inputs sit at the **start** of `public_signals`. + pub fn extract_field<'a>(&self, public_signals: &'a [u8], name: &str) -> Option<&'a [u8]> { + let fields = match self { + CircuitInputLayout::Fixed { fields } => fields, + _ => return None, + }; + let idx = fields.iter().position(|f| f.name == name)?; + let offset = idx * FIELD_BYTE_LEN; + let end = offset + FIELD_BYTE_LEN; + if public_signals.len() < end { + return None; + } + Some(&public_signals[offset..end]) + } +} + +/// C3 — Share encryption public inputs. +pub const SHARE_ENCRYPTION_INPUTS: &[OutputField] = &[ + f("expected_pk_commitment"), + f("expected_message_commitment"), +]; + +/// C6 — Threshold share decryption public inputs. +pub const THRESHOLD_SHARE_DECRYPTION_INPUTS: &[OutputField] = + &[f("expected_sk_commitment"), f("expected_e_sm_commitment")]; + // ── Per-circuit output field constants ────────────────────────────────────── const fn f(name: &'static str) -> OutputField { diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index fca31f7206..3ebaef89f7 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -37,7 +37,6 @@ use e3_events::{ }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; -use e3_zk_helpers::circuits::output_layout::FIELD_BYTE_LEN; use tracing::{error, info, warn}; /// Cached C4 return commitments for a single party. @@ -477,14 +476,11 @@ impl ShareVerificationActor { let c4_cache = self.c4_cache.get(e3_id)?; let c4 = c4_cache.get(&party.sender_party_id)?; let first_proof = party.signed_proofs.first()?; + let proof = &first_proof.payload.proof; - let ps = &first_proof.payload.proof.public_signals; - if ps.len() < 2 * FIELD_BYTE_LEN { - return None; - } - - let c6_sk = &ps[..FIELD_BYTE_LEN]; - let c6_esm = &ps[FIELD_BYTE_LEN..2 * FIELD_BYTE_LEN]; + // Extract C6 expected commitments using the input layout + let c6_sk = proof.extract_input("expected_sk_commitment")?; + let c6_esm = proof.extract_input("expected_e_sm_commitment")?; if c4.sk_commitment[..] != c6_sk[..] { warn!( From 9fa42fb7ab63ed7784bf509bc6b1c4aca66a3417 Mon Sep 17 00:00:00 2001 From: ctrlc03 <93448202+ctrlc03@users.noreply.github.com> Date: Thu, 26 Mar 2026 17:15:00 +0000 Subject: [PATCH 14/14] refactor: c4-c6 gate checks --- crates/aggregator/src/publickey_aggregator.rs | 6 +- crates/events/src/enclave_event/proof.rs | 2 +- .../proof_verification_passed.rs | 4 +- .../events/src/enclave_event/signed_proof.rs | 25 +- .../actors/commitment_consistency_checker.rs | 55 ++- .../src/actors/commitment_links/c1_to_c5.rs | 332 +++++++++--------- .../src/actors/commitment_links/c4a_to_c6.rs | 113 ++++++ .../src/actors/commitment_links/c4b_to_c6.rs | 112 ++++++ .../src/actors/commitment_links/mod.rs | 2 + crates/zk-prover/src/actors/proof_request.rs | 4 +- .../src/actors/proof_verification.rs | 1 + .../src/actors/share_verification.rs | 234 ++++++------ 12 files changed, 582 insertions(+), 308 deletions(-) create mode 100644 crates/zk-prover/src/actors/commitment_links/c4a_to_c6.rs create mode 100644 crates/zk-prover/src/actors/commitment_links/c4b_to_c6.rs diff --git a/crates/aggregator/src/publickey_aggregator.rs b/crates/aggregator/src/publickey_aggregator.rs index 09791cb83a..4b791e9944 100644 --- a/crates/aggregator/src/publickey_aggregator.rs +++ b/crates/aggregator/src/publickey_aggregator.rs @@ -12,9 +12,9 @@ use e3_events::{ prelude::*, BusHandle, ComputeResponse, ComputeResponseKind, DKGRecursiveAggregationComplete, 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, + PkAggregationProofRequest, PkAggregationProofSigned, Proof, ProofType, PublicKeyAggregated, + Seed, Sequenced, ShareVerificationComplete, ShareVerificationDispatched, SignedProofFailed, + SignedProofPayload, TypedEvent, VerificationKind, ZkResponse, }; use e3_events::{trap, EType}; use e3_fhe::{Fhe, GetAggregatePublicKey}; diff --git a/crates/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index 33a35e23cd..77135ca2b6 100644 --- a/crates/events/src/enclave_event/proof.rs +++ b/crates/events/src/enclave_event/proof.rs @@ -5,7 +5,7 @@ use e3_utils::utility_types::ArcBytes; use e3_zk_helpers::{ CircuitInputLayout, CircuitOutputLayout, DKG_SHARE_DECRYPTION_OUTPUTS, PK_AGGREGATION_OUTPUTS, PK_BFV_OUTPUTS, PK_GENERATION_OUTPUTS, SHARE_COMPUTATION_CHUNK_BATCH_OUTPUTS, - SHARE_COMPUTATION_OUTPUTS, SHARE_ENCRYPTION_INPUTS, THRESHOLD_SHARE_DECRYPTION_INPUTS, + SHARE_COMPUTATION_OUTPUTS, SHARE_ENCRYPTION_INPUTS, THRESHOLD_SHARE_DECRYPTION_INPUTS, THRESHOLD_SHARE_DECRYPTION_OUTPUTS, }; use serde::{Deserialize, Serialize}; diff --git a/crates/events/src/enclave_event/proof_verification_passed.rs b/crates/events/src/enclave_event/proof_verification_passed.rs index 15511f54e4..20f57a50b9 100644 --- a/crates/events/src/enclave_event/proof_verification_passed.rs +++ b/crates/events/src/enclave_event/proof_verification_passed.rs @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use crate::{E3id, ProofType}; +use crate::{E3id, ProofType, SignedProofPayload}; use actix::Message; use alloy::primitives::Address; use e3_utils::utility_types::ArcBytes; @@ -34,6 +34,8 @@ pub struct ProofVerificationPassed { pub data_hash: [u8; 32], /// Raw public signals from the verified proof — for commitment consistency checks. pub public_signals: ArcBytes, + /// The full signed proof — for fault evidence if a commitment mismatch is detected. + pub signed_payload: SignedProofPayload, } impl Display for ProofVerificationPassed { diff --git a/crates/events/src/enclave_event/signed_proof.rs b/crates/events/src/enclave_event/signed_proof.rs index c7c95bac94..eb050d15dd 100644 --- a/crates/events/src/enclave_event/signed_proof.rs +++ b/crates/events/src/enclave_event/signed_proof.rs @@ -39,14 +39,16 @@ pub enum ProofType { C3aSkShareEncryption = 4, /// C3b — Smudging noise share encryption proof (Proof 3b). C3bESmShareEncryption = 5, - /// C4 — DKG share decryption proof (Proof 4). - C4DkgShareDecryption = 6, + /// C4a — SK share decryption proof (Proof 4a). + C4aSkShareDecryption = 6, + /// C4b — Smudging noise share decryption proof (Proof 4b). + C4bESmShareDecryption = 7, /// C6 — Threshold share decryption proof (Proof 6). - C6ThresholdShareDecryption = 7, + C6ThresholdShareDecryption = 8, /// C7 — Decrypted shares aggregation proof (Proof 7). - C7DecryptedSharesAggregation = 8, + C7DecryptedSharesAggregation = 9, /// C5 — Public key aggregation proof (Proof 5). - C5PkAggregation = 9, + C5PkAggregation = 10, } impl ProofType { @@ -59,7 +61,9 @@ impl ProofType { ProofType::C2bESmShareComputation => vec![CircuitName::ShareComputation], ProofType::C3aSkShareEncryption => vec![CircuitName::ShareEncryption], ProofType::C3bESmShareEncryption => vec![CircuitName::ShareEncryption], - ProofType::C4DkgShareDecryption => vec![CircuitName::DkgShareDecryption], + ProofType::C4aSkShareDecryption | ProofType::C4bESmShareDecryption => { + vec![CircuitName::DkgShareDecryption] + } ProofType::C6ThresholdShareDecryption => vec![CircuitName::ThresholdShareDecryption], ProofType::C7DecryptedSharesAggregation => { vec![CircuitName::DecryptedSharesAggregation] @@ -77,7 +81,8 @@ impl ProofType { | ProofType::C2bESmShareComputation | ProofType::C3aSkShareEncryption | ProofType::C3bESmShareEncryption - | ProofType::C4DkgShareDecryption => "E3_BAD_DKG_PROOF", + | ProofType::C4aSkShareDecryption + | ProofType::C4bESmShareDecryption => "E3_BAD_DKG_PROOF", ProofType::C6ThresholdShareDecryption => "E3_BAD_DECRYPTION_PROOF", ProofType::C7DecryptedSharesAggregation => "E3_BAD_AGGREGATION_PROOF", ProofType::C5PkAggregation => "E3_BAD_PK_AGGREGATION_PROOF", @@ -391,7 +396,11 @@ mod tests { vec![CircuitName::ShareEncryption] ); assert_eq!( - ProofType::C4DkgShareDecryption.circuit_names(), + ProofType::C4aSkShareDecryption.circuit_names(), + vec![CircuitName::DkgShareDecryption] + ); + assert_eq!( + ProofType::C4bESmShareDecryption.circuit_names(), vec![CircuitName::DkgShareDecryption] ); assert_eq!( diff --git a/crates/zk-prover/src/actors/commitment_consistency_checker.rs b/crates/zk-prover/src/actors/commitment_consistency_checker.rs index 30642cb45a..e20795c9d7 100644 --- a/crates/zk-prover/src/actors/commitment_consistency_checker.rs +++ b/crates/zk-prover/src/actors/commitment_consistency_checker.rs @@ -24,19 +24,20 @@ use super::commitment_links::{CommitmentLink, LinkScope}; use actix::{Actor, Addr, Context, Handler}; use alloy::primitives::Address; use e3_events::{ - BusHandle, E3id, EnclaveEvent, EnclaveEventData, EventSubscriber, EventType, ProofType, - ProofVerificationPassed, TypedEvent, + BusHandle, E3id, EnclaveEvent, EnclaveEventData, EventPublisher, EventSubscriber, EventType, + ProofType, ProofVerificationPassed, SignedProofFailed, SignedProofPayload, TypedEvent, }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; use std::collections::HashMap; -use tracing::{info, warn}; +use tracing::{error, info, warn}; /// Cached data from a verified proof. struct VerifiedProofData { party_id: u64, address: Address, public_signals: ArcBytes, + signed_payload: SignedProofPayload, } /// Per-E3 actor that enforces cross-circuit commitment consistency. @@ -69,15 +70,39 @@ impl CommitmentConsistencyChecker { addr } + /// Emit SignedProofFailed for a party whose proof is inconsistent. + fn emit_fault( + &self, + data: &VerifiedProofData, + ec: &e3_events::EventContext, + ) { + if let Err(e) = self.bus.publish( + SignedProofFailed { + e3_id: self.e3_id.clone(), + faulting_node: data.address, + proof_type: data.signed_payload.payload.proof_type, + signed_payload: data.signed_payload.clone(), + }, + ec.clone(), + ) { + error!("Failed to publish SignedProofFailed: {e}"); + } + } + /// Evaluate all registered links given a newly arrived proof. - fn check_links(&self, new_proof_type: ProofType, new_address: Address) { + fn check_links( + &self, + new_proof_type: ProofType, + new_address: Address, + ec: &e3_events::EventContext, + ) { for link in &self.links { match link.scope() { LinkScope::SameParty => { - self.check_same_party_link(link.as_ref(), new_proof_type, new_address); + self.check_same_party_link(link.as_ref(), new_proof_type, new_address, ec); } LinkScope::CrossParty => { - self.check_cross_party_link(link.as_ref(), new_proof_type); + self.check_cross_party_link(link.as_ref(), new_proof_type, ec); } } } @@ -89,6 +114,7 @@ impl CommitmentConsistencyChecker { link: &dyn CommitmentLink, new_proof_type: ProofType, address: Address, + ec: &e3_events::EventContext, ) { let src_type = link.source_proof_type(); let tgt_type = link.target_proof_type(); @@ -114,13 +140,21 @@ impl CommitmentConsistencyChecker { src_type, tgt_type, ); + // Report the target proof as faulting — its inputs don't match + // the source's outputs. + self.emit_fault(tgt, ec); } } } /// Cross-party: check all cached sources against the target (or the new /// source against all cached targets). - fn check_cross_party_link(&self, link: &dyn CommitmentLink, new_proof_type: ProofType) { + fn check_cross_party_link( + &self, + link: &dyn CommitmentLink, + new_proof_type: ProofType, + ec: &e3_events::EventContext, + ) { let src_type = link.source_proof_type(); let tgt_type = link.target_proof_type(); @@ -164,6 +198,7 @@ impl CommitmentConsistencyChecker { tgt.address, tgt_type, ); + self.emit_fault(src, ec); } } } @@ -201,11 +236,12 @@ impl Handler> for CommitmentConsistencyCheck msg: TypedEvent, _ctx: &mut Self::Context, ) -> Self::Result { - let (data, _ec) = msg.into_components(); + let (data, ec) = msg.into_components(); let proof_type = data.proof_type; let address = data.address; let public_signals = data.public_signals; + let signed_payload = data.signed_payload; self.verified.insert( (address, proof_type), @@ -213,9 +249,10 @@ impl Handler> for CommitmentConsistencyCheck party_id: data.party_id, address, public_signals, + signed_payload, }, ); - self.check_links(proof_type, address); + self.check_links(proof_type, address, &ec); } } diff --git a/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs b/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs index db5519cdb0..90b8f903b5 100644 --- a/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs +++ b/crates/zk-prover/src/actors/commitment_links/c1_to_c5.rs @@ -1,166 +1,166 @@ -// SPDX-License-Identifier: LGPL-3.0-only -// -// This file is provided WITHOUT ANY WARRANTY; -// without even the implied warranty of MERCHANTABILITY -// or FITNESS FOR A PARTICULAR PURPOSE. - -//! C1 (PkGeneration) → C5 (PkAggregation) pk_commitment consistency link. -//! -//! ## Circuit layouts -//! -//! **C1 (PkGeneration)** outputs `(sk_commitment, pk_commitment, e_sm_commitment)`. -//! Public signals contain 3 fields (no public inputs); `pk_commitment` is at -//! field index 1 (byte offset 32..64). -//! -//! **C5 (PkAggregation)** takes `expected_threshold_pk_commitments: pub [Field; H]` -//! as public inputs and returns `pk_agg_commitment` as a single public output. -//! Public signals contain H+1 fields; the first H fields are per-party -//! `pk_commitment` values and the last field is the aggregated commitment. -//! -//! ## Check -//! -//! Each cipher node's C1 `pk_commitment` must appear somewhere in C5's -//! `expected_threshold_pk_commitments` array. - -use super::{CommitmentLink, FieldValue, LinkScope}; -use e3_events::{CircuitName, ProofType}; -use e3_zk_helpers::{CircuitOutputLayout, FIELD_BYTE_LEN}; - -/// C1 → C5 pk_commitment consistency link. -pub struct C1ToC5PkCommitmentLink; - -impl CommitmentLink for C1ToC5PkCommitmentLink { - fn name(&self) -> &'static str { - "C1→C5 pk_commitment" - } - - fn source_proof_type(&self) -> ProofType { - ProofType::C1PkGeneration - } - - fn target_proof_type(&self) -> ProofType { - ProofType::C5PkAggregation - } - - fn scope(&self) -> LinkScope { - LinkScope::CrossParty - } - - fn extract_source_values(&self, public_signals: &[u8]) -> Vec { - let layout = CircuitName::PkGeneration.output_layout(); - let Some(bytes) = layout.extract_field(public_signals, "pk_commitment") else { - return vec![]; - }; - let mut value = [0u8; FIELD_BYTE_LEN]; - value.copy_from_slice(bytes); - vec![value] - } - - fn check_consistency( - &self, - source_values: &[FieldValue], - target_public_signals: &[u8], - ) -> bool { - if source_values.is_empty() { - return true; - } - - // C5 public_signals layout: [pub inputs: pk_commitments[0..H]] [output: commitment] - // The output count comes from the circuit layout; everything before it is public inputs. - let output_count = CircuitName::PkAggregation - .output_layout() - .field_count() - .unwrap_or(1); - let total_fields = target_public_signals.len() / FIELD_BYTE_LEN; - if total_fields <= output_count { - return false; - } - let h = total_fields - output_count; - - let source_pk_commitment = &source_values[0]; - - // Check if the source pk_commitment appears in any of the H input fields - for i in 0..h { - let offset = i * FIELD_BYTE_LEN; - if target_public_signals[offset..offset + FIELD_BYTE_LEN] == *source_pk_commitment { - return true; - } - } - - false - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn make_field(val: u8) -> [u8; 32] { - let mut f = [0u8; 32]; - f[31] = val; - f - } - - #[test] - fn extract_pk_commitment_from_c1() { - let link = C1ToC5PkCommitmentLink; - let sk = make_field(1); - let pk = make_field(2); - let esm = make_field(3); - let mut signals = Vec::new(); - signals.extend_from_slice(&sk); - signals.extend_from_slice(&pk); - signals.extend_from_slice(&esm); - - let values = link.extract_source_values(&signals); - assert_eq!(values.len(), 1); - assert_eq!(values[0], pk); - } - - #[test] - fn consistency_passes_when_pk_present_in_c5() { - let link = C1ToC5PkCommitmentLink; - let pk = make_field(42); - let source_values = vec![pk]; - - // C5: [pk_comm_0, pk_comm_1(=42), pk_agg_commitment] - let mut c5_signals = Vec::new(); - c5_signals.extend_from_slice(&make_field(10)); - c5_signals.extend_from_slice(&pk); - c5_signals.extend_from_slice(&make_field(99)); - - assert!(link.check_consistency(&source_values, &c5_signals)); - } - - #[test] - fn consistency_fails_when_pk_missing_from_c5() { - let link = C1ToC5PkCommitmentLink; - let pk = make_field(42); - let source_values = vec![pk]; - - // C5: [pk_comm_0, pk_comm_1, pk_agg_commitment] — neither matches 42 - let mut c5_signals = Vec::new(); - c5_signals.extend_from_slice(&make_field(10)); - c5_signals.extend_from_slice(&make_field(20)); - c5_signals.extend_from_slice(&make_field(99)); - - assert!(!link.check_consistency(&source_values, &c5_signals)); - } - - #[test] - fn short_source_signals_treated_as_consistent() { - let link = C1ToC5PkCommitmentLink; - // Too short for C1 — extract returns empty, so vacuously consistent - assert!(link.extract_source_values(&[0u8; 60]).is_empty()); - assert!(link.check_consistency(&[], &[0u8; 31])); - } - - #[test] - fn short_target_signals_treated_as_inconsistent() { - let link = C1ToC5PkCommitmentLink; - // Source has valid data but target C5 is truncated — non-consistent - assert!(!link.check_consistency(&[make_field(1)], &[0u8; 31])); - // Only one field (< 2 required) — non-consistent - assert!(!link.check_consistency(&[make_field(1)], &make_field(1))); - } -} +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! C1 (PkGeneration) → C5 (PkAggregation) pk_commitment consistency link. +//! +//! ## Circuit layouts +//! +//! **C1 (PkGeneration)** outputs `(sk_commitment, pk_commitment, e_sm_commitment)`. +//! Public signals contain 3 fields (no public inputs); `pk_commitment` is at +//! field index 1 (byte offset 32..64). +//! +//! **C5 (PkAggregation)** takes `expected_threshold_pk_commitments: pub [Field; H]` +//! as public inputs and returns `pk_agg_commitment` as a single public output. +//! Public signals contain H+1 fields; the first H fields are per-party +//! `pk_commitment` values and the last field is the aggregated commitment. +//! +//! ## Check +//! +//! Each cipher node's C1 `pk_commitment` must appear somewhere in C5's +//! `expected_threshold_pk_commitments` array. + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::FIELD_BYTE_LEN; + +/// C1 → C5 pk_commitment consistency link. +pub struct C1ToC5PkCommitmentLink; + +impl CommitmentLink for C1ToC5PkCommitmentLink { + fn name(&self) -> &'static str { + "C1→C5 pk_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C1PkGeneration + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C5PkAggregation + } + + fn scope(&self) -> LinkScope { + LinkScope::CrossParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::PkGeneration.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "pk_commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] + } + + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + ) -> bool { + if source_values.is_empty() { + return true; + } + + // C5 public_signals layout: [pub inputs: pk_commitments[0..H]] [output: commitment] + // The output count comes from the circuit layout; everything before it is public inputs. + let output_count = CircuitName::PkAggregation + .output_layout() + .field_count() + .unwrap_or(1); + let total_fields = target_public_signals.len() / FIELD_BYTE_LEN; + if total_fields <= output_count { + return false; + } + let h = total_fields - output_count; + + let source_pk_commitment = &source_values[0]; + + // Check if the source pk_commitment appears in any of the H input fields + for i in 0..h { + let offset = i * FIELD_BYTE_LEN; + if target_public_signals[offset..offset + FIELD_BYTE_LEN] == *source_pk_commitment { + return true; + } + } + + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_field(val: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[31] = val; + f + } + + #[test] + fn extract_pk_commitment_from_c1() { + let link = C1ToC5PkCommitmentLink; + let sk = make_field(1); + let pk = make_field(2); + let esm = make_field(3); + let mut signals = Vec::new(); + signals.extend_from_slice(&sk); + signals.extend_from_slice(&pk); + signals.extend_from_slice(&esm); + + let values = link.extract_source_values(&signals); + assert_eq!(values.len(), 1); + assert_eq!(values[0], pk); + } + + #[test] + fn consistency_passes_when_pk_present_in_c5() { + let link = C1ToC5PkCommitmentLink; + let pk = make_field(42); + let source_values = vec![pk]; + + // C5: [pk_comm_0, pk_comm_1(=42), pk_agg_commitment] + let mut c5_signals = Vec::new(); + c5_signals.extend_from_slice(&make_field(10)); + c5_signals.extend_from_slice(&pk); + c5_signals.extend_from_slice(&make_field(99)); + + assert!(link.check_consistency(&source_values, &c5_signals)); + } + + #[test] + fn consistency_fails_when_pk_missing_from_c5() { + let link = C1ToC5PkCommitmentLink; + let pk = make_field(42); + let source_values = vec![pk]; + + // C5: [pk_comm_0, pk_comm_1, pk_agg_commitment] — neither matches 42 + let mut c5_signals = Vec::new(); + c5_signals.extend_from_slice(&make_field(10)); + c5_signals.extend_from_slice(&make_field(20)); + c5_signals.extend_from_slice(&make_field(99)); + + assert!(!link.check_consistency(&source_values, &c5_signals)); + } + + #[test] + fn short_source_signals_treated_as_consistent() { + let link = C1ToC5PkCommitmentLink; + // Too short for C1 — extract returns empty, so vacuously consistent + assert!(link.extract_source_values(&[0u8; 60]).is_empty()); + assert!(link.check_consistency(&[], &[0u8; 31])); + } + + #[test] + fn short_target_signals_treated_as_inconsistent() { + let link = C1ToC5PkCommitmentLink; + // Source has valid data but target C5 is truncated — non-consistent + assert!(!link.check_consistency(&[make_field(1)], &[0u8; 31])); + // Only one field (< 2 required) — non-consistent + assert!(!link.check_consistency(&[make_field(1)], &make_field(1))); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/c4a_to_c6.rs b/crates/zk-prover/src/actors/commitment_links/c4a_to_c6.rs new file mode 100644 index 0000000000..33554fe3dc --- /dev/null +++ b/crates/zk-prover/src/actors/commitment_links/c4a_to_c6.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! C4a (SK share decryption) → C6 (ThresholdShareDecryption) sk_commitment link. +//! +//! C4a outputs a single `commitment` field (the sk_commitment). +//! C6 takes `expected_sk_commitment` as a public input. + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::FIELD_BYTE_LEN; + +pub struct C4aToC6SkCommitmentLink; + +impl CommitmentLink for C4aToC6SkCommitmentLink { + fn name(&self) -> &'static str { + "C4a→C6 sk_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C4aSkShareDecryption + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C6ThresholdShareDecryption + } + + fn scope(&self) -> LinkScope { + LinkScope::SameParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::DkgShareDecryption.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] + } + + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + ) -> bool { + if source_values.is_empty() { + return true; + } + let layout = CircuitName::ThresholdShareDecryption.input_layout(); + layout + .extract_field(target_public_signals, "expected_sk_commitment") + .map_or(false, |extracted| extracted == source_values[0].as_slice()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_field(val: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[31] = val; + f + } + + #[test] + fn extract_commitment_from_c4a() { + let link = C4aToC6SkCommitmentLink; + // C4 has no public inputs, just one output: commitment + let signals = make_field(42); + let values = link.extract_source_values(&signals); + assert_eq!(values.len(), 1); + assert_eq!(values[0], make_field(42)); + } + + #[test] + fn consistency_passes_when_sk_matches() { + let link = C4aToC6SkCommitmentLink; + let sk_commitment = make_field(42); + let source_values = vec![sk_commitment]; + + // C6 inputs: [expected_sk_commitment=42, expected_e_sm_commitment=99] + let mut c6_signals = Vec::new(); + c6_signals.extend_from_slice(&sk_commitment); + c6_signals.extend_from_slice(&make_field(99)); + + assert!(link.check_consistency(&source_values, &c6_signals)); + } + + #[test] + fn consistency_fails_when_sk_differs() { + let link = C4aToC6SkCommitmentLink; + let source_values = vec![make_field(42)]; + + // C6 inputs: [expected_sk_commitment=99, expected_e_sm_commitment=99] + let mut c6_signals = Vec::new(); + c6_signals.extend_from_slice(&make_field(99)); + c6_signals.extend_from_slice(&make_field(99)); + + assert!(!link.check_consistency(&source_values, &c6_signals)); + } + + #[test] + fn short_signals() { + let link = C4aToC6SkCommitmentLink; + assert!(link.extract_source_values(&[0u8; 10]).is_empty()); + assert!(link.check_consistency(&[], &[0u8; 64])); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/c4b_to_c6.rs b/crates/zk-prover/src/actors/commitment_links/c4b_to_c6.rs new file mode 100644 index 0000000000..ca017a171a --- /dev/null +++ b/crates/zk-prover/src/actors/commitment_links/c4b_to_c6.rs @@ -0,0 +1,112 @@ +// SPDX-License-Identifier: LGPL-3.0-only +// +// This file is provided WITHOUT ANY WARRANTY; +// without even the implied warranty of MERCHANTABILITY +// or FITNESS FOR A PARTICULAR PURPOSE. + +//! C4b (ESM share decryption) → C6 (ThresholdShareDecryption) e_sm_commitment link. +//! +//! C4b outputs a single `commitment` field (the e_sm_commitment). +//! C6 takes `expected_e_sm_commitment` as a public input. + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::FIELD_BYTE_LEN; + +pub struct C4bToC6ESmCommitmentLink; + +impl CommitmentLink for C4bToC6ESmCommitmentLink { + fn name(&self) -> &'static str { + "C4b→C6 e_sm_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C4bESmShareDecryption + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C6ThresholdShareDecryption + } + + fn scope(&self) -> LinkScope { + LinkScope::SameParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::DkgShareDecryption.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] + } + + fn check_consistency( + &self, + source_values: &[FieldValue], + target_public_signals: &[u8], + ) -> bool { + if source_values.is_empty() { + return true; + } + let layout = CircuitName::ThresholdShareDecryption.input_layout(); + layout + .extract_field(target_public_signals, "expected_e_sm_commitment") + .map_or(false, |extracted| extracted == source_values[0].as_slice()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_field(val: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[31] = val; + f + } + + #[test] + fn extract_commitment_from_c4b() { + let link = C4bToC6ESmCommitmentLink; + let signals = make_field(77); + let values = link.extract_source_values(&signals); + assert_eq!(values.len(), 1); + assert_eq!(values[0], make_field(77)); + } + + #[test] + fn consistency_passes_when_esm_matches() { + let link = C4bToC6ESmCommitmentLink; + let esm_commitment = make_field(99); + let source_values = vec![esm_commitment]; + + // C6 inputs: [expected_sk_commitment=42, expected_e_sm_commitment=99] + let mut c6_signals = Vec::new(); + c6_signals.extend_from_slice(&make_field(42)); + c6_signals.extend_from_slice(&esm_commitment); + + assert!(link.check_consistency(&source_values, &c6_signals)); + } + + #[test] + fn consistency_fails_when_esm_differs() { + let link = C4bToC6ESmCommitmentLink; + let source_values = vec![make_field(99)]; + + // C6 inputs: [expected_sk_commitment=42, expected_e_sm_commitment=42] + let mut c6_signals = Vec::new(); + c6_signals.extend_from_slice(&make_field(42)); + c6_signals.extend_from_slice(&make_field(42)); + + assert!(!link.check_consistency(&source_values, &c6_signals)); + } + + #[test] + fn short_signals() { + let link = C4bToC6ESmCommitmentLink; + assert!(link.extract_source_values(&[0u8; 10]).is_empty()); + assert!(link.check_consistency(&[], &[0u8; 64])); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/mod.rs b/crates/zk-prover/src/actors/commitment_links/mod.rs index c368209c8f..b2bc618926 100644 --- a/crates/zk-prover/src/actors/commitment_links/mod.rs +++ b/crates/zk-prover/src/actors/commitment_links/mod.rs @@ -13,6 +13,8 @@ //! evaluates these links as verified proofs arrive. pub mod c1_to_c5; +pub mod c4a_to_c6; +pub mod c4b_to_c6; use e3_events::ProofType; diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index 61fc4ac134..c2f4356e98 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -712,7 +712,7 @@ impl ProofRequestActor { // Sign C4a (SK decryption proof) let Some(signed_sk) = self.sign_proof( e3_id, - ProofType::C4DkgShareDecryption, + ProofType::C4aSkShareDecryption, pending.sk_proof.expect("checked in is_complete"), ) else { error!("Failed to sign C4a SK proof — DecryptionKeyShared will not be published"); @@ -727,7 +727,7 @@ impl ProofRequestActor { .get(&idx) .expect("checked in is_complete") .clone(); - let Some(signed) = self.sign_proof(e3_id, ProofType::C4DkgShareDecryption, proof) + let Some(signed) = self.sign_proof(e3_id, ProofType::C4bESmShareDecryption, proof) else { error!( "Failed to sign C4b ESM proof [{}] — DecryptionKeyShared will not be published", diff --git a/crates/zk-prover/src/actors/proof_verification.rs b/crates/zk-prover/src/actors/proof_verification.rs index d9d1371509..a282204d46 100644 --- a/crates/zk-prover/src/actors/proof_verification.rs +++ b/crates/zk-prover/src/actors/proof_verification.rs @@ -252,6 +252,7 @@ impl Handler> for ProofVerificationActor { proof_type: ProofType::C0PkBfv, data_hash, public_signals: signed_payload.payload.proof.public_signals.clone(), + signed_payload: signed_payload.clone(), }, ec, ) { diff --git a/crates/zk-prover/src/actors/share_verification.rs b/crates/zk-prover/src/actors/share_verification.rs index 3ebaef89f7..f1cb8071c6 100644 --- a/crates/zk-prover/src/actors/share_verification.rs +++ b/crates/zk-prover/src/actors/share_verification.rs @@ -23,6 +23,9 @@ use std::collections::{BTreeSet, HashMap, HashSet}; +use super::commitment_links::c4a_to_c6::C4aToC6SkCommitmentLink; +use super::commitment_links::c4b_to_c6::C4bToC6ESmCommitmentLink; +use super::commitment_links::CommitmentLink; use actix::{Actor, Addr, Context, Handler}; use alloy::primitives::{keccak256, Address, Bytes}; use alloy::sol_types::SolValue; @@ -39,15 +42,6 @@ use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; use tracing::{error, info, warn}; -/// Cached C4 return commitments for a single party. -#[derive(Debug, Clone)] -struct C4Commitments { - /// C4a: commitment to aggregated SK shares. - sk_commitment: ArcBytes, - /// C4b: commitment(s) to aggregated ESM shares, one per ESI. - e_sm_commitments: Vec, -} - /// Trait for party types whose signed proofs can be ECDSA-validated and ZK-verified. trait VerifiableParty: Clone { fn party_id(&self) -> u64; @@ -98,6 +92,8 @@ struct PendingVerification { party_proof_hashes: HashMap>, /// Cached (proof_type, public_signals) per party — for commitment consistency checking. party_public_signals: HashMap>, + /// Cached signed proofs per party — for fault evidence in ProofVerificationPassed. + party_signed_proofs: HashMap>, } /// Actor that handles C2/C3/C4 share proof verification. @@ -110,9 +106,10 @@ pub struct ShareVerificationActor { bus: BusHandle, /// Tracks pending verifications by correlation ID. pending: HashMap, - /// Cached C4 return commitments per party, keyed by E3 ID. - /// Populated after C4 verification passes; consumed during C6 verification. - c4_cache: HashMap>, + /// Cached C4 public_signals per (e3_id, party_id) for the pre-verification + /// C4→C6 gate. Each party has two C4 proofs (C4a sk, C4b e_sm), so we store + /// a vec of (ProofType, public_signals) pairs. + c4_signals_cache: HashMap>>, } impl ShareVerificationActor { @@ -120,7 +117,7 @@ impl ShareVerificationActor { Self { bus: bus.clone(), pending: HashMap::new(), - c4_cache: HashMap::new(), + c4_signals_cache: HashMap::new(), } } @@ -165,34 +162,72 @@ impl ShareVerificationActor { ); } VerificationKind::ThresholdDecryptionProofs => { - // C4→C6 cross-check: compare C6 expected commitments against cached C4 values. - // Mismatched parties are added to pre_dishonest before ZK dispatch. + // Pre-verification C4→C6 gate: check cached C4 signals against + // C6 public inputs before dispatching ZK verification. let mut pre_dishonest = msg.pre_dishonest; - for party in &msg.share_proofs { - if let Some(mismatch_signed) = self.check_c6_party_against_c4(&e3_id, party) { - pre_dishonest.insert(party.sender_party_id); - self.emit_signed_proof_failed( - &e3_id, - &mismatch_signed, - None, - party.sender_party_id, - &ec, - ); + if let Some(c4_cache) = self.c4_signals_cache.get(&e3_id) { + let c4a_link = C4aToC6SkCommitmentLink; + let c4b_link = C4bToC6ESmCommitmentLink; + + for party in &msg.share_proofs { + let party_id = party.sender_party_id; + if pre_dishonest.contains(&party_id) { + continue; + } + let Some(c4_signals) = c4_cache.get(&party_id) else { + continue; // No cached C4 — skip gate, ZK will still verify + }; + + // Get C6 public_signals from the party's signed proof + let c6_proofs = &party.signed_proofs; + let Some(c6_proof) = c6_proofs.first() else { + continue; + }; + let c6_signals = &c6_proof.payload.proof.public_signals; + + let mut mismatch = false; + for (proof_type, c4_ps) in c4_signals { + match proof_type { + ProofType::C4aSkShareDecryption => { + let src = c4a_link.extract_source_values(c4_ps); + if !src.is_empty() + && !c4a_link.check_consistency(&src, c6_signals) + { + warn!( + "C4→C6 gate: sk_commitment mismatch for E3 {} party {} — marking dishonest", + e3_id, party_id + ); + mismatch = true; + } + } + ProofType::C4bESmShareDecryption => { + let src = c4b_link.extract_source_values(c4_ps); + if !src.is_empty() + && !c4b_link.check_consistency(&src, c6_signals) + { + warn!( + "C4→C6 gate: e_sm_commitment mismatch for E3 {} party {} — marking dishonest", + e3_id, party_id + ); + mismatch = true; + } + } + _ => {} + } + } + + if mismatch { + pre_dishonest.insert(party_id); + let addr = c6_proof.recover_address().ok(); + self.emit_signed_proof_failed(&e3_id, c6_proof, addr, party_id, &ec); + } } } - // Filter out parties already marked dishonest by the cross-check - // to avoid wasting ZK verification on them. - let share_proofs: Vec<_> = msg - .share_proofs - .into_iter() - .filter(|p| !pre_dishonest.contains(&p.sender_party_id)) - .collect(); - self.verify_proofs( e3_id, VerificationKind::ThresholdDecryptionProofs, - share_proofs, + msg.share_proofs, pre_dishonest, ec, |passed, corr_id, e3| { @@ -207,9 +242,6 @@ impl ShareVerificationActor { ); } VerificationKind::DecryptionProofs => { - // Cache C4 return commitments for later C6 cross-check. - self.cache_c4_commitments(&e3_id, &msg.decryption_proofs); - self.verify_proofs( e3_id, VerificationKind::DecryptionProofs, @@ -255,6 +287,9 @@ impl ShareVerificationActor { let mut party_addresses: HashMap = HashMap::new(); for party in &party_proofs { + if pre_dishonest.contains(&party.party_id()) { + continue; + } let proofs = party.signed_proofs(); let result = self.ecdsa_validate_signed_proofs(party.party_id(), &proofs, &e3_id_str, label); @@ -269,13 +304,11 @@ impl ShareVerificationActor { } // Store recovered addresses for passed parties - for party in &party_proofs { - if !ecdsa_dishonest.contains(&party.party_id()) { - let proofs = party.signed_proofs(); - if let Some(first_signed) = proofs.first() { - if let Ok(addr) = first_signed.recover_address() { - party_addresses.insert(party.party_id(), addr); - } + for party in &ecdsa_passed_parties { + let proofs = party.signed_proofs(); + if let Some(first_signed) = proofs.first() { + if let Ok(addr) = first_signed.recover_address() { + party_addresses.insert(party.party_id(), addr); } } } @@ -296,6 +329,7 @@ impl ShareVerificationActor { // Compute proof hashes for ECDSA-passed parties (for ProofVerificationPassed on success) let mut party_proof_hashes: HashMap> = HashMap::new(); let mut party_public_signals: HashMap> = HashMap::new(); + let mut party_signed_proofs: HashMap> = HashMap::new(); for party in &ecdsa_passed_parties { let hashes: Vec<(ProofType, [u8; 32])> = party .signed_proofs() @@ -319,8 +353,10 @@ impl ShareVerificationActor { ) }) .collect(); + let signed: Vec = party.signed_proofs().iter().cloned().collect(); party_proof_hashes.insert(party.party_id(), hashes); party_public_signals.insert(party.party_id(), signals); + party_signed_proofs.insert(party.party_id(), signed); } self.pending.insert( @@ -335,6 +371,7 @@ impl ShareVerificationActor { party_addresses, party_proof_hashes, party_public_signals, + party_signed_proofs, }, ); @@ -430,79 +467,6 @@ impl ShareVerificationActor { } } - /// Cache C4 return commitments from decryption proofs for later C6 cross-check. - fn cache_c4_commitments( - &mut self, - e3_id: &E3id, - parties: &[PartyShareDecryptionProofsToVerify], - ) { - let cache = self.c4_cache.entry(e3_id.clone()).or_default(); - for party in parties { - let sk = party - .signed_sk_decryption_proof - .payload - .proof - .extract_output("commitment"); - let esm: Vec = party - .signed_e_sm_decryption_proofs - .iter() - .filter_map(|p| p.payload.proof.extract_output("commitment")) - .collect(); - - if let Some(sk_commitment) = sk { - cache.insert( - party.sender_party_id, - C4Commitments { - sk_commitment, - e_sm_commitments: esm, - }, - ); - } - } - info!( - "Cached C4 commitments for {} parties (E3 {})", - cache.len(), - e3_id - ); - } - - /// Check one party's C6 expected commitments against cached C4 return values. - /// Returns the first mismatched signed proof (for fault attribution), or None if OK. - fn check_c6_party_against_c4( - &self, - e3_id: &E3id, - party: &PartyProofsToVerify, - ) -> Option { - let c4_cache = self.c4_cache.get(e3_id)?; - let c4 = c4_cache.get(&party.sender_party_id)?; - let first_proof = party.signed_proofs.first()?; - let proof = &first_proof.payload.proof; - - // Extract C6 expected commitments using the input layout - let c6_sk = proof.extract_input("expected_sk_commitment")?; - let c6_esm = proof.extract_input("expected_e_sm_commitment")?; - - if c4.sk_commitment[..] != c6_sk[..] { - warn!( - "C4→C6 SK commitment mismatch for party {}", - party.sender_party_id - ); - return Some(first_proof.clone()); - } - - if let Some(c4_esm) = c4.e_sm_commitments.first() { - if c4_esm[..] != c6_esm[..] { - warn!( - "C4→C6 ESM commitment mismatch for party {}", - party.sender_party_id - ); - return Some(first_proof.clone()); - } - } - - None - } - /// Handle ZK verification response from multithread. fn handle_compute_response(&mut self, msg: TypedEvent) { let (msg, _ec) = msg.into_components(); @@ -579,7 +543,7 @@ impl ShareVerificationActor { &pending.ec, ); } - } else { + } else if !all_dishonest.contains(&result.sender_party_id) { // Emit ProofVerificationPassed for each proof type from this party if let Some(hashes) = pending.party_proof_hashes.get(&result.sender_party_id) { let addr = pending @@ -588,11 +552,20 @@ impl ShareVerificationActor { .copied() .unwrap_or_default(); let signals = pending.party_public_signals.get(&result.sender_party_id); + let signed_proofs = pending.party_signed_proofs.get(&result.sender_party_id); for (i, &(proof_type, data_hash)) in hashes.iter().enumerate() { let public_signals = signals .and_then(|s| s.get(i)) .map(|(_, ps)| ps.clone()) .unwrap_or_default(); + let Some(signed_payload) = signed_proofs.and_then(|s| s.get(i)).cloned() + else { + warn!( + "Missing signed proof for party {} proof index {} — skipping ProofVerificationPassed", + result.sender_party_id, i + ); + continue; + }; if let Err(err) = self.bus.publish( ProofVerificationPassed { e3_id: pending.e3_id.clone(), @@ -601,6 +574,7 @@ impl ShareVerificationActor { proof_type, data_hash, public_signals, + signed_payload, }, pending.ec.clone(), ) { @@ -608,6 +582,30 @@ impl ShareVerificationActor { } } } + } else { + warn!( + "Party {} passed ZK but is in all_dishonest — suppressing ProofVerificationPassed", + result.sender_party_id + ); + } + } + + // Cache C4 signals for ZK-passed parties so the C4→C6 gate can use them later; + // evict the cache after C6 verification since the signals are no longer needed. + if pending.kind == VerificationKind::ThresholdDecryptionProofs { + self.c4_signals_cache.remove(&pending.e3_id); + } else if pending.kind == VerificationKind::DecryptionProofs { + let e3_cache = self + .c4_signals_cache + .entry(pending.e3_id.clone()) + .or_default(); + for result in &zk_results { + if result.all_verified && !all_dishonest.contains(&result.sender_party_id) { + if let Some(signals) = pending.party_public_signals.get(&result.sender_party_id) + { + e3_cache.insert(result.sender_party_id, signals.clone()); + } + } } }