diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index df65c93fbd..d274bfbcc2 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -120,7 +120,7 @@ pub struct ShareEncryptionProofRequest { pub struct DkgShareDecryptionProofRequest { /// BFV secret key used for decryption (witness — encrypted at rest). pub sk_bfv: SensitiveBytes, - /// Serialized BFV Ciphertext bytes from H honest parties, flattened as [H * L]. + /// BFV ciphertexts from H honest parties, flattened [H * L] in ascending party_id order. /// Layout: party 0 mod 0, party 0 mod 1, ..., party 1 mod 0, ... pub honest_ciphertexts_raw: Vec, /// Number of honest parties (H). diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 844fedb58a..f1f4f14b3a 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -74,6 +74,7 @@ pub struct GenEsiSss { #[derive(Message)] #[rtype(result = "()")] pub struct AllThresholdSharesCollected { + /// Threshold shares sorted by ascending `party_id`. shares: Vec>, /// Proofs from each sender, ordered by party_id (parallel to shares). share_proofs: Vec, @@ -258,7 +259,9 @@ pub struct ThresholdKeyshareState { /// Aggregated public key bytes, captured from PublicKeyAggregated event for C6 proof. pub aggregated_pk: Option, pub expelled_parties: HashSet, - pub honest_parties: Option>, + /// Honest party IDs in deterministic ascending order (`BTreeSet` guarantees this). + /// Downstream proof circuits index parties by position in this sorted set. + pub honest_parties: Option>, #[serde(default = "default_proof_agg")] pub proof_aggregation_enabled: bool, } @@ -1648,7 +1651,15 @@ impl ThresholdKeyshare { } // Store honest party IDs in state (after dimension exclusion) - let honest_party_ids: HashSet = honest_shares.iter().map(|s| s.party_id).collect(); + let honest_party_ids: BTreeSet = honest_shares.iter().map(|s| s.party_id).collect(); + + // honest_shares inherits sorted order from AllThresholdSharesCollected. + debug_assert!( + honest_shares + .windows(2) + .all(|w| w[0].party_id < w[1].party_id), + "BUG: honest_shares must be in strictly ascending party_id order" + ); let num_honest = honest_shares.len(); info!( diff --git a/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs b/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs index 6c72975ce5..0d934d88e8 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs @@ -29,6 +29,7 @@ pub struct ShareDecryptionCircuitData { /// DKG secret key used to decrypt (private input). pub secret_key: SecretKey, /// Ciphertexts from H honest parties: [party_idx][mod_idx] (one ciphertext per party per TRBFV modulus). + /// party_idx follows ascending party_id among honest parties pub honest_ciphertexts: Vec>, /// Which input type (SecretKey or SmudgingNoise) to resolve circuit path. pub dkg_input_type: DkgInputType, 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 5fa464abe8..fc1eae02a7 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs @@ -286,4 +286,41 @@ mod tests { sample.honest_ciphertexts.len() ); } + + /// Verify expected_commitments[i][j] matches direct commitment computation + /// for honest_ciphertexts[i][j], proving row ordering is consistent. + #[test] + fn test_commitment_ordering_consistency() { + let committee = CiphernodesCommitteeSize::Small.values(); + let preset = BfvPreset::InsecureThreshold512; + let sample = + ShareDecryptionCircuitData::generate_sample(preset, committee, DkgInputType::SecretKey) + .unwrap(); + + let (threshold_params, dkg_params) = build_pair_for_preset(preset).unwrap(); + let threshold_l = threshold_params.moduli().len(); + let msg_bit = calculate_bit_width(BigInt::from(dkg_params.plaintext())); + + let inputs = Inputs::compute(preset, &sample).unwrap(); + assert_eq!( + inputs.expected_commitments.len(), + sample.honest_ciphertexts.len() + ); + + 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 share_coeffs = decrypted_pt.value.deref().to_vec(); + let direct_commitment = compute_share_encryption_commitment_from_message( + &Polynomial::from_u64_vector(share_coeffs), + msg_bit, + ); + assert_eq!( + inputs.expected_commitments[party_idx][mod_idx], direct_commitment, + "expected_commitments[{}][{}] doesn't match direct computation", + party_idx, mod_idx + ); + } + } + } }