diff --git a/crates/events/src/enclave_event/proof.rs b/crates/events/src/enclave_event/proof.rs index 817e49ab93..bee8ce63e8 100644 --- a/crates/events/src/enclave_event/proof.rs +++ b/crates/events/src/enclave_event/proof.rs @@ -280,6 +280,46 @@ mod tests { assert_eq!(&*proof.extract_output("commitment").unwrap(), &[0xFF; 32]); } + #[test] + fn extract_c6_d_commitment_after_pub_inputs() { + // C6: 2 public inputs + 1 output (`d_commitment` at tail). + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); // expected_sk_commitment + signals[32..64].copy_from_slice(&[0x22; 32]); // expected_e_sm_commitment + signals[64..96].copy_from_slice(&[0x77; 32]); // d_commitment + + let proof = make_proof(CircuitName::ThresholdShareDecryption, &signals); + assert_eq!(&*proof.extract_output("d_commitment").unwrap(), &[0x77; 32]); + } + + #[test] + fn extract_c6_public_inputs() { + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); + signals[32..64].copy_from_slice(&[0x22; 32]); + signals[64..96].copy_from_slice(&[0x77; 32]); + + let proof = make_proof(CircuitName::ThresholdShareDecryption, &signals); + assert_eq!( + &*proof.extract_input("expected_sk_commitment").unwrap(), + &[0x11; 32] + ); + assert_eq!( + &*proof.extract_input("expected_e_sm_commitment").unwrap(), + &[0x22; 32] + ); + } + + #[test] + fn extract_c7_has_no_named_public_outputs() { + // C7 (`DecryptedSharesAggregation`) has only public inputs in Noir; `output_layout` is + // `None`, so `extract_output` cannot resolve a return field. + let signals = vec![0xAB; 32 * 8]; + let proof = make_proof(CircuitName::DecryptedSharesAggregation, &signals); + assert!(proof.extract_output("d_commitment").is_none()); + assert!(proof.extract_output("commitment").is_none()); + } + #[test] fn extract_nonexistent_field() { let proof = make_proof(CircuitName::PkBfv, &[0u8; 32]); diff --git a/crates/zk-helpers/src/circuits/output_layout.rs b/crates/zk-helpers/src/circuits/output_layout.rs index c5822f6488..a9dd797a16 100644 --- a/crates/zk-helpers/src/circuits/output_layout.rs +++ b/crates/zk-helpers/src/circuits/output_layout.rs @@ -314,4 +314,72 @@ mod tests { assert_eq!(CircuitOutputLayout::None.field_count(), Some(0)); assert_eq!(CircuitOutputLayout::Dynamic.field_count(), None); } + + #[test] + fn extract_c6_d_commitment_after_pub_inputs() { + let layout = CircuitOutputLayout::Fixed { + fields: THRESHOLD_SHARE_DECRYPTION_OUTPUTS, + }; + // C6: 2 public inputs + 1 output = 96 bytes + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); + signals[32..64].copy_from_slice(&[0x22; 32]); + signals[64..96].copy_from_slice(&[0x77; 32]); + + assert_eq!( + layout.extract_field(&signals, "d_commitment").unwrap(), + &[0x77; 32] + ); + } + + #[test] + fn extract_c6_public_inputs_via_input_layout() { + let layout = CircuitInputLayout::Fixed { + fields: THRESHOLD_SHARE_DECRYPTION_INPUTS, + }; + let mut signals = vec![0u8; 96]; + signals[0..32].copy_from_slice(&[0x11; 32]); + signals[32..64].copy_from_slice(&[0x22; 32]); + signals[64..96].copy_from_slice(&[0x77; 32]); + + assert_eq!( + layout + .extract_field(&signals, "expected_sk_commitment") + .unwrap(), + &[0x11; 32] + ); + assert_eq!( + layout + .extract_field(&signals, "expected_e_sm_commitment") + .unwrap(), + &[0x22; 32] + ); + } + + #[test] + fn extract_c6_input_signals_too_short_returns_none() { + let layout = CircuitInputLayout::Fixed { + fields: THRESHOLD_SHARE_DECRYPTION_INPUTS, + }; + assert!(layout + .extract_field(&[0u8; 32], "expected_e_sm_commitment") + .is_none()); + } + + /// C7 (`DecryptedSharesAggregation`) has no `-> pub` return values; metadata uses `None`. + #[test] + fn c7_void_output_extract_field_returns_none() { + let layout = CircuitOutputLayout::None; + let signals = vec![0u8; 256]; + assert!(layout.extract_field(&signals, "d_commitment").is_none()); + } + + /// C7: `extract_all` yields no named outputs when the layout is void. + #[test] + fn c7_void_output_extract_all_returns_empty() { + let layout = CircuitOutputLayout::None; + let signals = vec![0u8; 256]; + let all = layout.extract_all(&signals).unwrap(); + assert!(all.is_empty()); + } } diff --git a/crates/zk-prover/src/actors/commitment_links/c6_to_c7.rs b/crates/zk-prover/src/actors/commitment_links/c6_to_c7.rs new file mode 100644 index 0000000000..dea59e9355 --- /dev/null +++ b/crates/zk-prover/src/actors/commitment_links/c6_to_c7.rs @@ -0,0 +1,207 @@ +// 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. + +//! C6 (ShareDecryption) → C7 (DecryptedSharesAggregation) `d_commitment` consistency link. +//! +//! ## Circuit layouts +//! +//! **C6 (ThresholdShareDecryption)** outputs `d_commitment`. +//! +//! **C7 (DecryptedSharesAggregation)** `main` has (in order) `expected_d_commitments`, +//! `party_ids`, and `message` as public inputs; there are no public return values. So +//! `public_signals` are: +//! `[d_commitments (T+1)] [party_ids (T+1)] [message coefficients (MAX_MSG_NON_ZERO_COEFFS)]`. +//! +//! We recover `T+1` as `(total_fields - MAX_MSG_NON_ZERO_COEFFS) / 2`; see **Caveat** below and +//! `circuits/bin/threshold/decrypted_shares_aggregation/src/main.nr`. +//! +//! ## Check +//! +//! The C6 `d_commitment` must appear in the `expected_d_commitments` prefix only (not in party IDs +//! or message). +//! +//! ## Caveat +//! +//! The Rust constant `MAX_MSG_NON_ZERO_COEFFS` (imported from +//! `e3_zk_helpers::circuits::threshold::decrypted_shares_aggregation`) must match the **compiled** +//! C7 Noir circuit (same value as `Polynomial` / +//! `lib::configs::default::MAX_MSG_NON_ZERO_COEFFS` for that artifact). If you ever ship multiple C7 +//! builds with different message widths, verifying or linking against the wrong artifact will make +//! `(total_fields - MAX_MSG_NON_ZERO_COEFFS) / 2` wrong and this check will mis-parse +//! `public_signals` (false negatives/positives relative to the intended circuit). + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::circuits::threshold::decrypted_shares_aggregation::MAX_MSG_NON_ZERO_COEFFS; +use e3_zk_helpers::FIELD_BYTE_LEN; + +/// C6 → C7 `d_commitment` consistency link. +pub struct C6ToC7DCommitmentLink; + +impl CommitmentLink for C6ToC7DCommitmentLink { + fn name(&self) -> &'static str { + "C6->C7 d_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C6ThresholdShareDecryption + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C7DecryptedSharesAggregation + } + + fn scope(&self) -> LinkScope { + LinkScope::CrossParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::ThresholdShareDecryption.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "d_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 false; + } + + if target_public_signals.len() % FIELD_BYTE_LEN != 0 { + return false; + } + + // C7: `circuits/.../decrypted_shares_aggregation/src/main.nr` — public inputs in order: + // (T+1) d commitments, (T+1) party IDs, `MAX_MSG_NON_ZERO_COEFFS` message coefficients. + let total_fields = target_public_signals.len() / FIELD_BYTE_LEN; + let rem = total_fields.checked_sub(MAX_MSG_NON_ZERO_COEFFS); + let Some(rem) = rem else { + return false; + }; + if rem % 2 != 0 { + return false; + } + let d_commitment_fields = rem / 2; + if d_commitment_fields == 0 { + return false; + } + + let source_d_commitment = &source_values[0]; + + for i in 0..d_commitment_fields { + let offset = i * FIELD_BYTE_LEN; + if target_public_signals[offset..offset + FIELD_BYTE_LEN] == *source_d_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 + } + + /// Builds C7 `public_signals` bytes: `(T+1)` d commitments, `(T+1)` party IDs, then message + /// coeffs (`MAX_MSG_NON_ZERO_COEFFS` fields), matching `decrypted_shares_aggregation/src/main.nr`. + fn c7_public_signals( + d_commitments: &[[u8; FIELD_BYTE_LEN]], + party_ids: &[[u8; FIELD_BYTE_LEN]], + ) -> Vec { + assert_eq!(d_commitments.len(), party_ids.len()); + let mut v = Vec::new(); + for c in d_commitments { + v.extend_from_slice(c); + } + for p in party_ids { + v.extend_from_slice(p); + } + for _ in 0..MAX_MSG_NON_ZERO_COEFFS { + v.extend_from_slice(&make_field(0)); + } + v + } + + #[test] + fn extract_d_commitment_from_c6() { + let link = C6ToC7DCommitmentLink; + let d = make_field(7); + let signals = d.to_vec(); + + let values = link.extract_source_values(&signals); + assert_eq!(values.len(), 1); + assert_eq!(values[0], d); + } + + #[test] + fn consistency_passes_when_d_present_in_c7_inputs() { + let link = C6ToC7DCommitmentLink; + let d = make_field(42); + let source_values = vec![d]; + + let d_comm = [make_field(10), d, make_field(99)]; + let party = [make_field(1), make_field(2), make_field(3)]; + let c7_signals = c7_public_signals(&d_comm, &party); + + assert!(link.check_consistency(&source_values, &c7_signals)); + } + + #[test] + fn consistency_fails_when_d_missing_from_commitments() { + let link = C6ToC7DCommitmentLink; + let d = make_field(42); + let source_values = vec![d]; + + let d_comm = [make_field(10), make_field(20), make_field(99)]; + let party = [make_field(1), make_field(2), make_field(3)]; + let c7_signals = c7_public_signals(&d_comm, &party); + + assert!(!link.check_consistency(&source_values, &c7_signals)); + } + + #[test] + fn consistency_fails_when_d_only_appears_in_message_tail() { + let link = C6ToC7DCommitmentLink; + let d = make_field(42); + let source_values = vec![d]; + + let d_comm = [make_field(10), make_field(20), make_field(99)]; + let party = [make_field(1), make_field(2), make_field(3)]; + let mut c7_signals = c7_public_signals(&d_comm, &party); + // Overwrite first message coefficient with `d` — must not count as a match. + let msg_off = 6 * FIELD_BYTE_LEN; + c7_signals[msg_off..msg_off + FIELD_BYTE_LEN].copy_from_slice(&d); + + assert!(!link.check_consistency(&source_values, &c7_signals)); + } + + #[test] + fn short_source_signals_treated_as_inconsistent() { + let link = C6ToC7DCommitmentLink; + assert!(link.extract_source_values(&[0u8; 16]).is_empty()); + assert!(!link.check_consistency(&[], &[0u8; 32])); + } + + #[test] + fn short_target_signals_treated_as_inconsistent() { + let link = C6ToC7DCommitmentLink; + assert!(!link.check_consistency(&[make_field(1)], &[0u8; 16])); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/mod.rs b/crates/zk-prover/src/actors/commitment_links/mod.rs index 5005dd6818..11487bc20e 100644 --- a/crates/zk-prover/src/actors/commitment_links/mod.rs +++ b/crates/zk-prover/src/actors/commitment_links/mod.rs @@ -15,6 +15,7 @@ pub mod c1_to_c5; pub mod c4a_to_c6; pub mod c4b_to_c6; +pub mod c6_to_c7; use e3_events::ProofType; @@ -64,5 +65,6 @@ pub fn default_links() -> Vec> { Box::new(c1_to_c5::C1ToC5PkCommitmentLink), Box::new(c4a_to_c6::C4aToC6SkCommitmentLink), Box::new(c4b_to_c6::C4bToC6ESmCommitmentLink), + Box::new(c6_to_c7::C6ToC7DCommitmentLink), ] }