Skip to main content

confium_tc_cmp20/
keygen.rs

1//! CMP20 non-interactive distributed key generation over P-256.
2//!
3//! CMP20's headline DKG improvement over GG18 is that key generation is
4//! **non-interactive**: a single broadcast round suffices. Each party
5//! deals a Feldman VSS and bundles, into one outgoing message, both its
6//! commitment list (broadcast) and every peer's polynomial evaluation
7//! (directed). Recipients verify everything locally in the same round
8//! and assemble their combined share + the joint public key without any
9//! further interaction.
10//!
11//! After the round every party holds a combined share
12//! `x_i = sum_d f_d(i)` and the joint public key
13//! `X = prod_d g^{secret_d} = g^{sum secret_d}`. The combined secret is
14//! never reconstructed.
15//!
16//! ## Rounds
17//!
18//! - **Round 1 — deal and assemble.** Broadcast Feldman commitments plus
19//!   a directed share for every peer, all tagged for round 1. On
20//!   receipt, verify each dealer's commitments against the bundled
21//!   evaluation addressed to us, sum the verified shares into `x_i`,
22//!   and compute the joint public key. Complete.
23
24use elliptic_curve::PrimeField;
25use elliptic_curve::rand_core::UnwrapErr;
26use getrandom::SysRng;
27use p256::{AffinePoint, ProjectivePoint, Scalar};
28
29use confium_tc::Result;
30use confium_tc::message::Message;
31use confium_tc::registry::{RoundResult, SessionImpl};
32use confium_tc::session::SessionParams;
33
34use crate::error::{Cmp20ErrorCode, scheme_error};
35use crate::share::{Cmp20Share, SHARE_BYTES};
36use crate::vss::FeldmanVss;
37
38/// CMP20 DKG scheme over P-256. Registered as `CMP20-ECDSA-P256`.
39pub struct Cmp20DkgP256;
40
41impl Cmp20DkgP256 {
42    pub fn build_session(params: &SessionParams) -> Result<Box<dyn SessionImpl>> {
43        let party_id = params.parties.get(params.this_party_idx)?.id.clone();
44        let n = params.parties.len();
45        let t = params.threshold as usize;
46        let party_idx_1based = (params.this_party_idx + 1) as u32;
47        let party_ids: Vec<String> = params
48            .parties
49            .parties()
50            .iter()
51            .map(|p| p.id.clone())
52            .collect();
53
54        let vss = FeldmanVss::deal(&mut UnwrapErr(SysRng), n, t);
55
56        Ok(Box::new(Cmp20DkgSession {
57            party_id,
58            party_idx_1based,
59            party_ids,
60            n,
61            t,
62            our_vss: vss,
63            received_shares: Vec::new(),
64            joint_public_key: None,
65            our_combined_share: None,
66            round_done: 0,
67        }))
68    }
69}
70
71pub struct Cmp20DkgSession {
72    party_id: String,
73    party_idx_1based: u32,
74    party_ids: Vec<String>,
75    n: usize,
76    t: usize,
77    our_vss: FeldmanVss,
78    received_shares: Vec<(u64, Scalar)>,
79    joint_public_key: Option<AffinePoint>,
80    our_combined_share: Option<Scalar>,
81    round_done: u8,
82}
83
84const TAG_COMMITMENTS: u8 = 0xCC;
85const TAG_SHARE: u8 = 0xCE;
86
87impl Cmp20DkgSession {
88    /// Single non-interactive round: broadcast commitments and direct-send
89    /// every peer its evaluation in one batch. All messages are tagged
90    /// for round 1 — the framework delivers them all back in the same
91    /// `round` call, where verification and assembly happen.
92    fn round1_deal_and_assemble(&mut self, incoming: &[Message]) -> Result<RoundResult> {
93        let mut outgoing = Vec::with_capacity(1 + self.n);
94
95        // Broadcast our commitment list.
96        let commitments_bytes = FeldmanVss::encode_commitments(&self.our_vss.commitments);
97        let mut bc_payload = Vec::with_capacity(3 + commitments_bytes.len());
98        bc_payload.push(TAG_COMMITMENTS);
99        bc_payload.push(self.party_idx_1based as u8);
100        bc_payload.push(self.our_vss.commitments.len() as u8);
101        bc_payload.extend_from_slice(&commitments_bytes);
102        outgoing.push(Message::broadcast(&self.party_id, 1, bc_payload));
103
104        // Directed-send each peer its polynomial evaluation in the same
105        // round. CMP20's non-interactive property comes from bundling
106        // these with the broadcast above.
107        for (peer_pos, peer_id) in self.party_ids.iter().enumerate() {
108            if peer_id == &self.party_id {
109                continue;
110            }
111            let eval = self.our_vss.shares[peer_pos];
112            let mut payload = Vec::with_capacity(2 + 32);
113            payload.push(TAG_SHARE);
114            payload.push(self.party_idx_1based as u8);
115            payload.extend_from_slice(&eval.to_bytes());
116            outgoing.push(Message::directed(&self.party_id, peer_id, 1, payload));
117        }
118
119        // Process incoming messages in the same round. On the first call
120        // `incoming` is empty (no peer has broadcast yet) — we emit our
121        // deal and stay incomplete so the framework re-enters with the
122        // peer messages. When peer messages are present we verify and
123        // assemble.
124        if incoming.is_empty() {
125            return Ok(RoundResult::new(outgoing, false));
126        }
127
128        let mut commitments_by_dealer: Vec<(u64, Vec<AffinePoint>)> = Vec::new();
129        let mut own_evaluations: Vec<(u64, Scalar)> = Vec::new();
130
131        for msg in incoming {
132            if msg.round != 1 || msg.payload.is_empty() {
133                continue;
134            }
135            let tag = msg.payload[0];
136            match tag {
137                TAG_COMMITMENTS => {
138                    if msg.payload.len() < 3 {
139                        return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
140                    }
141                    let dealer_idx = msg.payload[1] as u64;
142                    let num_c = msg.payload[2] as usize;
143                    let expected = 3 + num_c * 33;
144                    if msg.payload.len() != expected {
145                        return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
146                    }
147                    let cs = FeldmanVss::decode_commitments(&msg.payload[3..expected])
148                        .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
149                    if cs.len() != num_c || cs.len() < self.t {
150                        return Err(scheme_error(Cmp20ErrorCode::VSS_VERIFY_FAILED));
151                    }
152                    commitments_by_dealer.push((dealer_idx, cs));
153                }
154                TAG_SHARE => {
155                    if msg.payload.len() != 2 + 32 {
156                        return Err(scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE));
157                    }
158                    if !msg.is_for(&self.party_id) {
159                        continue;
160                    }
161                    let dealer_idx = msg.payload[1] as u64;
162                    let mut eval_bytes = [0u8; 32];
163                    eval_bytes.copy_from_slice(&msg.payload[2..34]);
164                    let fb: p256::FieldBytes = eval_bytes.into();
165                    let eval: Scalar = Option::from(Scalar::from_repr(fb))
166                        .ok_or_else(|| scheme_error(Cmp20ErrorCode::BAD_ROUND_MESSAGE))?;
167                    own_evaluations.push((dealer_idx, eval));
168                }
169                _ => continue,
170            }
171        }
172
173        // Fold in our own self-evaluation + self-commitments so the
174        // verification loop below covers our own deal uniformly.
175        own_evaluations.push((
176            self.party_idx_1based as u64,
177            self.our_vss.shares[self.party_idx_1based as usize - 1],
178        ));
179        let self_idx = self.party_idx_1based as u64;
180        if !commitments_by_dealer.iter().any(|(d, _)| *d == self_idx) {
181            commitments_by_dealer.push((self_idx, self.our_vss.commitments.clone()));
182        }
183
184        let mut verified_shares: Vec<(u64, Scalar)> = Vec::new();
185        for (dealer_idx, eval) in &own_evaluations {
186            let commitments = commitments_by_dealer
187                .iter()
188                .find(|(d, _)| d == dealer_idx)
189                .map(|(_, c)| c.as_slice())
190                .ok_or_else(|| scheme_error(Cmp20ErrorCode::VSS_VERIFY_FAILED))?;
191            if !FeldmanVss::verify_share(commitments, self.party_idx_1based as u64, *eval) {
192                return Err(scheme_error(Cmp20ErrorCode::VSS_VERIFY_FAILED));
193            }
194            verified_shares.push((*dealer_idx, *eval));
195        }
196
197        let distinct_dealers: std::collections::HashSet<u64> =
198            verified_shares.iter().map(|(d, _)| *d).collect();
199        if distinct_dealers.len() < self.t {
200            return Err(scheme_error(Cmp20ErrorCode::BELOW_THRESHOLD));
201        }
202
203        let combined: Scalar = verified_shares
204            .iter()
205            .fold(Scalar::ZERO, |acc, &(_, ev)| acc + ev);
206        self.received_shares = verified_shares;
207        self.our_combined_share = Some(combined);
208
209        // Joint public key X = product over all dealers of C_0^{(d)}.
210        let mut joint = ProjectivePoint::IDENTITY;
211        for (_, cs) in &commitments_by_dealer {
212            joint += ProjectivePoint::from(cs[0]);
213        }
214        self.joint_public_key = Some(joint.to_affine());
215
216        Ok(RoundResult::done())
217    }
218}
219
220impl SessionImpl for Cmp20DkgSession {
221    fn round(&mut self, incoming: &[Message]) -> Result<RoundResult> {
222        self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
223            confium_tc::error::RoundOverflowSnafu {
224                round: self.round_done,
225            }
226            .build()
227        })?;
228        match self.round_done {
229            // CMP20 DKG is logically one round, but the framework drives
230            // it as two `round` calls: the first emits our deal with no
231            // incoming, the second receives peer deals and assembles.
232            // Both calls dispatch to the same handler, which branches on
233            // whether `incoming` is populated.
234            1 | 2 => self.round1_deal_and_assemble(incoming),
235            other => Err(confium_tc::error::RoundOverflowSnafu { round: other }.build()),
236        }
237    }
238
239    fn result(&self) -> Result<Vec<u8>> {
240        if self.round_done < 1 || self.our_combined_share.is_none() {
241            return Err(confium_tc::error::SessionNotCompleteSnafu {}.build());
242        }
243        let combined = self
244            .our_combined_share
245            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
246        let pk = self
247            .joint_public_key
248            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
249        let x_i: p256::NonZeroScalar = Option::from(p256::NonZeroScalar::new(combined))
250            .ok_or_else(|| scheme_error(Cmp20ErrorCode::INTERNAL))?;
251        let share = Cmp20Share::from_parts(x_i, pk, self.party_idx_1based);
252        Ok(share.to_bytes())
253    }
254
255    fn destroy(&mut self) {
256        if let Some(s) = self.our_combined_share.take() {
257            let _ = s;
258        }
259        for (_, s) in self.received_shares.drain(..) {
260            let _ = s;
261        }
262        self.our_vss.shares.fill(Scalar::ZERO);
263    }
264}
265
266/// Parse a DKG-produced share blob.
267pub fn parse_share(bytes: &[u8]) -> Result<Cmp20Share> {
268    if bytes.len() != SHARE_BYTES {
269        return Err(scheme_error(Cmp20ErrorCode::BAD_SHARE));
270    }
271    Cmp20Share::from_bytes(bytes)
272}
273
274#[cfg(test)]
275pub(crate) fn reconstruct_secret_for_test(shares: &[Cmp20Share]) -> Scalar {
276    use crate::lagrange;
277    let pairs: Vec<(Scalar, Scalar)> = shares
278        .iter()
279        .map(|s| (Scalar::from(s.party_idx), s.scalar()))
280        .collect();
281    lagrange::lagrange_weighted_sum(&pairs)
282}
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287    use confium_tc::party::{Party, PartyList};
288    use confium_tc::share::Share;
289    use elliptic_curve::sec1::ToSec1Point;
290
291    fn params(n: usize, t: u32, idx: usize) -> SessionParams {
292        let roster: Vec<Party> = (0..n).map(|i| Party::inproc(format!("p{}", i))).collect();
293        SessionParams {
294            scheme: crate::DKG_SCHEME_NAME.to_string(),
295            parties: PartyList::from_parties(roster),
296            threshold: t,
297            this_party_idx: idx,
298            local_share: None,
299            message: None,
300        }
301    }
302
303    /// Drive the non-interactive DKG to completion. The first `round`
304    /// call emits each party's deal; the second receives peer deals and
305    /// assembles. From the protocol's perspective this is still a single
306    /// broadcast round — the two calls reflect the framework's
307    /// send-then-receive cadence, not an extra protocol round.
308    fn run_dkg(n: usize, t: u32) -> Vec<Cmp20Share> {
309        let party_ids: Vec<String> = (0..n).map(|i| format!("p{}", i)).collect();
310        let mut sessions: Vec<Box<dyn SessionImpl>> = (0..n)
311            .map(|i| {
312                let p = params(n, t, i);
313                Cmp20DkgP256::build_session(&p).expect("session")
314            })
315            .collect();
316
317        // First pass: every party emits its deal (no incoming yet).
318        let mut outgoing_r1: Vec<Vec<Message>> = Vec::new();
319        for sess in sessions.iter_mut() {
320            let r = sess.round(&[]).expect("round 1 deal");
321            assert!(!r.complete, "round 1 must not complete without peer input");
322            outgoing_r1.push(r.outgoing);
323        }
324
325        // Route each peer's messages to its recipients.
326        let mut incoming: Vec<Vec<Message>> = vec![Vec::new(); n];
327        for (sender_pos, outs) in outgoing_r1.iter().enumerate() {
328            for m in outs {
329                for (recv_pos, pid) in party_ids.iter().enumerate() {
330                    if recv_pos == sender_pos {
331                        continue;
332                    }
333                    if m.is_for(pid) {
334                        incoming[recv_pos].push(m.clone());
335                    }
336                }
337            }
338        }
339
340        // Second pass: each party verifies and assembles. This is still
341        // the same protocol round — no new messages are emitted.
342        for (i, sess) in sessions.iter_mut().enumerate() {
343            let r = sess.round(&incoming[i]).expect("round 1 assemble");
344            assert!(
345                r.complete,
346                "DKG must complete after the single broadcast round"
347            );
348        }
349
350        sessions
351            .iter()
352            .map(|s| {
353                let bytes = s.result().expect("result");
354                Cmp20Share::from_bytes(&bytes).expect("share decodes")
355            })
356            .collect()
357    }
358
359    #[test]
360    fn dkg_two_of_three_produces_consistent_shares() {
361        let shares = run_dkg(3, 2);
362        assert_eq!(shares.len(), 3);
363        let pk0 = shares[0].public_key;
364        for s in &shares[1..] {
365            let a = pk0.to_sec1_point(true);
366            let b = s.public_key.to_sec1_point(true);
367            assert_eq!(a.as_bytes(), b.as_bytes(), "joint public key must match");
368        }
369        let secret_01 = reconstruct_secret_for_test(&shares[0..2]);
370        let secret_02 = reconstruct_secret_for_test(&[shares[0].clone(), shares[2].clone()]);
371        let secret_12 = reconstruct_secret_for_test(&shares[1..3]);
372        assert_eq!(secret_01, secret_02);
373        assert_eq!(secret_02, secret_12);
374        let g = ProjectivePoint::GENERATOR;
375        let expected_pk = (g * secret_01).to_affine();
376        let got_pk = shares[0].public_key.to_sec1_point(true);
377        let want_pk = expected_pk.to_sec1_point(true);
378        assert_eq!(got_pk.as_bytes(), want_pk.as_bytes());
379    }
380
381    #[test]
382    fn dkg_three_of_three_produces_consistent_shares() {
383        let shares = run_dkg(3, 3);
384        let secret = reconstruct_secret_for_test(&shares);
385        let g = ProjectivePoint::GENERATOR;
386        let pk = (g * secret).to_affine().to_sec1_point(true);
387        assert_eq!(
388            pk.as_bytes(),
389            shares[0].public_key.to_sec1_point(true).as_bytes()
390        );
391    }
392
393    #[test]
394    fn dkg_share_is_loadable_as_framework_share() {
395        let shares = run_dkg(3, 2);
396        let bytes = shares[0].to_bytes();
397        let fw = Share::new(crate::DKG_SCHEME_NAME, bytes);
398        assert_eq!(fw.scheme(), crate::DKG_SCHEME_NAME);
399        let rt = Share::from_bytes(&fw.to_bytes()).expect("framework decode");
400        assert_eq!(rt.scheme(), crate::DKG_SCHEME_NAME);
401        let inner = Cmp20Share::from_bytes(rt.bytes()).expect("inner decode");
402        assert_eq!(inner.party_idx, shares[0].party_idx);
403    }
404}