diff --git a/agent/flow-trace/00_INDEX.md b/agent/flow-trace/00_INDEX.md index b0aa8fddd6..ff709f8036 100644 --- a/agent/flow-trace/00_INDEX.md +++ b/agent/flow-trace/00_INDEX.md @@ -183,12 +183,12 @@ _Found during source-code cross-referencing of these trace documents._ ### Protocol Design Concerns -| # | Concern | Severity | Detail | -| --- | ---------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 1 | **Deregister-before-slash race** | Accepted | SlashingManager Lane B (evidence+appeal) has a window during which the operator can deregister and claim their exit. If they do, the slash executes against 0 funds. The contract comments acknowledge this as an accepted tradeoff for the appeal window design. | -| 2 | **Committee publication decentralized** | Resolved | `publishCommittee()` is permissionless. Off-chain role selection chooses the active aggregator, while on-chain C5 proof verification and the single-publish guard prevent invalid or duplicate committee publication. | -| 3 | **`gracePeriod` is dead code** | Medium | `gracePeriod` is stored and validated during config updates but never actually used in any timeout check. Either the deadlines already bake in sufficient buffer, or this is a missing feature. | -| 4 | **`activate` CLI command is misleading** | Low | Named "activate" but actually calls "register" — will fail for already-registered operators. There's no standalone way to trigger re-evaluation of active status; instead, `_updateOperatorStatus()` runs automatically inside `addTicketBalance()`, `bondLicense()`, etc. | -| 5 | **Active-job load balancing bug fixed** | Info | The Rust `NodeStateStore.available_tickets()` subtracts `active_jobs` from total tickets, reducing the chance of busy nodes being selected for new E3s. Previously, the `Sortition` actor's `Handler` was missing match arms for `E3Failed` and `E3StageChanged`, causing these events to fall to the default `_ => ()` — the typed handlers for decrementing jobs were dead code. This has been fixed: E3Failed and E3StageChanged are now routed to their handlers, and `finalized_committees` is cleaned up in `decrement_jobs_for_e3` to prevent unbounded memory growth. | -| 6 | **Committee member expulsion** | Info | `SlashingManager` can call `expelCommitteeMember()` mid-DKG. The `Sortition` actor enriches the raw `CommitteeMemberExpelled` event with the expelled member's `party_id` (resolved from its stored `Committee` list) and re-publishes it. `ThresholdKeyshare` then uses the enriched `party_id` to update its collectors, potentially completing DKG with fewer parties. `ThresholdKeyshare` itself does not hold committee state. | -| 7 | **NodeProofAggregator stall bridge fixed** | Info | `NodeProofAggregator` no longer drops `DKGInnerProofReady` events that arrive before `ThresholdSharePending`; it prebuffers them until collection state exists. It also converts `NodeDkgFold` `ComputeRequestError` into `DKGRecursiveAggregationComplete { aggregated_proof: None }` instead of silently discarding actor state, preventing DKG proof aggregation stalls when fold workers fail or events arrive slightly out of order. | +| # | Concern | Severity | Detail | +| --- | ------------------------------------------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 | **Deregister-before-slash race** | Accepted | SlashingManager Lane B (evidence+appeal) has a window during which the operator can deregister and claim their exit. If they do, the slash executes against 0 funds. The contract comments acknowledge this as an accepted tradeoff for the appeal window design. | +| 2 | **Committee publication decentralized** | Resolved | `publishCommittee()` is permissionless. Off-chain role selection chooses the active aggregator, while on-chain C5 proof verification and the single-publish guard prevent invalid or duplicate committee publication. | +| 3 | **`gracePeriod` is dead code** | Medium | `gracePeriod` is stored and validated during config updates but never actually used in any timeout check. Either the deadlines already bake in sufficient buffer, or this is a missing feature. | +| 4 | **`activate` CLI command is misleading** | Low | Named "activate" but actually calls "register" — will fail for already-registered operators. There's no standalone way to trigger re-evaluation of active status; instead, `_updateOperatorStatus()` runs automatically inside `addTicketBalance()`, `bondLicense()`, etc. | +| 5 | **Active-job load balancing bug fixed** | Info | The Rust `NodeStateStore.available_tickets()` subtracts `active_jobs` from total tickets, reducing the chance of busy nodes being selected for new E3s. Previously, the `Sortition` actor's `Handler` was missing match arms for `E3Failed` and `E3StageChanged`, causing these events to fall to the default `_ => ()` — the typed handlers for decrementing jobs were dead code. This has been fixed: E3Failed and E3StageChanged are now routed to their handlers, and `finalized_committees` is cleaned up in `decrement_jobs_for_e3` to prevent unbounded memory growth. | +| 6 | **Committee member expulsion** | Info | `SlashingManager` can call `expelCommitteeMember()` mid-DKG. The `Sortition` actor enriches the raw `CommitteeMemberExpelled` event with the expelled member's `party_id` (resolved from its stored `Committee` list) and re-publishes it. `ThresholdKeyshare` then uses the enriched `party_id` to update its collectors, potentially completing DKG with fewer parties. `ThresholdKeyshare` itself does not hold committee state. | +| 7 | **NodeProofAggregator stall bridge fixed** | Info | `NodeProofAggregator` no longer drops `DKGInnerProofReady` events that arrive before `ThresholdSharePending`; it prebuffers them until collection state exists. It also converts `NodeDkgFold` `ComputeRequestError` into `DKGRecursiveAggregationComplete { aggregated_proof: None }` instead of silently discarding actor state, preventing DKG proof aggregation stalls when fold workers fail or events arrive slightly out of order. | diff --git a/agent/flow-trace/04_DKG_AND_COMPUTATION.md b/agent/flow-trace/04_DKG_AND_COMPUTATION.md index f5e9941464..a331fa80f2 100644 --- a/agent/flow-trace/04_DKG_AND_COMPUTATION.md +++ b/agent/flow-trace/04_DKG_AND_COMPUTATION.md @@ -179,11 +179,13 @@ Both GenPkShareAndSkSss and GenEsiSss complete │ ├─ handle_shares_generated(): │ │ -│ ├─ 1. For EACH other party j in committee: -│ │ Encrypt sk_sss[j] under party j's BFV public key -│ │ Encrypt esi_sss[*][j] under party j's BFV public key -│ │ → BfvEncryptedShares::encrypt_all() -│ │ → Only party j can decrypt their share +│ ├─ 1. For EACH collected recipient slot in sorted real-party order: +│ │ Map compact recipient slot → real party_id (expelled parties may be absent) +│ │ Encrypt sk_sss[real_party_id] under that party's BFV public key +│ │ Encrypt esi_sss[*][real_party_id] under that party's BFV public key +│ │ Leave the sender's own slot empty (own plaintext rides locally into C4) +│ │ → BfvEncryptedShares::encrypt_all_extended_for_share_indices() +│ │ → Only the mapped real party can decrypt their share │ │ │ ├─ 2. Build ThresholdShare struct: │ │ { @@ -252,7 +254,7 @@ ProofRequestActor receives ThresholdSharePending │ ├─ 5. Sign all proofs via sign_and_group_proofs(): │ → Each proof gets its own SignedProofPayload with ECDSA signature -│ → C3a/C3b proofs indexed by (recipient_party_id, row_index) +│ → C3a/C3b proofs indexed by (real recipient_party_id, row_index) │ ├─ 6. Publish events: │ ├─ PkGenerationProofSigned { e3_id, party_id, signed_proof(C1) } @@ -289,13 +291,12 @@ implements `ZkRequest::NodeDkgFold` (full per-node pipeline to a `NodeFold` proo `ZkRequest::DecryptionAggregation` (per-ciphertext `C6Fold` + C7 + `DecryptionAggregator`). `NodeProofAggregator` prebuffers `DKGInnerProofReady` proofs that arrive before `ThresholdSharePending`, drains those buffered proofs into collection state once -`ThresholdSharePending` arrives, and issues one `NodeDkgFold` request when the -full ordered proof set is available. If that `NodeDkgFold` compute request fails, -it publishes `DKGRecursiveAggregationComplete { aggregated_proof: None }` so the -downstream DKG/public-key aggregation path can terminate deterministically instead -of stalling on missing node-fold output. `PublicKeyAggregator` and -`ThresholdPlaintextAggregator` dispatch the aggregator requests instead of pairwise -folding. +`ThresholdSharePending` arrives, and issues one `NodeDkgFold` request when the full ordered proof +set is available. If that `NodeDkgFold` compute request fails, it publishes +`DKGRecursiveAggregationComplete { aggregated_proof: None }` so the downstream DKG/public-key +aggregation path can terminate deterministically instead of stalling on missing node-fold output. +`PublicKeyAggregator` and `ThresholdPlaintextAggregator` dispatch the aggregator requests instead of +pairwise folding. ### Step 6: Collect All Threshold Shares (with C2/C3 Verification) diff --git a/circuits/bin/recursive_aggregation/node_fold/src/main.nr b/circuits/bin/recursive_aggregation/node_fold/src/main.nr index b52d3a66ac..b0d3b3781b 100644 --- a/circuits/bin/recursive_aggregation/node_fold/src/main.nr +++ b/circuits/bin/recursive_aggregation/node_fold/src/main.nr @@ -106,22 +106,33 @@ fn main( } } - // C2 to C3: all (party, modulus) slots must be bound - msg_a[j*L+l] == c2a_shares[j][l] - // and msg_b[j*L+l] == c2b_shares[j][l]. + // C2 to C3: encrypted recipient slots must be bound - msg_a[j*L+l] == c2a_shares[j][l] + // and msg_b[j*L+l] == c2b_shares[j][l]. The node's own slot is intentionally not + // C3-encrypted; it is bound through the C4 plaintext/decryption path instead. // c3ab layout: key-hash prefix, then pk_a, msg_a, ct_a, pk_b, msg_b, ct_b (`C3_SLOTS` each). // c2ab layout: see `C2AB_PREFIX_LEN` / `C2_PUBLIC_LEN`. for j in 0..N_PARTIES { for l in 0..L_THRESHOLD { - let slot = j * L_THRESHOLD + l; - assert(c2ab_public[C2A_SHARES_START + slot] == c3ab_public[C3AB_MSG_A_START + slot]); - assert(c2ab_public[C2B_SHARES_START + slot] == c3ab_public[C3AB_MSG_B_START + slot]); + if party_id != (j as Field) { + let slot = j * L_THRESHOLD + l; + assert( + c2ab_public[C2A_SHARES_START + slot] == c3ab_public[C3AB_MSG_A_START + slot], + ); + assert( + c2ab_public[C2B_SHARES_START + slot] == c3ab_public[C3AB_MSG_B_START + slot], + ); + } } } let mut c3_pk: [Field; N_PARTIES] = [0; N_PARTIES]; for j in 0..N_PARTIES { - // pk is per-party (same DKG key across all moduli of the same recipient): use slot j*L+0. - c3_pk[j] = c3ab_public[C3AB_PK_A_START + j * L_THRESHOLD]; + if party_id == (j as Field) { + c3_pk[j] = c0_pk; + } else { + // pk is per-party (same DKG key across all moduli of the same recipient): use slot j*L+0. + c3_pk[j] = c3ab_public[C3AB_PK_A_START + j * L_THRESHOLD]; + } } let mut c2a: [[Field; L_THRESHOLD]; N_PARTIES] = [[0; L_THRESHOLD]; N_PARTIES]; 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 6efe35d128..cab830ab09 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}, @@ -138,6 +139,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, @@ -426,6 +432,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 { @@ -441,6 +451,7 @@ impl ThresholdKeyshare { pending_shares: Vec::new(), pending_share_decryption_data: None, pending_c4_verification_shares: None, + pending_own_dkg_shares: None, } } } @@ -452,6 +463,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, @@ -467,6 +495,7 @@ impl ThresholdKeyshare { ); let e3_id = state.e3_id.clone(); let threshold_n = state.threshold_n; + let own_party_id = state.party_id; let timeout = resolve_timeout( DkgTimeoutPhase::ThresholdShareCollection, state.dkg_started_at_unix_secs, @@ -478,7 +507,13 @@ impl ThresholdKeyshare { timeout.description ); let addr = self.decryption_key_collector.get_or_insert_with(|| { - ThresholdShareCollector::setup(self_addr, threshold_n, e3_id, timeout.duration) + ThresholdShareCollector::setup( + self_addr, + threshold_n, + own_party_id, + e3_id, + timeout.duration, + ) }); Ok(addr.clone()) } @@ -1060,6 +1095,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()?; @@ -1067,6 +1108,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, @@ -1121,6 +1164,20 @@ impl ThresholdKeyshare { .map_err(|e| anyhow!("Failed to deserialize BFV public key: {:?}", e)) }) .collect::>()?; + let recipient_party_ids: Vec = encryption_keys.iter().map(|k| k.party_id).collect(); + let recipient_share_indices: Vec = recipient_party_ids + .iter() + .map(|&recipient_party_id| recipient_party_id as usize) + .collect(); + let own_idx = recipient_party_ids + .iter() + .position(|&recipient_party_id| recipient_party_id == party_id) + .ok_or_else(|| { + anyhow!( + "own party {} missing from collected encryption keys", + party_id + ) + })?; // Decrypt our shares from local storage let decrypted_sk_sss: SharedSecret = sk_sss.decrypt(&self.cipher)?; @@ -1144,19 +1201,59 @@ impl ThresholdKeyshare { }) .collect::>()?; - // Encrypt shares for all recipients using BFV (extended to capture 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, + // 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_for_share_indices( + &decrypted_sk_sss, + &recipient_pks, + &recipient_share_indices, + ¶ms, + &mut rng, + Some(own_idx), + )?; + 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_for_share_indices( + esi, + &recipient_pks, + &recipient_share_indices, + ¶ms, + &mut rng, + Some(own_idx), + ) }) .collect::>>()? .into_iter() @@ -1201,9 +1298,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(); for (recipient_idx, recipient_witnesses) in sk_witnesses.iter().enumerate() { + if recipient_idx == own_idx { + continue; + } + let recipient_party_id = recipient_share_indices[recipient_idx]; for (row_idx, witness) in recipient_witnesses.iter().enumerate() { sk_share_encryption_requests.push(ShareEncryptionProofRequest { share_row_raw: SensitiveBytes::new( @@ -1221,17 +1324,21 @@ impl ThresholdKeyshare { dkg_input_type: DkgInputType::SecretKey, params_preset: threshold_preset, committee_size: derived_committee_size, - recipient_party_id: recipient_idx, + recipient_party_id, row_index: row_idx, esi_index: 0, }); } } - // 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; + } + let recipient_party_id = recipient_share_indices[recipient_idx]; for (row_idx, witness) in recipient_witnesses.iter().enumerate() { e_sm_share_encryption_requests.push(ShareEncryptionProofRequest { share_row_raw: SensitiveBytes::new( @@ -1249,7 +1356,7 @@ impl ThresholdKeyshare { dkg_input_type: DkgInputType::SmudgingNoise, params_preset: threshold_preset, committee_size: derived_committee_size, - recipient_party_id: recipient_idx, + recipient_party_id, row_index: row_idx, esi_index: esi_idx, }); @@ -1266,9 +1373,6 @@ impl ThresholdKeyshare { e_sm_share_encryption_requests.len() ); - // Collect real party IDs in positional order (indices match encrypt_all_extended output) - let recipient_party_ids: Vec = encryption_keys.iter().map(|k| k.party_id).collect(); - // Publish ThresholdSharePending - ProofRequestActor will generate proof, sign, and publish ThresholdShareCreated self.bus.publish( ThresholdSharePending { @@ -1322,24 +1426,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(); @@ -1579,6 +1679,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 @@ -1592,7 +1693,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| { @@ -1602,48 +1725,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", @@ -1652,7 +1738,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 => { @@ -1673,7 +1758,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) { @@ -1706,9 +1790,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 { @@ -1722,26 +1806,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 { @@ -1755,7 +1845,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]; @@ -1771,8 +1861,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 }; @@ -1784,8 +1874,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 @@ -1801,6 +1895,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| { @@ -1839,17 +1940,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 c36d261f61..92c8c2a438 100644 --- a/crates/keyshare/src/threshold_share_collector.rs +++ b/crates/keyshare/src/threshold_share_collector.rs @@ -4,6 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. +use core::time; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -45,8 +46,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 { @@ -73,15 +73,17 @@ pub struct ThresholdShareCollector { } impl ThresholdShareCollector { + /// 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, timeout: Duration, ) -> 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..aad686c012 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,104 @@ 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 + ), + )); + } + let n = dkg_params.degree(); + for (row_idx, row) in own_plaintext_share.iter().enumerate() { + if row.len() != n { + return Err(make_zk_error( + &request, + format!( + "own_plaintext_share[{}] has {} coefficients, expected {}", + row_idx, + row.len(), + n + ), + )); + } } - // 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..be1d8dffef 100644 --- a/crates/trbfv/src/shares/bfv_encrypted.rs +++ b/crates/trbfv/src/shares/bfv_encrypted.rs @@ -4,7 +4,7 @@ // without even the implied warranty of MERCHANTABILITY // or FITNESS FOR A PARTICULAR PURPOSE. -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use derivative::Derivative; use e3_utils::utility_types::ArcBytes; use fhe::bfv::{BfvParameters, Ciphertext, Encoding, Plaintext, PublicKey, SecretKey}; @@ -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,59 +230,105 @@ 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 recipient_share_indices: Vec<_> = (0..recipient_pks.len()).collect(); + Self::encrypt_all_extended_for_share_indices( + secret, + recipient_pks, + &recipient_share_indices, + params, + rng, + skip_idx, + ) + } + + /// Encrypt shares for all recipient slots while selecting Shamir rows by real party index. + /// + /// `recipient_share_indices[slot]` is the row in `secret` encrypted for `recipient_pks[slot]`. + /// This supports sparse recipient vectors after expulsions while keeping the encrypted-share + /// vector compact and positional. + pub fn encrypt_all_extended_for_share_indices( + secret: &SharedSecret, + recipient_pks: &[PublicKey], + recipient_share_indices: &[usize], + params: &Arc, + rng: &mut R, + skip_idx: Option, ) -> Result<(Self, Vec>)> { + if recipient_pks.len() != recipient_share_indices.len() { + bail!( + "recipient_pks length ({}) must match recipient_share_indices length ({})", + recipient_pks.len(), + recipient_share_indices.len() + ); + } + 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 { - let share = secret - .extract_party_share(party_id) - .context(format!("Failed to extract share for party {}", party_id))?; + for (slot_idx, (recipient_pk, &share_idx)) in recipient_pks + .iter() + .zip(recipient_share_indices.iter()) + .enumerate() + { + if Some(slot_idx) == skip_idx { + shares.push(None); + all_witnesses.push(Vec::new()); + continue; + } + + let share = secret.extract_party_share(share_idx).context(format!( + "Failed to extract share for party {} at recipient slot {}", + share_idx, slot_idx + ))?; let (encrypted, witnesses) = - BfvEncryptedShare::encrypt_extended(&share, &recipient_pks[party_id], params, rng)?; + BfvEncryptedShare::encrypt_extended(&share, recipient_pk, 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() } @@ -356,4 +400,45 @@ mod tests { // Verify coefficients match assert_eq!(sk.coeffs, sk_restored.coeffs); } + + #[test] + fn test_encrypt_all_extended_for_share_indices_uses_real_share_rows() { + let params = BfvParamSet::from(BfvPreset::InsecureDkg512).build_arc(); + let mut rng = OsRng; + + let _sk_one = SecretKey::random(¶ms, &mut rng); + let pk_one = PublicKey::new(&_sk_one, &mut rng); + let sk_two = SecretKey::random(¶ms, &mut rng); + let pk_two = PublicKey::new(&sk_two, &mut rng); + + let degree = params.degree(); + let mut data = Array2::zeros((3, degree)); + for party_id in 0..3 { + for coeff_idx in 0..degree { + data[[party_id, coeff_idx]] = ((party_id as u64) + 1) * 10_000 + coeff_idx as u64; + } + } + let secret = SharedSecret::new(vec![data]); + + let (encrypted, witnesses) = BfvEncryptedShares::encrypt_all_extended_for_share_indices( + &secret, + &[pk_one, pk_two], + &[1, 2], + ¶ms, + &mut rng, + Some(0), + ) + .expect("sparse recipient encryption should succeed"); + + assert!(encrypted.clone_share(0).is_none()); + assert!(witnesses[0].is_empty()); + + let decrypted = encrypted + .clone_share(1) + .expect("slot 1 should be encrypted") + .decrypt(&sk_two, ¶ms, degree) + .expect("recipient should decrypt its share"); + let expected = secret.extract_party_share(2).unwrap(); + assert_eq!(decrypted.deref(), expected.deref()); + } } 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..d1508af66f 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,78 @@ 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]) + .map_err(|e| { + CircuitsErrors::Other(format!( + "failed to decrypt honest ciphertext at modulus {}: {:?}", + mod_idx, e + )) + })?; + 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 +348,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 +369,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..0b73f4630d 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,45 @@ 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(), - ) { + match share.extract_for_party(positional_idx) { + Some(party_share) => { + let proof_key = real_party_id as usize; + let c3a_proofs = signed_c3a_map.get(&proof_key).cloned().unwrap_or_default(); + let c3b_proofs = signed_c3b_map.get(&proof_key).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 if real_party_id == party_id => { + // Own slot is sparse (no self-encryption); nothing to publish. + trace!( + "Skipping ThresholdShareCreated for own slot (party {} idx {})", + real_party_id, + positional_idx + ); + } + None => { error!( - "Failed to publish ThresholdShareCreated for party {} (idx {}): {err}", - real_party_id, positional_idx + "Missing encrypted share for recipient party {} (idx {}) from sender party {}; ThresholdShareCreated will not be published for that recipient", + real_party_id, positional_idx, party_id ); } - } else { - error!("Failed to extract share for index {}", positional_idx); } } } diff --git a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs index aacac9c409..5e4122e67d 100644 --- a/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs +++ b/crates/zk-prover/tests/node_fold_correlated_e2e_tests.rs @@ -128,7 +128,7 @@ fn triplicate_honest_rows(mut d: ShareDecryptionCircuitData) -> ShareDecryptionC } #[tokio::test] -async fn node_fold_correlated_proves_and_verifies() { +async fn node_fold_correlated_sparse_self_slot_proves_and_verifies() { let Some(bb) = find_bb().await else { println!("skipping: bb not found"); return; @@ -295,10 +295,17 @@ async fn node_fold_correlated_proves_and_verifies() { let total_slots = c3_fold_total_slots_from_compiled_json(); assert_eq!(total_slots, 6, "Micro / insecure preset uses 3×2 C3 slots"); + let slots_per_party = total_slots / committee.n as usize; + let own_party_id = 0usize; let mut c3a_inners = Vec::new(); let mut c3b_inners = Vec::new(); + let mut slot_indices = Vec::new(); for slot in 0..total_slots { + if slot / slots_per_party == own_party_id { + continue; + } + let da = share_encryption_for_slot( preset, &dkg_sk, @@ -342,9 +349,10 @@ async fn node_fold_correlated_proves_and_verifies() { ) .expect("C3b inner"), ); + slot_indices.push(slot as u32); } + assert_eq!(slot_indices, vec![2, 3, 4, 5]); - let slot_indices: Vec = (0..total_slots as u32).collect(); let c3a_folded = generate_sequential_c3_fold( &prover, &c3a_inners, @@ -377,6 +385,19 @@ async fn node_fold_correlated_proves_and_verifies() { let c3a_pub = proof_public_fields(&c3a_folded); let c3b_pub = proof_public_fields(&c3b_folded); + let c3_prefix_len = c3a_pub.len() - (3 * total_slots); + for slot in 0..slots_per_party { + assert_eq!(c3a_pub[c3_prefix_len + slot], field_str_zero()); + assert_eq!( + c3a_pub[c3_prefix_len + total_slots + slot], + field_str_zero() + ); + assert_eq!(c3b_pub[c3_prefix_len + slot], field_str_zero()); + assert_eq!( + c3b_pub[c3_prefix_len + total_slots + slot], + field_str_zero() + ); + } let c3ab = C3abFoldWitness { c3a_vk: c3a_vk.verification_key, c3a_proof: fold_witness_field_strings(&c3a_folded.data).expect("c3a fold proof"),