diff --git a/crates/events/src/enclave_event/compute_request/zk.rs b/crates/events/src/enclave_event/compute_request/zk.rs index bb926aa321..dce15a8da2 100644 --- a/crates/events/src/enclave_event/compute_request/zk.rs +++ b/crates/events/src/enclave_event/compute_request/zk.rs @@ -166,20 +166,29 @@ impl ShareEncryptionProofRequest { /// Request to generate a proof for DKG share decryption (C4a or C4b). /// -/// Proves that a node correctly decrypted H honest parties' BFV-encrypted -/// Shamir shares using its own BFV secret key. +/// Proves that a node correctly decrypted (H − 1) external honest parties' BFV-encrypted +/// Shamir shares using its own BFV secret key, and that its own (un-encrypted) share row +/// matches the C2-bound commitment for its slot. The own slot is supplied as plaintext +/// because parties no longer self-encrypt during DKG. #[derive(Derivative, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derivative(Debug)] pub struct DkgShareDecryptionProofRequest { /// BFV secret key used for decryption (witness — encrypted at rest). pub sk_bfv: SensitiveBytes, - /// 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, ... + /// BFV ciphertexts from the (H − 1) external honest parties, flattened + /// `[(H − 1) * L]` in ascending external-party_id order (own party skipped). + /// Layout: ext party 0 mod 0, ext party 0 mod 1, ..., ext party 1 mod 0, ... pub honest_ciphertexts_raw: Vec, - /// Number of honest parties (H). + /// Total number of honest parties (H), counting the own slot. pub num_honest_parties: usize, /// Number of CRT moduli (L). pub num_moduli: usize, + /// Position of the own party within the H ascending-party_id ordering. The prover + /// splices `own_share_raw` into this slot when assembling C4 inputs. + pub own_plaintext_idx: usize, + /// Bincode-serialised `Vec>` of shape `[L][N]` — the own party's plaintext + /// share row per modulus (witness — encrypted at rest). + pub own_share_raw: SensitiveBytes, /// SecretKey or SmudgingNoise. pub dkg_input_type: DkgInputType, /// BFV preset for parameter resolution. diff --git a/crates/keyshare/src/threshold_keyshare.rs b/crates/keyshare/src/threshold_keyshare.rs index 99afe0fd6b..030f97c993 100644 --- a/crates/keyshare/src/threshold_keyshare.rs +++ b/crates/keyshare/src/threshold_keyshare.rs @@ -41,6 +41,7 @@ use e3_zk_helpers::computation::DkgInputType; use e3_zk_helpers::CiphernodesCommitteeSize; use fhe::bfv::{PublicKey, SecretKey}; use fhe_traits::{DeserializeParametrized, Serialize}; +use ndarray::Array2; use rand::rngs::OsRng; use std::{ collections::{BTreeSet, HashMap, HashSet}, @@ -137,6 +138,11 @@ pub struct GeneratingThresholdShareData { pub struct AggregatingDecryptionKey { pk_share: ArcBytes, sk_bfv: SensitiveBytes, + /// Bincode-serialised `Vec>` of shape `[L][N]` — own party's plaintext sk + /// share row per modulus. Used by C4a in lieu of self-encryption. + own_sk_share_raw: SensitiveBytes, + /// One bincode-serialised `Vec>` per smudging-noise (esi). Used by C4b. + own_esi_shares_raw: Vec, signed_pk_generation_proof: Option, signed_sk_share_computation_proof: Option, signed_e_sm_share_computation_proof: Option, @@ -422,6 +428,10 @@ pub struct ThresholdKeyshare { )>, /// Temporarily stores DecryptionKeyShared while C4 verification is in flight. pending_c4_verification_shares: Option>, + /// Own DKG plaintext shares captured during `handle_shares_generated`, consumed by + /// the AggregatingDecryptionKey transition. Tuple is `(own_sk_share, own_esi_shares)`. + /// Each entry is bincode-encoded `Vec>` of shape `[L][N]`. + pending_own_dkg_shares: Option<(SensitiveBytes, Vec)>, } impl ThresholdKeyshare { @@ -437,6 +447,7 @@ impl ThresholdKeyshare { pending_shares: Vec::new(), pending_share_decryption_data: None, pending_c4_verification_shares: None, + pending_own_dkg_shares: None, } } } @@ -448,6 +459,23 @@ impl Actor for ThresholdKeyshare { } } +/// Build a `ShamirShare` (rows = moduli, cols = `N` coefficients) from a `Vec>` +/// of shape `[L][N]`. Used to lift our own plaintext DKG share into the same matrix +/// shape as BFV-decrypted external shares. +fn vec_of_rows_to_shamir_share(rows: &[Vec], degree: usize) -> Result { + if rows.iter().any(|r| r.len() != degree) { + bail!( + "ShamirShare row length mismatch: each row must have {} coefficients", + degree + ); + } + let l = rows.len(); + let flat: Vec = rows.iter().flatten().copied().collect(); + let arr = Array2::from_shape_vec((l, degree), flat) + .context("Failed to build Array2 for ShamirShare")?; + Ok(ShamirShare::new(arr)) +} + impl ThresholdKeyshare { pub fn ensure_collector( &mut self, @@ -463,9 +491,10 @@ impl ThresholdKeyshare { ); let e3_id = state.e3_id.clone(); let threshold_n = state.threshold_n; - let addr = self - .decryption_key_collector - .get_or_insert_with(|| ThresholdShareCollector::setup(self_addr, threshold_n, e3_id)); + let own_party_id = state.party_id; + let addr = self.decryption_key_collector.get_or_insert_with(|| { + ThresholdShareCollector::setup(self_addr, threshold_n, own_party_id, e3_id) + }); Ok(addr.clone()) } @@ -1026,6 +1055,12 @@ impl ThresholdKeyshare { // Call handle_shares_generated while still in GeneratingThresholdShare state self.handle_shares_generated(ec.clone())?; + // Consume the own plaintext shares stashed transiently by handle_shares_generated. + let (own_sk_share_raw, own_esi_shares_raw) = + self.pending_own_dkg_shares.take().ok_or_else(|| { + anyhow!("pending_own_dkg_shares missing — handle_shares_generated did not run") + })?; + // Now transition to AggregatingDecryptionKey with minimal state self.state.try_mutate(&ec, |s| { let current: GeneratingThresholdShareData = s.clone().try_into()?; @@ -1033,6 +1068,8 @@ impl ThresholdKeyshare { AggregatingDecryptionKey { pk_share: current.pk_share.expect("pk_share checked above"), sk_bfv: current.sk_bfv, + own_sk_share_raw: own_sk_share_raw.clone(), + own_esi_shares_raw: own_esi_shares_raw.clone(), signed_pk_generation_proof: None, signed_sk_share_computation_proof: None, signed_e_sm_share_computation_proof: None, @@ -1110,19 +1147,56 @@ impl ThresholdKeyshare { }) .collect::>()?; - // Encrypt shares for all recipients using BFV (extended to capture randomness for C3 proofs) + // Cache own plaintext share rows for C4 (no self-encryption); stored encrypted at rest. + let own_sk_shamir = decrypted_sk_sss.extract_party_share(party_id as usize)?; + let own_sk_rows: Vec> = own_sk_shamir + .rows() + .into_iter() + .map(|row| row.iter().copied().collect()) + .collect(); + let own_sk_share_raw = SensitiveBytes::new( + bincode::serialize(&own_sk_rows) + .map_err(|e| anyhow!("Failed to serialize own sk share: {}", e))?, + &self.cipher, + )?; + + let own_esi_shares_raw: Vec = decrypted_esi_sss + .iter() + .map(|esi| { + let shamir = esi.extract_party_share(party_id as usize)?; + let rows: Vec> = shamir + .rows() + .into_iter() + .map(|row| row.iter().copied().collect()) + .collect(); + let bytes = bincode::serialize(&rows) + .map_err(|e| anyhow!("Failed to serialize own esi share: {}", e))?; + SensitiveBytes::new(bytes, &self.cipher) + }) + .collect::>()?; + self.pending_own_dkg_shares = Some((own_sk_share_raw, own_esi_shares_raw)); + + // BFV-encrypt shares to all recipients except own slot (own share is bound via C2, + // consumed locally by C4). Returns per-row randomness for C3 proofs. let mut rng = OsRng; let (encrypted_sk_sss, sk_witnesses) = BfvEncryptedShares::encrypt_all_extended( &decrypted_sk_sss, &recipient_pks, ¶ms, &mut rng, + Some(party_id as usize), )?; let (encrypted_esi_sss, esi_witnesses): (Vec<_>, Vec<_>) = decrypted_esi_sss .iter() .map(|esi| { - BfvEncryptedShares::encrypt_all_extended(esi, &recipient_pks, ¶ms, &mut rng) + BfvEncryptedShares::encrypt_all_extended( + esi, + &recipient_pks, + ¶ms, + &mut rng, + Some(party_id as usize), + ) }) .collect::>>()? .into_iter() @@ -1167,9 +1241,15 @@ impl ThresholdKeyshare { committee_size: derived_committee_size, }; - // Build C3a proof requests (SK share encryption) from witnesses + // Build C3a proof requests (SK share encryption) from witnesses. + // The own slot was skipped during BFV encryption (witness vec empty), so it + // contributes no C3a request. let mut sk_share_encryption_requests = Vec::new(); + let own_idx = party_id as usize; for (recipient_idx, recipient_witnesses) in sk_witnesses.iter().enumerate() { + if recipient_idx == own_idx { + continue; + } for (row_idx, witness) in recipient_witnesses.iter().enumerate() { sk_share_encryption_requests.push(ShareEncryptionProofRequest { share_row_raw: SensitiveBytes::new( @@ -1194,10 +1274,13 @@ impl ThresholdKeyshare { } } - // Build C3b proof requests (E_SM share encryption) from witnesses + // Build C3b proof requests (E_SM share encryption) from witnesses; skip own slot. let mut e_sm_share_encryption_requests = Vec::new(); for (esi_idx, esi_recipient_witnesses) in esi_witnesses.iter().enumerate() { for (recipient_idx, recipient_witnesses) in esi_recipient_witnesses.iter().enumerate() { + if recipient_idx == own_idx { + continue; + } for (row_idx, witness) in recipient_witnesses.iter().enumerate() { e_sm_share_encryption_requests.push(ShareEncryptionProofRequest { share_row_raw: SensitiveBytes::new( @@ -1288,24 +1371,20 @@ impl ThresholdKeyshare { .unzip() }; - // Derive expected proof counts from our own share (trusted source). - // All parties use the same BFV params, so moduli counts are identical. - // Using the sender's share would let a malicious party manipulate expected counts. - let own_share = shares - .iter() - .find(|s| s.party_id == own_party_id) - .ok_or_else(|| anyhow!("Own share not found in AllThresholdSharesCollected"))?; - let expected_c3a = own_share - .sk_sss - .get_share(0) - .map(|s| s.num_moduli()) - .unwrap_or(0); - let expected_c3b: usize = own_share - .esi_sss - .iter() - .map(|esi| esi.get_share(0).map(|s| s.num_moduli()).unwrap_or(0)) - .sum(); - let expected_num_esi = own_share.esi_sss.len(); + // Expected proof counts come from local cached own shares (trusted source); the + // collector excludes self from `shares`, so we cannot read them from there. + let current: AggregatingDecryptionKey = state.clone().try_into()?; + let own_sk_rows: Vec> = + bincode::deserialize(¤t.own_sk_share_raw.access_raw(&self.cipher)?) + .context("Failed to deserialize own_sk_share_raw")?; + let expected_c3a = own_sk_rows.len(); + let expected_num_esi = current.own_esi_shares_raw.len(); + let mut expected_c3b: usize = 0; + for esi_raw in current.own_esi_shares_raw.iter() { + let rows: Vec> = bincode::deserialize(&esi_raw.access_raw(&self.cipher)?) + .context("Failed to deserialize own esi share")?; + expected_c3b += rows.len(); + } // Build verification requests for other parties' proofs let mut party_proofs_to_verify: Vec = Vec::new(); @@ -1545,6 +1624,7 @@ impl ThresholdKeyshare { let state = self.state.try_get()?; let e3_id = state.get_e3_id(); let party_id = state.party_id as usize; + let own_party_id = state.party_id as u64; let trbfv_config = state.get_trbfv_config(); // Get our BFV secret key from state, pending shares from the actor @@ -1558,7 +1638,29 @@ impl ThresholdKeyshare { let sk_bfv = deserialize_secret_key(&sk_bytes, ¶ms)?; let degree = params.degree(); - // Filter to honest parties only + // Own plaintext shares (bincode `Vec>` shape [L][N]) cached at generation time. + let own_sk_rows: Vec> = + bincode::deserialize(¤t.own_sk_share_raw.access_raw(&cipher)?) + .context("Failed to deserialize own_sk_share_raw")?; + let own_esi_rows_per_esi: Vec>> = current + .own_esi_shares_raw + .iter() + .map(|sb| { + let bytes = sb.access_raw(&cipher)?; + bincode::deserialize::>>(&bytes) + .context("Failed to deserialize own esi share") + }) + .collect::>()?; + + // Expected dimensions derived from own (trusted) shares. + let expected_num_esi = own_esi_rows_per_esi.len(); + let expected_num_moduli_sk = own_sk_rows.len(); + let expected_num_moduli_esi = own_esi_rows_per_esi + .first() + .map(|rows| rows.len()) + .unwrap_or(0); + + // Filter to honest external parties (collector already excludes self). let honest_shares: Vec<_> = shares .iter() .filter(|ts| { @@ -1568,48 +1670,11 @@ impl ThresholdKeyshare { }) .collect(); - // Derive expected dimensions from our own share (trusted source). - // All parties use the same on-chain BFV params, so dimensions must be identical. - let own_share = honest_shares - .iter() - .find(|ts| ts.party_id == state.party_id as u64) - .ok_or_else(|| anyhow!("Own share not found in honest shares"))?; - - let expected_num_esi = own_share.esi_sss.len(); - let own_sk_share = own_share - .sk_sss - .clone_share(if own_share.sk_sss.len() == 1 { - 0 - } else { - party_id - }) - .ok_or(anyhow!("No own sk_sss share"))?; - let expected_num_moduli_sk = own_sk_share.num_moduli(); - let expected_num_moduli_esi = if expected_num_esi > 0 { - own_share.esi_sss[0] - .clone_share(if own_share.esi_sss[0].len() == 1 { - 0 - } else { - party_id - }) - .map(|s| s.num_moduli()) - .unwrap_or(0) - } else { - 0 - }; - // Validate per-party dimensions and exclude mismatched parties. - // This prevents a malicious party with wrong-sized shares from - // causing a panic or opaque error in downstream matrix building. let mut dimension_excluded: Vec = Vec::new(); let honest_shares: Vec<_> = honest_shares .into_iter() .filter(|ts| { - // Own share is always valid - if ts.party_id == state.party_id as u64 { - return true; - } - // Check esi count if ts.esi_sss.len() != expected_num_esi { warn!( "Party {} has wrong esi_sss count ({} vs expected {}) — excluding from honest set", @@ -1618,7 +1683,6 @@ impl ThresholdKeyshare { dimension_excluded.push(ts.party_id); return false; } - // Check sk share exists and moduli count let idx = if ts.sk_sss.len() == 1 { 0 } else { party_id }; match ts.sk_sss.clone_share(idx) { Some(share) if share.num_moduli() != expected_num_moduli_sk => { @@ -1639,7 +1703,6 @@ impl ThresholdKeyshare { } _ => {} } - // Check esi shares exist and moduli counts for (esi_idx, esi_shares) in ts.esi_sss.iter().enumerate() { let idx = if esi_shares.len() == 1 { 0 } else { party_id }; match esi_shares.clone_share(idx) { @@ -1672,9 +1735,9 @@ impl ThresholdKeyshare { dimension_excluded.len(), dimension_excluded ); - // Re-check threshold after exclusion + // Re-check threshold after exclusion (+1 for own share). let threshold = state.threshold_m; - if (honest_shares.len() as u64) <= threshold { + if (honest_shares.len() as u64 + 1) <= threshold { self.pending_shares.clear(); self.bus.publish( E3Failed { @@ -1688,26 +1751,32 @@ impl ThresholdKeyshare { } } - // Store honest party IDs in state (after dimension exclusion) - let honest_party_ids: BTreeSet = honest_shares.iter().map(|s| s.party_id).collect(); + // Honest party IDs include self (signing/aggregation treats own party as honest). + let mut honest_party_ids: BTreeSet = + honest_shares.iter().map(|s| s.party_id).collect(); + honest_party_ids.insert(own_party_id); - // 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" + "honest_shares must be strictly ascending by party_id" ); - let num_honest = honest_shares.len(); + // Position of own party within sorted {own} ∪ external honest set. + let own_plaintext_idx = honest_shares + .iter() + .position(|ts| ts.party_id > own_party_id) + .unwrap_or(honest_shares.len()); + + let num_honest = honest_shares.len() + 1; info!( - "Decrypting shares from {} honest parties for E3 {}", + "Decrypting shares from {} honest parties (incl. self) for E3 {}", num_honest, e3_id ); - // Collect ciphertext bytes for C4 proof requests (built here, sent after CalculateDecryptionKey) - // Dimensions are validated per-party above, so all shares are consistent. - // C4a: sk_sss ciphertexts from honest parties [H * L] + // External ciphertexts for C4: own slot omitted from wire (rides as `own_share_raw`). + // C4a: sk_sss external ciphertexts [(H-1) * L] let num_moduli_sk = expected_num_moduli_sk; let mut sk_ciphertexts_raw = Vec::new(); for ts in &honest_shares { @@ -1721,7 +1790,7 @@ impl ThresholdKeyshare { } } - // C4b: esi_sss ciphertexts from honest parties — one set per smudging noise + // C4b: esi_sss external ciphertexts — one set per smudging noise let num_esi = expected_num_esi; let num_moduli_esi = expected_num_moduli_esi; let mut esi_ciphertexts_raw: Vec> = vec![Vec::new(); num_esi]; @@ -1737,8 +1806,8 @@ impl ThresholdKeyshare { } } - // Decrypt our share from each honest sender using BFV - let sk_sss_collected: Vec = honest_shares + // Decrypt our share row from each external honest sender using BFV. + let mut sk_sss_collected: Vec = honest_shares .iter() .map(|ts| { let idx = if ts.sk_sss.len() == 1 { 0 } else { party_id }; @@ -1750,8 +1819,12 @@ impl ThresholdKeyshare { }) .collect::>()?; - // Decrypt per-party ESI shares: shape [party][esm_idx] - let per_party_esi: Vec> = honest_shares + // Splice own sk share at the sorted-party position. + let own_sk_shamir = vec_of_rows_to_shamir_share(&own_sk_rows, degree)?; + sk_sss_collected.insert(own_plaintext_idx, own_sk_shamir); + + // Decrypt per-party ESI shares: shape [external_party][esm_idx] + let mut per_party_esi: Vec> = honest_shares .iter() .map(|ts| { ts.esi_sss @@ -1767,6 +1840,13 @@ impl ThresholdKeyshare { }) .collect::>()?; + // Splice own esi shares (one per smudging noise). + let own_esi_shamirs: Vec = own_esi_rows_per_esi + .iter() + .map(|rows| vec_of_rows_to_shamir_share(rows, degree)) + .collect::>()?; + per_party_esi.insert(own_plaintext_idx, own_esi_shamirs); + // Transpose to [esm_idx][party] — CalculateDecryptionKey aggregates per smudging noise let esi_sss_collected: Vec> = (0..num_esi) .map(|esm_idx| { @@ -1805,17 +1885,22 @@ impl ThresholdKeyshare { honest_ciphertexts_raw: sk_ciphertexts_raw, num_honest_parties: num_honest, num_moduli: num_moduli_sk, + own_plaintext_idx, + own_share_raw: current.own_sk_share_raw.clone(), dkg_input_type: DkgInputType::SecretKey, params_preset: threshold_preset, }; let esm_requests: Vec = esi_ciphertexts_raw .into_iter() - .map(|esi_cts| DkgShareDecryptionProofRequest { + .enumerate() + .map(|(esi_idx, esi_cts)| DkgShareDecryptionProofRequest { sk_bfv: current.sk_bfv.clone(), honest_ciphertexts_raw: esi_cts, num_honest_parties: num_honest, num_moduli: num_moduli_esi, + own_plaintext_idx, + own_share_raw: current.own_esi_shares_raw[esi_idx].clone(), dkg_input_type: DkgInputType::SmudgingNoise, params_preset: threshold_preset, }) diff --git a/crates/keyshare/src/threshold_share_collector.rs b/crates/keyshare/src/threshold_share_collector.rs index c3085c5940..a3b11b1935 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -58,8 +58,7 @@ pub(crate) enum CollectorState { #[rtype(result = "()")] pub struct ThresholdShareCollectionTimeout; -/// Removes this party from the `todo` set so the DKG can complete with -/// N-1 shares instead of waiting for a share that will never arrive. +/// Remove this party from `todo` so collection finishes without it. #[derive(Message, Clone, Debug)] #[rtype(result = "()")] pub struct ExpelPartyFromShareCollection { @@ -85,10 +84,16 @@ pub struct ThresholdShareCollector { } impl ThresholdShareCollector { - pub fn setup(parent: Addr, total: u64, e3_id: E3id) -> Addr { + /// Excludes `own_party_id` from `todo` (own share is consumed locally for C4). + pub fn setup( + parent: Addr, + total: u64, + own_party_id: u64, + e3_id: E3id, + ) -> Addr { let collector = Self { e3_id, - todo: (0..total).collect(), + todo: (0..total).filter(|p| *p != own_party_id).collect(), parent, state: CollectorState::Collecting, shares: HashMap::new(), diff --git a/crates/multithread/src/multithread.rs b/crates/multithread/src/multithread.rs index 4ee356b4f4..88bc6e25ce 100644 --- a/crates/multithread/src/multithread.rs +++ b/crates/multithread/src/multithread.rs @@ -1122,11 +1122,9 @@ fn handle_dkg_share_decryption_proof( req: DkgShareDecryptionProofRequest, request: ComputeRequest, ) -> Result { - // 1. Build DKG params from preset let (_threshold_params, dkg_params) = build_pair_for_preset(req.params_preset) .map_err(|e| make_zk_error(&request, format!("build_pair_for_preset: {}", e)))?; - // 2. Decrypt BFV secret key from SensitiveBytes let sk_bytes = req .sk_bfv .access_raw(cipher) @@ -1134,49 +1132,90 @@ fn handle_dkg_share_decryption_proof( let secret_key = deserialize_secret_key(&sk_bytes, &dkg_params) .map_err(|e| make_zk_error(&request, format!("sk_bfv deserialize: {}", e)))?; - // 3. Deserialize ciphertexts from raw bytes [H * L] → Vec> [H][L] + // External slots = (H - 1), each carrying L ciphertexts. let h = req.num_honest_parties; let l = req.num_moduli; - if req.honest_ciphertexts_raw.len() != h * l { + if req.own_plaintext_idx >= h { return Err(make_zk_error( &request, format!( - "Expected {} ciphertexts (H={} * L={}), got {}", - h * l, - h, + "own_plaintext_idx {} out of range (num_honest_parties={})", + req.own_plaintext_idx, h + ), + )); + } + let expected_external_cts = h.saturating_sub(1) * l; + if req.honest_ciphertexts_raw.len() != expected_external_cts { + return Err(make_zk_error( + &request, + format!( + "Expected {} external ciphertexts ((H-1)={} * L={}), got {}", + expected_external_cts, + h.saturating_sub(1), l, req.honest_ciphertexts_raw.len() ), )); } - let mut honest_ciphertexts: Vec> = Vec::with_capacity(h); - for party_idx in 0..h { + // Deserialize external ciphertexts → [(H-1)][L] + let num_external = h.saturating_sub(1); + let mut external_ciphertexts: Vec> = Vec::with_capacity(num_external); + for ext_idx in 0..num_external { let mut party_cts = Vec::with_capacity(l); for mod_idx in 0..l { - let raw = &req.honest_ciphertexts_raw[party_idx * l + mod_idx]; + let raw = &req.honest_ciphertexts_raw[ext_idx * l + mod_idx]; let ct = Ciphertext::from_bytes(raw, &dkg_params).map_err(|e| { make_zk_error( &request, - format!( - "ciphertext[{}][{}] deserialize: {:?}", - party_idx, mod_idx, e - ), + format!("ciphertext[{}][{}] deserialize: {:?}", ext_idx, mod_idx, e), ) })?; party_cts.push(ct); } - honest_ciphertexts.push(party_cts); + external_ciphertexts.push(party_cts); + } + + // Splice None at `own_plaintext_idx` so the H-sized vector matches ascending honest party_id order. + let mut honest_ciphertexts: Vec>> = Vec::with_capacity(h); + let mut external_iter = external_ciphertexts.into_iter(); + for slot in 0..h { + if slot == req.own_plaintext_idx { + honest_ciphertexts.push(None); + } else { + honest_ciphertexts.push(Some( + external_iter + .next() + .expect("external_iter exhausted: lengths validated above"), + )); + } + } + + // Own-plaintext share rows: bincode `Vec>` shape [L][N]. + let own_share_bytes = req + .own_share_raw + .access_raw(cipher) + .map_err(|e| make_zk_error(&request, format!("own_share decrypt: {}", e)))?; + let own_plaintext_share: Vec> = bincode::deserialize(&own_share_bytes) + .map_err(|e| make_zk_error(&request, format!("own_share deserialize: {}", e)))?; + if own_plaintext_share.len() != l { + return Err(make_zk_error( + &request, + format!( + "own_plaintext_share has {} moduli, expected {}", + own_plaintext_share.len(), + l + ), + )); } - // 4. Build circuit data let circuit_data = ShareDecryptionCircuitData { secret_key, honest_ciphertexts, + own_plaintext_share, dkg_input_type: req.dkg_input_type, }; - // 5. Generate proof let circuit = ShareDecryptionCircuit; let bb_work = zk_bb_work_id(&request); let artifacts_dir = req.params_preset.artifacts_dir(); diff --git a/crates/trbfv/src/shares/bfv_encrypted.rs b/crates/trbfv/src/shares/bfv_encrypted.rs index 5c34cfc93a..750ca99cfc 100644 --- a/crates/trbfv/src/shares/bfv_encrypted.rs +++ b/crates/trbfv/src/shares/bfv_encrypted.rs @@ -195,26 +195,24 @@ impl Default for BfvEncryptedShare { } } +/// A collection of BFV-encrypted shares for all recipients. +/// /// A collection of BFV-encrypted shares for all recipients. /// /// When a party generates Shamir shares, they encrypt each recipient's share /// with that recipient's public key. This struct holds all encrypted shares -/// from a single sender. +/// from a single sender. A `None` slot indicates the recipient was deliberately +/// skipped (e.g. the sender does not encrypt their own share during DKG). #[derive(Derivative, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[derivative(Debug)] pub struct BfvEncryptedShares { - /// Encrypted shares indexed by recipient party_id (0-based) - shares: Vec, + /// Encrypted shares indexed by recipient party_id (0-based). + /// `None` means the recipient slot was skipped (no ciphertext produced). + shares: Vec>, } impl BfvEncryptedShares { - /// Encrypt shares for all recipients. - /// - /// # Arguments - /// * `secret` - The SharedSecret containing shares for all parties - /// * `recipient_pks` - Public keys for all recipients, indexed by party_id - /// * `params` - BFV parameters for share encryption - /// * `rng` - Random number generator + /// Encrypt shares for all recipients (no skipping). pub fn encrypt_all( secret: &SharedSecret, recipient_pks: &[PublicKey], @@ -232,27 +230,34 @@ impl BfvEncryptedShares { let encrypted = BfvEncryptedShare::encrypt(&share, &recipient_pks[party_id], params, rng)?; - shares.push(encrypted); + shares.push(Some(encrypted)); } Ok(Self { shares }) } - /// Encrypt shares for all recipients and return encryption randomness for ZK proofs. + /// Encrypt shares for all recipients and capture per-row randomness (u, e0, e1) for C3 proofs. /// - /// Same as `encrypt_all` but captures encryption randomness (u, e0, e1) per row per recipient. - /// Returns `(encrypted_shares, witnesses)` where `witnesses[recipient_idx][row_idx]`. + /// `skip_idx == Some(idx)` leaves that slot as `None` with an empty witness vec — a DKG + /// party never encrypts its own share (bound via C2, consumed locally by C4). pub fn encrypt_all_extended( secret: &SharedSecret, recipient_pks: &[PublicKey], params: &Arc, rng: &mut R, + skip_idx: Option, ) -> Result<(Self, Vec>)> { let num_parties = recipient_pks.len(); let mut shares = Vec::with_capacity(num_parties); let mut all_witnesses = Vec::with_capacity(num_parties); for party_id in 0..num_parties { + if Some(party_id) == skip_idx { + shares.push(None); + all_witnesses.push(Vec::new()); + continue; + } + let share = secret .extract_party_share(party_id) .context(format!("Failed to extract share for party {}", party_id))?; @@ -260,31 +265,33 @@ impl BfvEncryptedShares { let (encrypted, witnesses) = BfvEncryptedShare::encrypt_extended(&share, &recipient_pks[party_id], params, rng)?; - shares.push(encrypted); + shares.push(Some(encrypted)); all_witnesses.push(witnesses); } Ok((Self { shares }, all_witnesses)) } - /// Get the encrypted share for a specific recipient. + /// Get the encrypted share for a specific recipient. Returns `None` for skipped slots. pub fn get_share(&self, party_id: usize) -> Option<&BfvEncryptedShare> { - self.shares.get(party_id) + self.shares.get(party_id).and_then(|s| s.as_ref()) } - /// Clone the encrypted share for a specific recipient. + /// Clone the encrypted share for a specific recipient. Returns `None` for skipped slots. pub fn clone_share(&self, party_id: usize) -> Option { - self.shares.get(party_id).cloned() + self.shares.get(party_id).and_then(|s| s.clone()) } - /// Extract only the share for a specific party (for bandwidth optimization) + /// Extract only the share for a specific party (for bandwidth optimization). + /// Returns `None` if the slot is empty/skipped or out of range. pub fn extract_for_party(&self, party_id: usize) -> Option { - self.shares.get(party_id).map(|share| Self { - shares: vec![share.clone()], + let share = self.shares.get(party_id).and_then(|s| s.as_ref())?; + Some(Self { + shares: vec![Some(share.clone())], }) } - /// Number of encrypted shares + /// Number of recipient slots (including any skipped slots). pub fn len(&self) -> usize { self.shares.len() } 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 0d934d88e8..20f46a9f74 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs @@ -24,13 +24,20 @@ impl Circuit for ShareDecryptionCircuit { const DKG_INPUT_TYPE: Option = None; } -/// Data for the share-decryption circuit: secret key and honest parties' ciphertexts. +/// Data for the share-decryption circuit: secret key, ciphertexts from external honest +/// parties, and the own party's plaintext share row. pub struct ShareDecryptionCircuitData { - /// DKG secret key used to decrypt (private input). + /// DKG secret key used to decrypt external ciphertexts (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>, + /// Per-honest-party ciphertexts, length H, indexed by ascending honest party_id. + /// `None` means that slot is the own party (no ciphertext was produced because the + /// party does not self-encrypt during DKG); `Some(cts)` carries one ciphertext per + /// CRT modulus for an external honest party. + pub honest_ciphertexts: Vec>>, + /// Own party's plaintext share row per modulus, shape `[L][N]` (length L, each + /// inner Vec length N). Spliced into the H-sized list at the `None` slot when + /// computing commitments and decrypted-share inputs. + pub own_plaintext_share: 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 88c939b8ed..04fac3ea03 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/computation.rs @@ -172,37 +172,71 @@ impl Computation for Inputs { let msg_bit = compute_msg_bit(&dkg_params); - // Decrypt each ciphertext and compute its commitment - for party_cts in data.honest_ciphertexts.iter() { - if party_cts.len() < threshold_l { - return Err(CircuitsErrors::Other(format!( - "honest_ciphertexts party has {} ciphertexts but threshold_l is {}; \ - each party must have at least threshold_l ciphertexts", - party_cts.len(), - threshold_l - ))); - } + // Validate own-plaintext shape against L only when an own slot is present. + let has_own_slot = data.honest_ciphertexts.iter().any(|s| s.is_none()); + if has_own_slot && data.own_plaintext_share.len() != threshold_l { + return Err(CircuitsErrors::Other(format!( + "own_plaintext_share has {} moduli but threshold_l is {}", + data.own_plaintext_share.len(), + threshold_l + ))); + } + + // Iterate H slots in ascending honest-party order: external slots BFV-decrypt and + // commit; the own slot uses the supplied plaintext directly. + for slot in data.honest_ciphertexts.iter() { let mut party_commitments = Vec::with_capacity(threshold_l); let mut party_shares = Vec::with_capacity(threshold_l); - for mod_idx in 0..threshold_l { - // Decrypt the ciphertext to get the plaintext share - let decrypted_pt = data.secret_key.try_decrypt(&party_cts[mod_idx]).unwrap(); - let share_coeffs = decrypted_pt.value.deref().to_vec(); - // Reverse to match C3's message witness, which is constructed as - // `pt.value.reversed()` before committing (share_encryption/computation.rs). - let mut reversed_coeffs = share_coeffs.clone(); - reversed_coeffs.reverse(); - party_commitments.push(compute_share_encryption_commitment_from_message( - &Polynomial::from_u64_vector(reversed_coeffs), - msg_bit, - )); - party_shares.push( - share_coeffs - .iter() - .map(|c| BigInt::from(*c)) - .collect::>(), - ); + + match slot { + Some(party_cts) => { + if party_cts.len() < threshold_l { + return Err(CircuitsErrors::Other(format!( + "honest_ciphertexts party has {} ciphertexts but threshold_l is {}; \ + each party must have at least threshold_l ciphertexts", + party_cts.len(), + threshold_l + ))); + } + for mod_idx in 0..threshold_l { + let decrypted_pt = + data.secret_key.try_decrypt(&party_cts[mod_idx]).unwrap(); + let share_coeffs = decrypted_pt.value.deref().to_vec(); + // Reverse to match C3's `pt.value.reversed()` commitment convention. + let mut reversed_coeffs = share_coeffs.clone(); + reversed_coeffs.reverse(); + party_commitments.push(compute_share_encryption_commitment_from_message( + &Polynomial::from_u64_vector(reversed_coeffs), + msg_bit, + )); + party_shares.push( + share_coeffs + .iter() + .map(|c| BigInt::from(*c)) + .collect::>(), + ); + } + } + None => { + for mod_idx in 0..threshold_l { + let share_coeffs = &data.own_plaintext_share[mod_idx]; + // Same reverse-then-commit as the BFV-decrypted branch. + let mut reversed_coeffs = share_coeffs.clone(); + reversed_coeffs.reverse(); + party_commitments.push(compute_share_encryption_commitment_from_message( + &Polynomial::from_u64_vector(reversed_coeffs), + msg_bit, + )); + party_shares.push( + share_coeffs + .iter() + .map(|c| BigInt::from(*c)) + .collect::>(), + ); + } + } } + expected_commitments.push(party_commitments); decrypted_shares.push(party_shares); } @@ -307,8 +341,9 @@ mod tests { ); } - /// Verify expected_commitments[i][j] matches direct commitment computation - /// for honest_ciphertexts[i][j], proving row ordering is consistent. + /// Verify expected_commitments[i][j] matches direct commitment computation for each + /// slot, proving row ordering is consistent. External slots use BFV-decryption; the + /// own slot uses the supplied plaintext. #[test] fn test_commitment_ordering_consistency() { let committee = CiphernodesCommitteeSize::Small.values(); @@ -327,10 +362,16 @@ mod tests { sample.honest_ciphertexts.len() ); - for (party_idx, party_cts) in sample.honest_ciphertexts.iter().enumerate() { + for (party_idx, slot) 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 share_coeffs = match slot { + Some(party_cts) => { + let decrypted_pt = + sample.secret_key.try_decrypt(&party_cts[mod_idx]).unwrap(); + decrypted_pt.value.deref().to_vec() + } + None => sample.own_plaintext_share[mod_idx].clone(), + }; // 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(); diff --git a/crates/zk-helpers/src/circuits/dkg/share_decryption/sample.rs b/crates/zk-helpers/src/circuits/dkg/share_decryption/sample.rs index e1a954f915..2761847987 100644 --- a/crates/zk-helpers/src/circuits/dkg/share_decryption/sample.rs +++ b/crates/zk-helpers/src/circuits/dkg/share_decryption/sample.rs @@ -45,10 +45,16 @@ impl ShareDecryptionCircuitData { let mut share_manager = ShareManager::new(committee.n, committee.threshold, threshold_params.clone()); - let mut honest_ciphertexts: Vec> = Vec::new(); + let mut honest_ciphertexts: Vec>> = Vec::new(); let num_honest = committee.h; - for _ in 0..num_honest { + // Midpoint own slot exercises both the None (own plaintext) and Some (BFV-decrypt) branches. + let own_slot_idx = num_honest / 2; + let mut own_plaintext_share: Vec> = + Vec::with_capacity(threshold_params.moduli().len()); + + for slot_idx in 0..num_honest { let mut party_cts = Vec::new(); + let mut own_share_for_slot: Vec> = Vec::new(); for _ in 0..threshold_params.moduli().len() { let share_row = match dkg_input_type { DkgInputType::SecretKey => { @@ -114,6 +120,11 @@ impl ShareDecryptionCircuitData { } }; + if slot_idx == own_slot_idx { + own_share_for_slot.push(share_row); + continue; + } + let pt = Plaintext::try_encode(&share_row, Encoding::poly(), &dkg_params).map_err( |e| CircuitsErrors::Sample(format!("Failed to encode plaintext: {:?}", e)), )?; @@ -124,11 +135,18 @@ impl ShareDecryptionCircuitData { party_cts.push(ct); } - honest_ciphertexts.push(party_cts); + + if slot_idx == own_slot_idx { + own_plaintext_share = own_share_for_slot; + honest_ciphertexts.push(None); + } else { + honest_ciphertexts.push(Some(party_cts)); + } } Ok(ShareDecryptionCircuitData { honest_ciphertexts, + own_plaintext_share, secret_key: dkg_secret_key, dkg_input_type, }) diff --git a/crates/zk-prover/src/actors/proof_request.rs b/crates/zk-prover/src/actors/proof_request.rs index 7895485bb9..26761000dc 100644 --- a/crates/zk-prover/src/actors/proof_request.rs +++ b/crates/zk-prover/src/actors/proof_request.rs @@ -26,7 +26,7 @@ use e3_events::{ }; use e3_utils::utility_types::ArcBytes; use e3_utils::NotifySync; -use tracing::{error, info, warn}; +use tracing::{error, info, trace, warn}; #[derive(Clone, Debug)] enum ThresholdProofKind { @@ -1241,36 +1241,44 @@ impl ProofRequestActor { ); for (positional_idx, &real_party_id) in pending.recipient_party_ids.iter().enumerate() { - if let Some(party_share) = share.extract_for_party(positional_idx) { - let c3a_proofs = signed_c3a_map - .get(&positional_idx) - .cloned() - .unwrap_or_default(); - let c3b_proofs = signed_c3b_map - .get(&positional_idx) - .cloned() - .unwrap_or_default(); - - if let Err(err) = self.bus.publish( - ThresholdShareCreated { - e3_id: e3_id.clone(), - share: Arc::new(party_share), - target_party_id: real_party_id, - external: false, - signed_c2a_proof: Some(signed_c2a.clone()), - signed_c2b_proof: Some(signed_c2b.clone()), - signed_c3a_proofs: c3a_proofs, - signed_c3b_proofs: c3b_proofs, - }, - ec.clone(), - ) { - error!( - "Failed to publish ThresholdShareCreated for party {} (idx {}): {err}", - real_party_id, positional_idx + match share.extract_for_party(positional_idx) { + Some(party_share) => { + let c3a_proofs = signed_c3a_map + .get(&positional_idx) + .cloned() + .unwrap_or_default(); + let c3b_proofs = signed_c3b_map + .get(&positional_idx) + .cloned() + .unwrap_or_default(); + + if let Err(err) = self.bus.publish( + ThresholdShareCreated { + e3_id: e3_id.clone(), + share: Arc::new(party_share), + target_party_id: real_party_id, + external: false, + signed_c2a_proof: Some(signed_c2a.clone()), + signed_c2b_proof: Some(signed_c2b.clone()), + signed_c3a_proofs: c3a_proofs, + signed_c3b_proofs: c3b_proofs, + }, + ec.clone(), + ) { + error!( + "Failed to publish ThresholdShareCreated for party {} (idx {}): {err}", + real_party_id, positional_idx + ); + } + } + None => { + // Own slot is sparse (no self-encryption); nothing to publish. + trace!( + "Skipping ThresholdShareCreated for own slot (party {} idx {})", + real_party_id, + positional_idx ); } - } else { - error!("Failed to extract share for index {}", positional_idx); } } }