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/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/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index 22300ebc62..77135ca2b6 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/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/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-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/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 38a24c9b83..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; @@ -89,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. @@ -101,6 +106,10 @@ pub struct ShareVerificationActor { bus: BusHandle, /// Tracks pending verifications by correlation ID. pending: 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 { @@ -108,6 +117,7 @@ impl ShareVerificationActor { Self { bus: bus.clone(), pending: HashMap::new(), + c4_signals_cache: HashMap::new(), } } @@ -132,9 +142,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,6 +161,86 @@ impl ShareVerificationActor { }, ); } + VerificationKind::ThresholdDecryptionProofs => { + // Pre-verification C4→C6 gate: check cached C4 signals against + // C6 public inputs before dispatching ZK verification. + let mut pre_dishonest = msg.pre_dishonest; + 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); + } + } + } + + 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 => { self.verify_proofs( e3_id, @@ -199,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); @@ -213,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); } } } @@ -240,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() @@ -263,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( @@ -279,6 +371,7 @@ impl ShareVerificationActor { party_addresses, party_proof_hashes, party_public_signals, + party_signed_proofs, }, ); @@ -450,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 @@ -459,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(), @@ -472,6 +574,7 @@ impl ShareVerificationActor { proof_type, data_hash, public_signals, + signed_payload, }, pending.ec.clone(), ) { @@ -479,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()); + } + } } }