Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions crates/events/src/enclave_event/compute_request/zk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<ArcBytes>,
/// 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<Vec<u64>>` 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.
Expand Down
249 changes: 167 additions & 82 deletions crates/keyshare/src/threshold_keyshare.rs

Large diffs are not rendered by default.

13 changes: 9 additions & 4 deletions crates/keyshare/src/threshold_share_collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -85,10 +84,16 @@ pub struct ThresholdShareCollector {
}

impl ThresholdShareCollector {
pub fn setup(parent: Addr<ThresholdKeyshare>, total: u64, e3_id: E3id) -> Addr<Self> {
/// Excludes `own_party_id` from `todo` (own share is consumed locally for C4).
pub fn setup(
parent: Addr<ThresholdKeyshare>,
total: u64,
own_party_id: u64,
e3_id: E3id,
) -> Addr<Self> {
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(),
Expand Down
73 changes: 56 additions & 17 deletions crates/multithread/src/multithread.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1122,61 +1122,100 @@ fn handle_dkg_share_decryption_proof(
req: DkgShareDecryptionProofRequest,
request: ComputeRequest,
) -> Result<ComputeResponse, ComputeRequestError> {
// 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)
.map_err(|e| make_zk_error(&request, format!("sk_bfv decrypt: {}", e)))?;
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<Vec<Ciphertext>> [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<Ciphertext>> = 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<Ciphertext>> = 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<Option<Vec<Ciphertext>>> = 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<Vec<u64>>` 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<Vec<u64>> = 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
),
));
}
Comment on lines +1194 to 1210
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the inner N dimension of own_share_raw too.

This only checks the outer [L] shape. If any decoded row has the wrong coefficient count, the circuit gets variable-length plaintext shares and fails much later with a less actionable proof/witness error. Validate each row against the BFV degree here.

Suggested fix
     let own_plaintext_share: Vec<Vec<u64>> = 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 expected_degree = dkg_params.degree();
+    if let Some((row_idx, row)) = own_plaintext_share
+        .iter()
+        .enumerate()
+        .find(|(_, row)| row.len() != expected_degree)
+    {
+        return Err(make_zk_error(
+            &request,
+            format!(
+                "own_plaintext_share[{}] has {} coefficients, expected {}",
+                row_idx,
+                row.len(),
+                expected_degree
+            ),
+        ));
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/multithread/src/multithread.rs` around lines 1194 - 1210, After
deserializing own_share_raw into own_plaintext_share you must also validate each
inner row length matches the BFV coefficient count (the expected number of
coefficients used for plaintext polynomials) to avoid variable-length shares;
iterate over own_plaintext_share (use enumerate to get the row index), and for
each row check row.len() == expected_coefficient_count (the BFV/poly coefficient
count used elsewhere in the module), returning Err(make_zk_error(&request,
format!("own_plaintext_share[{}] has {} coefficients, expected {}", i,
row.len(), expected_coefficient_count))) on mismatch instead of proceeding.


// 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();
Expand Down
53 changes: 30 additions & 23 deletions crates/trbfv/src/shares/bfv_encrypted.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<BfvEncryptedShare>,
/// Encrypted shares indexed by recipient party_id (0-based).
/// `None` means the recipient slot was skipped (no ciphertext produced).
shares: Vec<Option<BfvEncryptedShare>>,
}

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<R: RngCore + CryptoRng>(
secret: &SharedSecret,
recipient_pks: &[PublicKey],
Expand All @@ -232,59 +230,68 @@ 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<R: RngCore + CryptoRng>(
secret: &SharedSecret,
recipient_pks: &[PublicKey],
params: &Arc<BfvParameters>,
rng: &mut R,
skip_idx: Option<usize>,
) -> Result<(Self, Vec<Vec<BfvEncryptionWitness>>)> {
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))?;

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<BfvEncryptedShare> {
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> {
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()
}
Expand Down
17 changes: 12 additions & 5 deletions crates/zk-helpers/src/circuits/dkg/share_decryption/circuit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,20 @@ impl Circuit for ShareDecryptionCircuit {
const DKG_INPUT_TYPE: Option<DkgInputType> = 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<Vec<Ciphertext>>,
/// 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<Option<Vec<Ciphertext>>>,
/// 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<Vec<u64>>,
/// Which input type (SecretKey or SmudgingNoise) to resolve circuit path.
pub dkg_input_type: DkgInputType,
}
Loading
Loading