From 99f37e28b1178d9ba69b8298f7e5cdfd36c24d03 Mon Sep 17 00:00:00 2001 From: 0xjei Date: Sat, 4 Apr 2026 17:15:52 +0200 Subject: [PATCH 1/5] fix: C4 normalises aggregated shares before hashing to match C6 C4 sums H parties' share coefficients (each in [0, q_l)), producing values in [0, H*q_l). C6's FHE library delivers sk already reduced to [0, q_l), then applies reverse + center before hashing. normalize_aggregated now: 1. Reduces each coefficient mod q_l (subtracts q up to H-1 times) 2. Reverses coefficient order (high-degree first) 3. Centers into [-(q_l-1)/2, (q_l-1)/2] The H generic parameter is threaded through execute() and the call-site in main.nr passes QIS_THRESHOLD as the moduli array. Rust-side e2e tests updated with normalize_crt_for_commitment so their expected_c4 recomputation mirrors the circuit's normalisation. Co-Authored-By: Claude Sonnet 4.6 --- circuits/bin/dkg/share_decryption/src/main.nr | 4 +- circuits/lib/src/core/dkg/share_decryption.nr | 75 +++++++++++++++++-- crates/zk-prover/tests/local_e2e_tests.rs | 45 +++++++++-- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/circuits/bin/dkg/share_decryption/src/main.nr b/circuits/bin/dkg/share_decryption/src/main.nr index 1204951a90..76b85484fb 100644 --- a/circuits/bin/dkg/share_decryption/src/main.nr +++ b/circuits/bin/dkg/share_decryption/src/main.nr @@ -5,7 +5,7 @@ // or FITNESS FOR A PARTICULAR PURPOSE. use lib::configs::default::dkg::{ - L_THRESHOLD, N, SHARE_DECRYPTION_BIT_AGG, SHARE_DECRYPTION_BIT_MSG, + L_THRESHOLD, N, QIS_THRESHOLD, SHARE_DECRYPTION_BIT_AGG, SHARE_DECRYPTION_BIT_MSG, }; use lib::configs::default::H; use lib::core::dkg::share_decryption::ShareDecryption; @@ -18,5 +18,5 @@ fn main( let share_decryption: ShareDecryption = ShareDecryption::new(expected_commitments, decrypted_shares); - share_decryption.execute() + share_decryption.execute(QIS_THRESHOLD) } diff --git a/circuits/lib/src/core/dkg/share_decryption.nr b/circuits/lib/src/core/dkg/share_decryption.nr index 41abcd96d3..2967e63159 100644 --- a/circuits/lib/src/core/dkg/share_decryption.nr +++ b/circuits/lib/src/core/dkg/share_decryption.nr @@ -73,12 +73,12 @@ impl Sha /// Aggregates all verified shares by summing coefficient-wise across honest parties. /// /// For each CRT modulus and each coefficient position, computes: - /// aggregated[mod_idx][coeff] = sum(decrypted_shares[party_idx][mod_idx][coeff]) mod q_l + /// aggregated[mod_idx][coeff] = sum(decrypted_shares[party_idx][mod_idx][coeff]) /// for party_idx in 0..H /// - /// Note: explicit modular reduction is omitted here because shares are verified to be in - /// [0, q_l) by C2 range checks, and the sum fits in the circuit field. The modular - /// reduction is enforced implicitly via the quotient polynomials in the downstream C6 circuit. + /// No modular reduction is applied here each share coefficient is in [0, q_l) by C2 range + /// checks, so the sum lies in [0, H*q_l) and fits in the circuit field. Reduction to [0, q_l) + /// is performed by normalize_aggregated before committing. /// /// This reflects the threshold key construction: sk = sum(sk_i) for i in H. /// Each ciphernode now holds a share of this sum, used during threshold decryption in P4. @@ -106,14 +106,75 @@ impl Sha /// Returns a commitment to the aggregated shares: /// - C4a: commit(agg_sk), consumed by C6 in P4 /// - C4b: commit(agg_e_sm), consumed by C6 in P4 - pub fn execute(self) -> Field { + /// + /// `moduli` must be the threshold CRT moduli `[q_0, ..., q_{L-1}]`. + /// They are used to reduce, reverse, and center the aggregated polynomials so that + /// `compute_aggregated_shares_commitment` produces the same hash as C6's + /// Rust witness (which operates on sk coefficients already reduced to `[0, q_l)` + /// and then applies `.reverse()` and `.center(&qi)` before hashing). + pub fn execute(self, moduli: [Field; L]) -> Field { // Step 1: Verify decrypted shares match expected C2 commitments. self.verify_commitments(); // Step 2: Sum shares coefficient-wise per CRT limb. let aggregated = self.compute_aggregated_shares(); - // Step 3: Publish commitment to the aggregate (C6 input). - compute_aggregated_shares_commitment::(aggregated) + // Step 3: Normalize to match C6's representation: reduce mod q, reverse, center. + // H is passed so iterative reduction can bring coefficients from [0, H*q) into [0, q). + let normalized = normalize_aggregated::(aggregated, moduli); + + // Step 4: Publish commitment to the aggregate (C6 input). + compute_aggregated_shares_commitment::(normalized) + } +} + +/// Normalize aggregated share polynomials to match C6's Rust witness representation. +/// +/// C6 applies two transformations before hashing: +/// 1. Reduce: sk coefficients from the FHE library are already in `[0, q_l)`. +/// 2. Reverse: coefficients are reordered highest-degree-first. +/// 3. Center: each coefficient `c` is mapped to `c - q` when `c > (q-1)/2`, +/// so values lie in `[-(q-1)/2, (q-1)/2]` instead of `[0, q)`. +/// +/// C4's aggregated coefficients are the sum of H party shares. Each share is in +/// `[0, q_l)` (verified by C2 range checks), so the sum lies in `[0, H*q_l)`. +/// Before centering we must reduce the sum into `[0, q_l)` by subtracting `q_l` +/// up to `H-1` times one subtraction per extra copy of `q_l` that may be present. +/// +/// `H` is a compile-time parameter bounding the number of iterations. +/// Comparison uses `u128` to safely handle moduli up to 62 bits. +fn normalize_aggregated( + aggregated: [Polynomial; L], + moduli: [Field; L], +) -> [Polynomial; L] { + let mut normalized: [Polynomial; L] = [Polynomial::new([0; N]); L]; + + for mod_idx in 0..L { + let poly = aggregated[mod_idx]; + let q = moduli[mod_idx]; + let half = (q - 1) / 2; + + let mut coeffs: [Field; N] = [0; N]; + for i in 0..N { + // Reverse: position i gets the coefficient from N-1-i. + let c = poly.coefficients[N - 1 - i]; + // Reduce mod q: sum is in [0, H*q), subtract q up to H-1 times. + let mut reduced = c; + for _ in 0..H - 1 { + if (reduced as u128) >= (q as u128) { + reduced = reduced - q; + } + } + // Center: shift to [-(q-1)/2, (q-1)/2]. + let centered = if (reduced as u128) > (half as u128) { + reduced - q + } else { + reduced + }; + coeffs[i] = centered; + } + normalized[mod_idx] = Polynomial::new(coeffs); } + + normalized } diff --git a/crates/zk-prover/tests/local_e2e_tests.rs b/crates/zk-prover/tests/local_e2e_tests.rs index aeeeb78f07..14dc28e8bc 100644 --- a/crates/zk-prover/tests/local_e2e_tests.rs +++ b/crates/zk-prover/tests/local_e2e_tests.rs @@ -25,7 +25,7 @@ use common::{ }; use e3_events::CircuitName; use e3_fhe_params::{build_pair_for_preset, BfvPreset}; -use e3_polynomial::CrtPolynomial; +use e3_polynomial::{CrtPolynomial, Polynomial}; use e3_zk_helpers::circuits::dkg::pk::circuit::PkCircuit; use e3_zk_helpers::circuits::dkg::pk::circuit::PkCircuitData; use e3_zk_helpers::circuits::threshold::pk_generation::utils::deterministic_crp_crt_polynomial; @@ -88,6 +88,33 @@ fn aggregate_dkg_decrypted_shares_to_crt( CrtPolynomial::from_bigint_vectors(limb_vecs) } +/// Normalize an aggregated DKG CrtPolynomial to match C4's output representation. +/// +/// C4 normalizes aggregated shares (which sum to `[0, H*q_l)`) before hashing: +/// 1. Reduce each coefficient mod q_l → `[0, q_l)` +/// 2. Reverse each limb (high-degree first) +/// 3. Center each limb → `[-(q_l-1)/2, (q_l-1)/2]` +/// +/// This is the same normalization C6 applies to `s` (already `[0, q_l)`) before +/// calling `compute_aggregated_shares_commitment`, so after this C4 and C6 hash +/// the same representation. +fn normalize_crt_for_commitment(crt: &CrtPolynomial, moduli: &[u64]) -> CrtPolynomial { + let normalized: Vec = crt + .limbs + .iter() + .zip(moduli.iter()) + .map(|(limb, &qi)| { + let q = num_bigint::BigInt::from(qi); + let mut l = limb.clone(); + l.reduce(&q); + l.reverse(); + l.center(&q); + l + }) + .collect(); + CrtPolynomial::new(normalized) +} + fn bigint_to_field_bytes32(b: &num_bigint::BigInt) -> [u8; 32] { let (_, bytes) = b.to_bytes_be(); let mut out = [0u8; 32]; @@ -745,14 +772,19 @@ async fn test_c4_sk_commitment_is_c6_expected_sk_input_e2e() { .extract_field(&c6_proof.public_signals, "expected_sk_commitment") .expect("C6 proof must expose expected_sk_commitment at public inputs"); + let (threshold_params, _) = build_pair_for_preset(preset).unwrap(); let dkg_out = DkgShareDecryptionCircuit::compute(preset, &dkg_sample).unwrap(); let aggregated = aggregate_dkg_decrypted_shares_to_crt(&dkg_out.inputs.decrypted_shares); - let expected_c4 = compute_aggregated_shares_commitment(&aggregated, dkg_out.bits.agg_bit); + // C4 normalizes (reduce + reverse + center) before hashing; apply the same here. + let aggregated_normalized = + normalize_crt_for_commitment(&aggregated, threshold_params.moduli()); + let expected_c4 = + compute_aggregated_shares_commitment(&aggregated_normalized, dkg_out.bits.agg_bit); let c4_from_proof = num_bigint::BigInt::from_bytes_be(num_bigint::Sign::Plus, c4_commitment_bytes); assert_eq!( c4_from_proof, expected_c4, - "C4 commitment output must match compute_aggregated_shares_commitment on aggregated DKG shares" + "C4 commitment output must match compute_aggregated_shares_commitment on normalized aggregated DKG shares" ); let c6_out = ThresholdShareDecryptionCircuit::compute(preset, &c6_sample).unwrap(); @@ -843,10 +875,13 @@ async fn test_c4_c6_sk_commitment_aligned_transcript_e2e() { let c4_big = num_bigint::BigInt::from_bytes_be(num_bigint::Sign::Plus, c4_commitment); let c6_big = num_bigint::BigInt::from_bytes_be(num_bigint::Sign::Plus, c6_expected_sk); - let expected_c4_hash = compute_aggregated_shares_commitment(&agg_sk, dkg_out.bits.agg_bit); + // C4 normalizes (reduce + reverse + center) before hashing; apply the same here. + let agg_sk_normalized = normalize_crt_for_commitment(&agg_sk, threshold_params.moduli()); + let expected_c4_hash = + compute_aggregated_shares_commitment(&agg_sk_normalized, dkg_out.bits.agg_bit); assert_eq!( c4_big, expected_c4_hash, - "C4 commitment must match hash(agg_sk) with DKG agg_bit (BIT_AGG)" + "C4 commitment must match hash(normalized agg_sk) with DKG agg_bit (BIT_AGG)" ); let c6_out = ThresholdShareDecryptionCircuit::compute(preset, &c6_sample).unwrap(); From dbd4ee32e9b2fbf1959b894d859cf58fedd92e7b Mon Sep 17 00:00:00 2001 From: 0xjei Date: Sat, 4 Apr 2026 17:29:03 +0200 Subject: [PATCH 2/5] c1 to c2 link --- agent/flow-trace/04_DKG_AND_COMPUTATION.md | 38 ++-- .../src/actors/commitment_links/c1_to_c2.rs | 213 ++++++++++++++++++ .../src/actors/commitment_links/mod.rs | 17 +- 3 files changed, 247 insertions(+), 21 deletions(-) create mode 100644 crates/zk-prover/src/actors/commitment_links/c1_to_c2.rs diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index de44f849aa..2f77cf1ef6 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -338,12 +338,16 @@ ShareVerificationActor receives ShareVerificationDispatched(kind=ShareProofs) │ ├─ CommitmentConsistencyChecker (per-E3 actor) receives this: │ │ ├─ Caches each party's (address, proof_type) → {public_signals, data_hash} │ │ ├─ Evaluates all registered CommitmentLinks: -│ │ │ C0→C3 (SourceMustExistInTargets): C3's expected_pk_commitment ∈ any C0 pk_commitment -│ │ │ C1→C5 (CrossParty): C1's pk_commitment ∈ C5 expected pk inputs -│ │ │ C2→C3 (SameParty): C3's expected_message_commitment ∈ C2's share commitments -│ │ │ C2→C4 (SourceMustExistInTargets): C2's L share commitments for recipient R exactly -│ │ │ match C4_R's expected_commitments row for sender X -│ │ │ C6→C7 (CrossParty): C6's d_commitment matches C7's expected_d_commitment +│ │ │ C0→C3 (SourceMustExistInTargets): C3's expected_pk_commitment ∈ any C0 pk_commitment +│ │ │ C1→C2a (SameParty): C1's sk_commitment == C2a's expected_secret_commitment +│ │ │ C1→C2b (SameParty): C1's e_sm_commitment == C2b's expected_secret_commitment +│ │ │ C1→C5 (CrossParty): C1's pk_commitment ∈ C5 expected pk inputs +│ │ │ C2→C3 (SameParty): C3's expected_message_commitment ∈ C2's share commitments +│ │ │ C2→C4 (SourceMustExistInTargets): C2's L share commitments for recipient R exactly +│ │ │ match C4_R's expected_commitments row for sender X +│ │ │ C4a→C6 (SameParty): C4a's commitment == C6's expected_sk_commitment +│ │ │ C4b→C6 (SameParty): C4b's commitment == C6's expected_e_sm_commitment +│ │ │ C6→C7 (CrossParty): C6's d_commitment matches C7's expected_d_commitment │ │ │ │ │ ├─ On mismatch: publishes CommitmentConsistencyViolation │ │ │ → AccusationManager initiates accusation quorum (see Part 5) @@ -458,8 +462,9 @@ ThresholdKeyshare receives AllThresholdSharesCollected │ party_proofs: [C4a + C4b proofs per party] │ } │ → ShareVerificationActor performs same 2-phase verification: -│ Phase 1: ECDSA signature recovery + consistency check -│ Phase 2: ZK proof verification via bb binary +│ Phase 1: ECDSA signature recovery +│ Phase 2: Commitment consistency check (C2→C4, C4a→C6, C4b→C6) +│ Phase 3: ZK proof verification via bb binary │ → On failure: SignedProofFailed → accusation pipeline │ → On pass: ProofVerificationPassed (cached) │ @@ -773,7 +778,9 @@ EnclaveSolReader decodes CiphertextOutputPublished event │ │ │ │ correctly │ ├──────┼────────────────────────────┼───────────────────┼──────────────────────────────┤ │ C1 │ TrBFV PK Generation │ DKG: Share Gen │ Threshold pk_share derived │ -│ │ │ │ correctly from sk │ +│ │ │ │ correctly from sk; outputs │ +│ │ │ │ sk_commitment, pk_commitment,│ +│ │ │ │ e_sm_commitment │ ├──────┼────────────────────────────┼───────────────────┼──────────────────────────────┤ │ C2a │ SK Share Computation │ DKG: Share Gen │ Shamir shares of sk computed │ │ │ │ │ correctly │ @@ -787,11 +794,16 @@ EnclaveSolReader decodes CiphertextOutputPublished event │ C3b │ ESM Share Encryption │ DKG: Share Gen │ esi_sss encrypted correctly │ │ │ │ │ under recipient's BFV key │ ├──────┼────────────────────────────┼───────────────────┼──────────────────────────────┤ -│ C4a │ SK Decryption Share (T2) │ DKG: Key Calc │ SK share decryption done │ -│ │ │ │ correctly │ +│ C4a │ SK Decryption Share (T2) │ DKG: Key Calc │ Verifies H decrypted shares │ +│ │ │ │ match C2a commitments; sums │ +│ │ │ │ and normalises (reduce mod │ +│ │ │ │ q, reverse, center) before │ +│ │ │ │ hashing; output commitment │ +│ │ │ │ consumed by C6 │ ├──────┼────────────────────────────┼───────────────────┼──────────────────────────────┤ -│ C4b │ ESM Decryption Share (T2) │ DKG: Key Calc │ ESM share decryption done │ -│ │ │ │ correctly │ +│ C4b │ ESM Decryption Share (T2) │ DKG: Key Calc │ Same as C4a for e_sm branch; │ +│ │ │ │ output commitment consumed │ +│ │ │ │ by C6 │ ├──────┼────────────────────────────┼───────────────────┼──────────────────────────────┤ │ C5 │ PK Aggregation │ Aggregation │ Aggregate PK correctly │ │ │ │ │ computed from all pk_shares │ diff --git a/crates/zk-prover/src/actors/commitment_links/c1_to_c2.rs b/crates/zk-prover/src/actors/commitment_links/c1_to_c2.rs new file mode 100644 index 0000000000..439969e9f0 --- /dev/null +++ b/crates/zk-prover/src/actors/commitment_links/c1_to_c2.rs @@ -0,0 +1,213 @@ +// 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) → C2a/C2b (ShareComputation) secret commitment links. +//! +//! ## Circuit layouts +//! +//! **C1 (PkGeneration)** outputs `(sk_commitment, pk_commitment, e_sm_commitment)`. +//! Public signals contain 3 fields (no public inputs): +//! - field 0: `sk_commitment` (byte offset 0..32) +//! - field 1: `pk_commitment` (byte offset 32..64) +//! - field 2: `e_sm_commitment` (byte offset 64..96) +//! +//! **C2a/C2b (ShareComputation inner circuit)** takes `expected_secret_commitment` +//! as its first public input (head, bytes 0..32). The remaining fields are +//! per-party-per-modulus share commitment outputs from `commit_to_party_shares`. +//! +//! ## Checks +//! +//! - **C1→C2a**: `C1.sk_commitment` must equal `C2a.expected_secret_commitment`. +//! Prevents a party from Shamir-splitting a different sk than the one committed +//! to in their C1 (TrBFV pk_generation) proof. +//! +//! - **C1→C2b**: `C1.e_sm_commitment` must equal `C2b.expected_secret_commitment`. +//! Prevents a party from Shamir-splitting a different e_sm than the one committed +//! to in their C1 proof. + +use super::{CommitmentLink, FieldValue, LinkScope}; +use e3_events::{CircuitName, ProofType}; +use e3_zk_helpers::FIELD_BYTE_LEN; + +/// C1 → C2a: `sk_commitment` from PkGeneration must match C2a's `expected_secret_commitment`. +pub struct C1ToC2aSkCommitmentLink; + +impl CommitmentLink for C1ToC2aSkCommitmentLink { + fn name(&self) -> &'static str { + "C1->C2a sk_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C1PkGeneration + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C2aSkShareComputation + } + + fn scope(&self) -> LinkScope { + LinkScope::SameParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::PkGeneration.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "sk_commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] + } + + fn check_signals(&self, source_values: &[FieldValue], target_public_signals: &[u8]) -> bool { + if source_values.is_empty() || target_public_signals.len() < FIELD_BYTE_LEN { + return false; + } + target_public_signals[..FIELD_BYTE_LEN] == source_values[0] + } +} + +/// C1 → C2b: `e_sm_commitment` from PkGeneration must match C2b's `expected_secret_commitment`. +pub struct C1ToC2bESmCommitmentLink; + +impl CommitmentLink for C1ToC2bESmCommitmentLink { + fn name(&self) -> &'static str { + "C1->C2b e_sm_commitment" + } + + fn source_proof_type(&self) -> ProofType { + ProofType::C1PkGeneration + } + + fn target_proof_type(&self) -> ProofType { + ProofType::C2bESmShareComputation + } + + fn scope(&self) -> LinkScope { + LinkScope::SameParty + } + + fn extract_source_values(&self, public_signals: &[u8]) -> Vec { + let layout = CircuitName::PkGeneration.output_layout(); + let Some(bytes) = layout.extract_field(public_signals, "e_sm_commitment") else { + return vec![]; + }; + let mut value = [0u8; FIELD_BYTE_LEN]; + value.copy_from_slice(bytes); + vec![value] + } + + fn check_signals(&self, source_values: &[FieldValue], target_public_signals: &[u8]) -> bool { + if source_values.is_empty() || target_public_signals.len() < FIELD_BYTE_LEN { + return false; + } + target_public_signals[..FIELD_BYTE_LEN] == source_values[0] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_field(val: u8) -> [u8; 32] { + let mut f = [0u8; 32]; + f[31] = val; + f + } + + /// C1 public signals: [sk_commitment, pk_commitment, e_sm_commitment] + fn c1_signals(sk: [u8; 32], pk: [u8; 32], esm: [u8; 32]) -> Vec { + let mut v = Vec::with_capacity(96); + v.extend_from_slice(&sk); + v.extend_from_slice(&pk); + v.extend_from_slice(&esm); + v + } + + /// C2 inner public signals: [expected_secret_commitment] + share commitments... + fn c2_signals(secret_commitment: [u8; 32], share_commitments: &[[u8; 32]]) -> Vec { + let mut v = Vec::with_capacity(32 + share_commitments.len() * 32); + v.extend_from_slice(&secret_commitment); + for c in share_commitments { + v.extend_from_slice(c); + } + v + } + + // ── C1→C2a ────────────────────────────────────────────────────────────── + + #[test] + fn extract_sk_commitment_from_c1() { + let link = C1ToC2aSkCommitmentLink; + let sk = make_field(1); + let values = link.extract_source_values(&c1_signals(sk, make_field(2), make_field(3))); + assert_eq!(values.len(), 1); + assert_eq!(values[0], sk); + } + + #[test] + fn c2a_consistency_passes_when_sk_matches() { + let link = C1ToC2aSkCommitmentLink; + let sk = make_field(42); + let c2 = c2_signals(sk, &[make_field(10), make_field(11)]); + assert!(link.check_signals(&[sk], &c2)); + } + + #[test] + fn c2a_consistency_fails_when_sk_differs() { + let link = C1ToC2aSkCommitmentLink; + let c2 = c2_signals(make_field(99), &[make_field(10)]); + assert!(!link.check_signals(&[make_field(42)], &c2)); + } + + #[test] + fn c2a_short_or_empty_signals() { + let link = C1ToC2aSkCommitmentLink; + // C1 too short to extract sk_commitment + assert!(link.extract_source_values(&[0u8; 60]).is_empty()); + // Empty source values + assert!(!link.check_signals(&[], &c2_signals(make_field(1), &[]))); + // C2 target too short + assert!(!link.check_signals(&[make_field(1)], &[0u8; 10])); + } + + // ── C1→C2b ────────────────────────────────────────────────────────────── + + #[test] + fn extract_esm_commitment_from_c1() { + let link = C1ToC2bESmCommitmentLink; + let esm = make_field(7); + let values = link.extract_source_values(&c1_signals(make_field(1), make_field(2), esm)); + assert_eq!(values.len(), 1); + assert_eq!(values[0], esm); + } + + #[test] + fn c2b_consistency_passes_when_esm_matches() { + let link = C1ToC2bESmCommitmentLink; + let esm = make_field(77); + let c2 = c2_signals(esm, &[make_field(10), make_field(11)]); + assert!(link.check_signals(&[esm], &c2)); + } + + #[test] + fn c2b_consistency_fails_when_esm_differs() { + let link = C1ToC2bESmCommitmentLink; + let c2 = c2_signals(make_field(99), &[make_field(10)]); + assert!(!link.check_signals(&[make_field(77)], &c2)); + } + + #[test] + fn c2b_short_or_empty_signals() { + let link = C1ToC2bESmCommitmentLink; + // C1 too short to extract e_sm_commitment (need 96 bytes, providing 64) + assert!(link.extract_source_values(&[0u8; 64]).is_empty()); + // Empty source values + assert!(!link.check_signals(&[], &c2_signals(make_field(1), &[]))); + // C2 target too short + assert!(!link.check_signals(&[make_field(1)], &[0u8; 10])); + } +} diff --git a/crates/zk-prover/src/actors/commitment_links/mod.rs b/crates/zk-prover/src/actors/commitment_links/mod.rs index abc9a99ec9..723f6a41b4 100644 --- a/crates/zk-prover/src/actors/commitment_links/mod.rs +++ b/crates/zk-prover/src/actors/commitment_links/mod.rs @@ -13,6 +13,7 @@ //! evaluates these links as verified proofs arrive. pub mod c0_to_c3; +pub mod c1_to_c2; pub mod c1_to_c5; pub mod c2_to_c3; pub mod c2_to_c4; @@ -94,12 +95,10 @@ pub trait CommitmentLink: Send + Sync { /// Returns the default set of commitment links to register. /// -/// C4→C6 links are disabled: the C4 Noir circuit and C6 Rust code compute -/// `compute_aggregated_shares_commitment` from incompatible representations -/// of the same aggregated polynomial (unreduced/uncentered/non-reversed vs -/// reduced/centered/reversed, plus different BIT parameters). See test -/// `c4_c6_commitment_mismatch_due_to_modular_reduction` in `c4a_to_c6.rs`. -/// Re-enable after aligning the commitment computation (circuit change needed). +/// C4→C6 links verify that C4's aggregated share commitment matches C6's +/// `expected_sk_commitment` / `expected_e_sm_commitment`. The C4 circuit +/// normalizes its aggregated polynomial (reverse + center per CRT modulus) +/// before hashing, matching the representation C6's Rust witness computes. /// /// C3→C4 links are replaced by C2→C4: C2 directly outputs share commitments /// that C4 consumes as `expected_commitments`. Since C2→C3 already ensures @@ -110,13 +109,15 @@ pub fn default_links() -> Vec> { vec![ Box::new(c0_to_c3::C3aToC0PkCommitmentLink), Box::new(c0_to_c3::C3bToC0PkCommitmentLink), + Box::new(c1_to_c2::C1ToC2aSkCommitmentLink), + Box::new(c1_to_c2::C1ToC2bESmCommitmentLink), Box::new(c1_to_c5::C1ToC5PkCommitmentLink), Box::new(c2_to_c3::C3aToC2aShareEncryptionLink), Box::new(c2_to_c3::C3bToC2bShareEncryptionLink), Box::new(c2_to_c4::C2aToC4aShareCommitmentLink { l }), Box::new(c2_to_c4::C2bToC4bShareCommitmentLink { l }), Box::new(c6_to_c7::C6ToC7DCommitmentLink), - // Box::new(c4a_to_c6::C4aToC6SkCommitmentLink), - // Box::new(c4b_to_c6::C4bToC6ESmCommitmentLink), + Box::new(c4a_to_c6::C4aToC6SkCommitmentLink), + Box::new(c4b_to_c6::C4bToC6ESmCommitmentLink), ] } From 7a9660cb4ca0157923972d2c6e3b536abd78f1fa Mon Sep 17 00:00:00 2001 From: 0xjei Date: Sat, 4 Apr 2026 21:38:37 +0200 Subject: [PATCH 3/5] update centering and reverse to match the commitments --- circuits/lib/src/configs/insecure/dkg.nr | 2 +- .../lib/src/core/dkg/share_computation.nr | 38 ++++++++++++++++--- .../dkg/share_computation/computation.rs | 34 +++++++++++++++-- .../dkg/share_decryption/computation.rs | 11 +++--- 4 files changed, 69 insertions(+), 16 deletions(-) diff --git a/circuits/lib/src/configs/insecure/dkg.nr b/circuits/lib/src/configs/insecure/dkg.nr index 043392204b..87dada5bff 100644 --- a/circuits/lib/src/configs/insecure/dkg.nr +++ b/circuits/lib/src/configs/insecure/dkg.nr @@ -52,7 +52,7 @@ share_computation_e_sm (CIRCUIT 2b) ------------------------------------- ************************************/ -pub global SHARE_COMPUTATION_E_SM_BIT_SECRET: u32 = 24; +pub global SHARE_COMPUTATION_E_SM_BIT_SECRET: u32 = 28; pub global SHARE_COMPUTATION_E_SM_CONFIGS: ShareComputationConfigs = ShareComputationConfigs::new(QIS_THRESHOLD); diff --git a/circuits/lib/src/core/dkg/share_computation.nr b/circuits/lib/src/core/dkg/share_computation.nr index e83fd752e7..f9def515e5 100644 --- a/circuits/lib/src/core/dkg/share_computation.nr +++ b/circuits/lib/src/core/dkg/share_computation.nr @@ -108,10 +108,19 @@ impl(self.y) } - /// Verifies that secret hashes to expected_secret_commitment + /// Verifies that secret hashes to expected_secret_commitment. + /// + /// Reverses `sk_secret` coefficients before hashing to match C1 (PkGeneration)'s + /// convention, which passes sk in highest-degree-first order. This ensures + /// `C1.sk_commitment == C2a.expected_secret_commitment` for the same sk. fn verify_secret_commitment(self) { + let mut reversed_coeffs: [Field; N] = [0; N]; + for i in 0..N { + reversed_coeffs[i] = self.sk_secret.coefficients[N - 1 - i]; + } + let reversed_sk = Polynomial::new(reversed_coeffs); assert( - compute_share_computation_sk_commitment::(self.sk_secret) + compute_share_computation_sk_commitment::(reversed_sk) == self.expected_secret_commitment, "SK commitment mismatch", ); @@ -169,12 +178,29 @@ impl(self.y) } - /// Verifies that secret hashes to expected_secret_commitment - /// The commitment is computed over all L RNS polynomials (matching - /// multiple_polynomial_payload's behavior which hashes all L modulus polynomials) + /// Verifies that secret hashes to expected_secret_commitment. + /// + /// Reverses and centers each `e_sm_secret` limb before hashing to match C1 (PkGeneration)'s + /// convention: C1 passes e_sm reversed+centered (highest-degree-first, coefficients in + /// `[-(q-1)/2, (q-1)/2]`). `e_sm_secret` here is in `[0, q)`, so we apply the same + /// reverse+center transform before hashing. fn verify_secret_commitment(self) { + let mut normalized: [Polynomial; L] = [Polynomial::new([0; N]); L]; + for j in 0..L { + let q = self.configs.qis[j]; + let half = (q - 1) / 2; + let mut coeffs: [Field; N] = [0; N]; + for i in 0..N { + // Reverse: position i gets the coefficient from N-1-i. + let c = self.e_sm_secret[j].coefficients[N - 1 - i]; + // Center: shift [0, q) to [-(q-1)/2, (q-1)/2]. + let centered = if (c as u128) > (half as u128) { c - q } else { c }; + coeffs[i] = centered; + } + normalized[j] = Polynomial::new(coeffs); + } assert( - compute_share_computation_e_sm_commitment::(self.e_sm_secret) + compute_share_computation_e_sm_commitment::(normalized) == self.expected_secret_commitment, "ESM commitment mismatch", ); diff --git a/crates/zk-helpers/src/circuits/dkg/share_computation/computation.rs b/crates/zk-helpers/src/circuits/dkg/share_computation/computation.rs index feb90ac85d..b80e5bb3a2 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_computation/computation.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_computation/computation.rs @@ -150,7 +150,7 @@ impl Computation for Bounds { type Data = ShareComputationCircuitData; type Error = CircuitsErrors; - fn compute(preset: Self::Preset, data: &Self::Data) -> Result { + fn compute(preset: Self::Preset, _data: &Self::Data) -> Result { let (threshold_params, _) = build_pair_for_preset(preset).map_err(|e| CircuitsErrors::Sample(e.to_string()))?; let defaults = preset @@ -159,9 +159,12 @@ impl Computation for Bounds { let num_ciphertexts = defaults.z; let lambda = defaults.lambda; + // Use search_defaults.n (same as C1/PkGeneration) so the smudging bound and + // resulting bit width match C1's PK_GENERATION_BIT_E_SM. This ensures + // C1.e_sm_commitment == C2b.expected_secret_commitment for the same e_sm. let e_sm_config = SmudgingBoundCalculatorConfig::new( threshold_params, - data.n_parties as usize, + defaults.n as usize, num_ciphertexts as usize, lambda as usize, ); @@ -219,12 +222,35 @@ impl Computation for Inputs { let bounds = Bounds::compute(preset, data)?; let bits = Bits::compute(preset, &bounds)?; + // Reverse+center before committing to match C1 (PkGeneration)'s convention: + // C1 applies reverse then center to sk/e_sm before computing the commitment. + // For SK the values are already centered ({-1,0,1}) so centering is a no-op. + // For e_sm values are in [0,q) here, so we must center to [-(q-1)/2, (q-1)/2]. let expected_secret_commitment = match data.dkg_input_type { DkgInputType::SecretKey => { - compute_share_computation_sk_commitment(secret_crt.limb(0), bits.bit_sk_secret) + let mut reversed = secret_crt.limb(0).clone(); + reversed.reverse(); + compute_share_computation_sk_commitment(&reversed, bits.bit_sk_secret) } DkgInputType::SmudgingNoise => { - compute_share_computation_e_sm_commitment(&secret_crt, bits.bit_e_sm_secret) + let centered_reversed_crt = e3_polynomial::CrtPolynomial::new( + secret_crt + .limbs + .iter() + .zip(moduli.iter()) + .map(|(l, &qi)| { + let q = num_bigint::BigInt::from(qi); + let mut r = l.clone(); + r.reverse(); + r.center(&q); + r + }) + .collect(), + ); + compute_share_computation_e_sm_commitment( + ¢ered_reversed_crt, + bits.bit_e_sm_secret, + ) } }; diff --git a/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs b/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs index 76dffe7f21..88c939b8ed 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs @@ -330,12 +330,13 @@ mod tests { for (party_idx, party_cts) in sample.honest_ciphertexts.iter().enumerate() { for mod_idx in 0..threshold_l { let decrypted_pt = sample.secret_key.try_decrypt(&party_cts[mod_idx]).unwrap(); - let mut share_coeffs = decrypted_pt.value.deref().to_vec(); - // Reverse to match Inputs::compute, which reverses before committing - // (matching C3's message witness construction). - share_coeffs.reverse(); + let share_coeffs = decrypted_pt.value.deref().to_vec(); + // Reverse to match Inputs::compute, which reverses before committing to align + // with C2's commit_to_party_shares (highest-degree-first convention). + let mut reversed = share_coeffs.clone(); + reversed.reverse(); let direct_commitment = compute_share_encryption_commitment_from_message( - &Polynomial::from_u64_vector(share_coeffs), + &Polynomial::from_u64_vector(reversed), msg_bit, ); assert_eq!( From 585282d9d201906a168d074f9217c4fba62d259f Mon Sep 17 00:00:00 2001 From: 0xjei Date: Sat, 4 Apr 2026 21:40:15 +0200 Subject: [PATCH 4/5] fmt circuits --- circuits/lib/src/core/dkg/share_computation.nr | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/circuits/lib/src/core/dkg/share_computation.nr b/circuits/lib/src/core/dkg/share_computation.nr index f9def515e5..c91a2a603c 100644 --- a/circuits/lib/src/core/dkg/share_computation.nr +++ b/circuits/lib/src/core/dkg/share_computation.nr @@ -194,7 +194,11 @@ impl (half as u128) { c - q } else { c }; + let centered = if (c as u128) > (half as u128) { + c - q + } else { + c + }; coeffs[i] = centered; } normalized[j] = Polynomial::new(coeffs); From 69168a651473354911d1c7e5a5217232077ce330 Mon Sep 17 00:00:00 2001 From: Cedoor Date: Tue, 7 Apr 2026 15:20:12 +0200 Subject: [PATCH 5/5] perf(circuits): use ModU128::reduce_mod in C4 normalize_aggregated - Replace iterative mod reduction with ModU128::new(q).reduce_mod(c) - Drop unused H generic from normalize_aggregated; update docs Made-with: Cursor --- circuits/lib/src/core/dkg/share_decryption.nr | 26 +++++-------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/circuits/lib/src/core/dkg/share_decryption.nr b/circuits/lib/src/core/dkg/share_decryption.nr index 2967e63159..fe278764e8 100644 --- a/circuits/lib/src/core/dkg/share_decryption.nr +++ b/circuits/lib/src/core/dkg/share_decryption.nr @@ -7,6 +7,7 @@ use crate::math::commitments::{ compute_aggregated_shares_commitment, compute_share_encryption_commitment_from_message, }; +use crate::math::modulo::U128::ModU128; use crate::math::polynomial::Polynomial; /// DKG share decryption and aggregation (C4a / C4b). @@ -120,8 +121,7 @@ impl Sha let aggregated = self.compute_aggregated_shares(); // Step 3: Normalize to match C6's representation: reduce mod q, reverse, center. - // H is passed so iterative reduction can bring coefficients from [0, H*q) into [0, q). - let normalized = normalize_aggregated::(aggregated, moduli); + let normalized = normalize_aggregated::(aggregated, moduli); // Step 4: Publish commitment to the aggregate (C6 input). compute_aggregated_shares_commitment::(normalized) @@ -130,20 +130,14 @@ impl Sha /// Normalize aggregated share polynomials to match C6's Rust witness representation. /// -/// C6 applies two transformations before hashing: -/// 1. Reduce: sk coefficients from the FHE library are already in `[0, q_l)`. +/// C6 applies three transformations before hashing: +/// 1. Reduce: coefficients to `[0, q_l)` (FHE `sk` is already reduced; C4 sums lie in `[0, H*q_l)`). /// 2. Reverse: coefficients are reordered highest-degree-first. /// 3. Center: each coefficient `c` is mapped to `c - q` when `c > (q-1)/2`, /// so values lie in `[-(q-1)/2, (q-1)/2]` instead of `[0, q)`. /// -/// C4's aggregated coefficients are the sum of H party shares. Each share is in -/// `[0, q_l)` (verified by C2 range checks), so the sum lies in `[0, H*q_l)`. -/// Before centering we must reduce the sum into `[0, q_l)` by subtracting `q_l` -/// up to `H-1` times one subtraction per extra copy of `q_l` that may be present. -/// -/// `H` is a compile-time parameter bounding the number of iterations. -/// Comparison uses `u128` to safely handle moduli up to 62 bits. -fn normalize_aggregated( +/// Reduction uses [`ModU128::reduce_mod`] (unconstrained quotient/remainder + in-circuit checks). +fn normalize_aggregated( aggregated: [Polynomial; L], moduli: [Field; L], ) -> [Polynomial; L] { @@ -158,13 +152,7 @@ fn normalize_aggregated( for i in 0..N { // Reverse: position i gets the coefficient from N-1-i. let c = poly.coefficients[N - 1 - i]; - // Reduce mod q: sum is in [0, H*q), subtract q up to H-1 times. - let mut reduced = c; - for _ in 0..H - 1 { - if (reduced as u128) >= (q as u128) { - reduced = reduced - q; - } - } + let reduced = ModU128::new(q).reduce_mod(c); // Center: shift to [-(q-1)/2, (q-1)/2]. let centered = if (reduced as u128) > (half as u128) { reduced - q