Skip to main content

confium_tc_frost_ed25519/
dkg.rs

1//! Distributed key generation for FROST-ed25519.
2//!
3//! Implements a Pedersen / Feldman VSS-based DKG that produces:
4//!
5//! - a per-party secret share `s_i` (32-byte scalar, little-endian),
6//! - the aggregate public key `A = (sum of constant terms) · B`
7//!   (32-byte compressed point),
8//!
9//! such that any `T` of the `N` shares can produce FROST signatures that
10//! verify against `A` under standard ed25519.
11//!
12//! ## Protocol
13//!
14//! Two rounds:
15//!
16//! - **Round 1** — each party generates a fresh random degree-`T-1`
17//!   polynomial `f_i(X)`, broadcasts its Feldman commitment list
18//!   `C_{i,k} = a_{i,k} · B`, and sends each peer `j` the directed share
19//!   `f_i(j)`.
20//!
21//! - **Round 2** — each party verifies every received share against its
22//!   sender's commitment list (rejecting byzantine parties with a proof),
23//!   sums all valid shares into its aggregate share `s_i`, and computes
24//!   the aggregate public key as the sum of all parties' `C_{i,0}`.
25//!
26//! ## Output shape
27//!
28//! The DKG session's `result()` returns a length-prefixed blob of the form
29//!
30//! ```text
31//!   pubkey_len:u32 BE | pubkey[32] | share_len:u32 BE | share[32]
32//! ```
33//!
34//! The framework's [`confium_tc::Session::dkg_public_key`] only surfaces
35//! the first 32 bytes (the public key); this crate's [`parse_output`]
36//! helper parses the full blob for callers that need the share as well
37//! (e.g. feeding it into a subsequent FROST signing session).
38//!
39//! ## Deviations from the textbook protocol
40//!
41//! - **No complaint round.** A party whose VSS share fails verification
42//!   is silently excluded from this party's aggregate; that party's
43//!   commitment list is also excluded from the public-key sum. This means
44//!   all honest parties still converge on the same key as long as they
45//!   observe the same broadcasts. A future revision should add an
46//!   explicit complaint / expose-the-liar round as the spec requires.
47//! - **No secure channel abstraction.** The directed share `f_i(j)` is
48//!   transported in the clear inside a [`confium_tc::Message`] addressed
49//!   to party `j`. In production this requires an authenticated, private
50//!   transport; the framework provides message routing but not
51//!   confidentiality. This matches the framework's "transport is a
52//!   separate concern" stance (see TODO.roadmap/05).
53
54use curve25519_dalek::edwards::EdwardsPoint;
55use curve25519_dalek::rand_core::UnwrapErr;
56use curve25519_dalek::scalar::Scalar;
57use curve25519_dalek::traits::Identity;
58
59use crate::error::{
60    CODE_BELOW_THRESHOLD, CODE_MALFORMED_MESSAGE, CODE_MALFORMED_SHARE, CODE_ROSTER_CONFIG,
61    CODE_ROUND_OVERFLOW, CODE_SESSION_NOT_COMPLETE, FrostError, Result,
62};
63use crate::group;
64use crate::polynomial::{CommitmentList, Polynomial};
65
66/// Canonical scheme name advertised through the registry.
67pub const SCHEME_NAME: &str = "FROST-ed25519-dkg";
68
69/// Message type byte tags used inside payloads.
70const MSG_ROUND1_BROADCAST: u8 = 0x01;
71const MSG_ROUND1_DIRECTED: u8 = 0x02;
72
73// ---------------------------------------------------------------------------
74// Scheme + registration
75// ---------------------------------------------------------------------------
76
77/// FROST-ed25519 distributed key generation scheme.
78///
79/// Stateless; all per-session state lives in the internal `DkgSession`.
80pub struct FrostEd25519Dkg;
81
82impl confium_tc::registry::TcScheme for FrostEd25519Dkg {
83    fn name(&self) -> &'static str {
84        SCHEME_NAME
85    }
86
87    fn kind(&self) -> confium_tc::registry::TcSchemeKind {
88        confium_tc::registry::TcSchemeKind::Dkg
89    }
90
91    fn create_session(
92        &self,
93        params: &confium_tc::SessionParams,
94    ) -> confium_tc::error::Result<Box<dyn confium_tc::registry::SessionImpl>> {
95        DkgSession::new(params)
96            .map(|s| Box::new(s) as Box<dyn confium_tc::registry::SessionImpl>)
97            .map_err(FrostError::framework)
98    }
99}
100
101// Register at link time so `Session::create("FROST-ed25519-dkg")` resolves.
102confium_tc::register_tc_scheme!(FrostEd25519Dkg);
103
104// ---------------------------------------------------------------------------
105// Session
106// ---------------------------------------------------------------------------
107
108/// Per-party DKG session state.
109struct DkgSession {
110    party_id: String,
111    /// 1-indexed numeric party index used as the polynomial evaluation
112    /// point. Derived from the roster position.
113    party_index: u32,
114    threshold: u32,
115    /// All N party ids in roster order.
116    roster_ids: Vec<String>,
117    /// Map from party id → numeric index (roster position + 1).
118    id_to_index: std::collections::HashMap<String, u32>,
119    /// Our own VSS polynomial. Cleared after round 1.
120    poly: Option<Polynomial>,
121    /// Our own commitment list, broadcast in round 1.
122    our_commitments: Vec<[u8; group::ELEMENT_BYTES]>,
123    /// Commitment lists received from every peer (by party id).
124    peer_commitments: std::collections::HashMap<String, Vec<[u8; group::ELEMENT_BYTES]>>,
125    /// VSS share fragments received from peers directed to us. The
126    /// aggregate share is `own_share + sum(fragments)`.
127    own_share: Scalar,
128    received_fragments: Vec<(String, Scalar)>,
129    /// Aggregate public key, computed in round 2.
130    aggregate_pubkey: Option<[u8; group::ELEMENT_BYTES]>,
131    /// Round counter: 0 = no rounds run, 1 after round 1, 2 after round 2.
132    round_done: u8,
133}
134
135impl DkgSession {
136    fn new(params: &confium_tc::SessionParams) -> Result<Self> {
137        let threshold = params.threshold;
138        if threshold == 0 {
139            return Err(FrostError::RosterConfig {
140                reason: "threshold must be >= 1",
141                code: CODE_ROSTER_CONFIG,
142            });
143        }
144        let roster: Vec<String> = params
145            .parties
146            .parties()
147            .iter()
148            .map(|p| p.id.clone())
149            .collect();
150        if roster.is_empty() {
151            return Err(FrostError::RosterConfig {
152                reason: "roster must be non-empty",
153                code: CODE_ROSTER_CONFIG,
154            });
155        }
156        let (threshold_usize, n) = (threshold as usize, roster.len());
157        if threshold_usize > n {
158            return Err(FrostError::RosterConfig {
159                reason: "threshold exceeds party count",
160                code: CODE_ROSTER_CONFIG,
161            });
162        }
163        let this_idx = params.this_party_idx;
164        if this_idx >= n {
165            return Err(FrostError::RosterConfig {
166                reason: "this_party_idx out of range",
167                code: CODE_ROSTER_CONFIG,
168            });
169        }
170        let party_id = roster[this_idx].clone();
171        // 1-indexed polynomial evaluation points — matches FROST spec
172        // convention (party indices start at 1).
173        let party_index = (this_idx as u32) + 1;
174        let mut id_to_index = std::collections::HashMap::new();
175        for (i, id) in roster.iter().enumerate() {
176            id_to_index.insert(id.clone(), (i as u32) + 1);
177        }
178
179        // Sample a fresh degree-(T-1) polynomial. The constant term is
180        // this party's contribution to the aggregate secret; we never
181        // reconstruct it.
182        let mut coeff = Vec::with_capacity(threshold_usize);
183        let mut rng = UnwrapErr(getrandom::SysRng);
184        for _ in 0..threshold_usize {
185            coeff.push(Scalar::random(&mut rng));
186        }
187        let poly = Polynomial::from_coefficients(coeff);
188        let commits = poly
189            .coefficients()
190            .iter()
191            .map(|a| group::point_to_bytes(&group::mul_base(a)))
192            .collect::<Vec<_>>();
193        // Our own share contribution is f_i(own_index).
194        let own_share = poly.evaluate(party_index);
195
196        Ok(DkgSession {
197            party_id,
198            party_index,
199            threshold,
200            roster_ids: roster,
201            id_to_index,
202            poly: Some(poly),
203            our_commitments: commits,
204            peer_commitments: std::collections::HashMap::new(),
205            own_share,
206            received_fragments: Vec::new(),
207            aggregate_pubkey: None,
208            round_done: 0,
209        })
210    }
211
212    /// Round 1 — broadcast our commitment list and direct shares to peers.
213    fn round1(&mut self) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
214        let poly = self.poly.as_ref().ok_or_else(|| {
215            FrostError::RoundOverflow {
216                round: self.round_done,
217                code: CODE_ROUND_OVERFLOW,
218            }
219            .framework()
220        })?;
221
222        // Broadcast: commitment list.
223        let bc_payload = encode_round1_broadcast(self.party_index, &self.our_commitments);
224        let mut outgoing = vec![confium_tc::Message::broadcast(
225            &self.party_id,
226            1,
227            bc_payload,
228        )];
229
230        // Directed: send f_i(j) to every other party.
231        for peer_id in &self.roster_ids {
232            if peer_id == &self.party_id {
233                continue;
234            }
235            let j = self.id_to_index.get(peer_id).copied().ok_or_else(|| {
236                FrostError::RosterConfig {
237                    reason: "peer missing from index map",
238                    code: CODE_ROSTER_CONFIG,
239                }
240                .framework()
241            })?;
242            let frag = poly.evaluate(j);
243            let payload = encode_round1_directed(self.party_index, &frag);
244            outgoing.push(confium_tc::Message::directed(
245                &self.party_id,
246                peer_id,
247                1,
248                payload,
249            ));
250        }
251
252        Ok(confium_tc::registry::RoundResult::new(outgoing, false))
253    }
254
255    /// Round 2 — verify received fragments against their senders'
256    /// commitment lists; aggregate the share and public key.
257    fn round2(
258        &mut self,
259        incoming: &[confium_tc::Message],
260    ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
261        // First, collect commitment broadcasts from round 1.
262        for m in incoming {
263            if m.round != 1 {
264                continue;
265            }
266            // Distinguish broadcast (commitment list) from directed
267            // (share fragment) by the tag byte.
268            if m.payload.is_empty() {
269                continue;
270            }
271            let tag = m.payload[0];
272            match tag {
273                MSG_ROUND1_BROADCAST => match decode_round1_broadcast(&m.payload) {
274                    Ok((_idx, commits)) => {
275                        self.peer_commitments
276                            .insert(m.from_party_id.clone(), commits);
277                    }
278                    Err(e) => {
279                        return Err(e.framework());
280                    }
281                },
282                MSG_ROUND1_DIRECTED => {
283                    // Must be addressed to us.
284                    if !m.is_for(&self.party_id) {
285                        continue;
286                    }
287                    match decode_round1_directed(&m.payload) {
288                        Ok((_sender_idx, frag)) => {
289                            self.received_fragments
290                                .push((m.from_party_id.clone(), frag));
291                        }
292                        Err(e) => {
293                            return Err(e.framework());
294                        }
295                    }
296                }
297                _ => {
298                    return Err(FrostError::MalformedMessage {
299                        reason: "unknown message tag in DKG round 1",
300                        code: CODE_MALFORMED_MESSAGE,
301                    }
302                    .framework());
303                }
304            }
305        }
306
307        // Verify each received fragment against its sender's commitment
308        // list. Byzantine senders are excluded from both the share sum
309        // and the public key sum.
310        let mut byzantine: Vec<String> = Vec::new();
311        for (sender_id, frag) in &self.received_fragments {
312            let Some(commits) = self.peer_commitments.get(sender_id) else {
313                // No commitment list → cannot verify, treat as byzantine.
314                byzantine.push(sender_id.clone());
315                continue;
316            };
317            let cl = CommitmentList::from_bytes(commits.clone());
318            if !cl.verify_share(self.party_index, frag) {
319                byzantine.push(sender_id.clone());
320            }
321        }
322
323        // Aggregate the share: own contribution + every verified fragment.
324        let mut share = self.own_share;
325        for (sender_id, frag) in &self.received_fragments {
326            if byzantine.contains(sender_id) {
327                continue;
328            }
329            share += frag;
330        }
331
332        // Aggregate the public key: sum of every sender's C_0 plus our
333        // own. Byzantine senders are excluded.
334        let mut pubkey_point = EdwardsPoint::identity();
335        // Our own C_0.
336        if !self.our_commitments.is_empty() {
337            if let Some(p) = group::point_from_bytes(&self.our_commitments[0]) {
338                pubkey_point += p;
339            }
340        }
341        for (sender_id, commits) in &self.peer_commitments {
342            if byzantine.contains(sender_id) || commits.is_empty() {
343                continue;
344            }
345            if let Some(p) = group::point_from_bytes(&commits[0]) {
346                pubkey_point += p;
347            }
348        }
349
350        // Threshold check: we need at least T distinct honest senders
351        // (including ourselves) for the resulting key to be threshold-safe.
352        // We approximate this by requiring that the total contributing
353        // count (peers minus byzantine plus ourselves) is >= T.
354        let contributing = (self.peer_commitments.len() + 1).saturating_sub(byzantine.len());
355        if (contributing as u32) < self.threshold {
356            return Err(FrostError::BelowThreshold {
357                have: contributing as u32,
358                need: self.threshold,
359                code: CODE_BELOW_THRESHOLD,
360            }
361            .framework());
362        }
363
364        let pubkey_bytes = group::point_to_bytes(&pubkey_point);
365        self.aggregate_pubkey = Some(pubkey_bytes);
366        self.own_share = share;
367
368        // We're done — no more rounds needed.
369        Ok(confium_tc::registry::RoundResult::done())
370    }
371}
372
373impl confium_tc::registry::SessionImpl for DkgSession {
374    fn round(
375        &mut self,
376        incoming: &[confium_tc::Message],
377    ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
378        self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
379            FrostError::RoundOverflow {
380                round: self.round_done,
381                code: CODE_ROUND_OVERFLOW,
382            }
383            .framework()
384        })?;
385        match self.round_done {
386            1 => self.round1(),
387            2 => self.round2(incoming),
388            other => Err(FrostError::RoundOverflow {
389                round: other,
390                code: CODE_ROUND_OVERFLOW,
391            }
392            .framework()),
393        }
394    }
395
396    fn result(&self) -> confium_tc::error::Result<Vec<u8>> {
397        let pubkey = self.aggregate_pubkey.ok_or_else(|| {
398            FrostError::SessionNotComplete {
399                code: CODE_SESSION_NOT_COMPLETE,
400            }
401            .framework()
402        })?;
403        let share_bytes = group::scalar_to_bytes(&self.own_share);
404        Ok(encode_dkg_output(&pubkey, &share_bytes))
405    }
406
407    fn destroy(&mut self) {
408        // Zeroize sensitive state.
409        self.own_share = Scalar::ZERO;
410        self.poly = None;
411        for (_id, frag) in self.received_fragments.drain(..) {
412            let _ = frag;
413        }
414    }
415}
416
417// ---------------------------------------------------------------------------
418// Output parsing — public helper so callers (and signing sessions) can
419// recover (pubkey, share) from a DKG result blob.
420// ---------------------------------------------------------------------------
421
422/// Parse a DKG output blob into `(public_key_bytes, share_bytes)`.
423///
424/// The blob is the value returned by the DKG session via
425/// [`confium_tc::Session::result`] /
426/// [`confium_tc::Session::dkg_public_key`].
427pub fn parse_output(
428    blob: &[u8],
429) -> Result<([u8; group::ELEMENT_BYTES], [u8; group::SCALAR_BYTES])> {
430    if blob.len() < 4 {
431        return Err(FrostError::MalformedShare {
432            reason: "DKG output too short for pubkey length prefix",
433            code: CODE_MALFORMED_SHARE,
434        });
435    }
436    let pk_len = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]) as usize;
437    if pk_len != group::ELEMENT_BYTES {
438        return Err(FrostError::MalformedShare {
439            reason: "unexpected pubkey length",
440            code: CODE_MALFORMED_SHARE,
441        });
442    }
443    let pk_end = 4 + pk_len;
444    if blob.len() < pk_end + 4 {
445        return Err(FrostError::MalformedShare {
446            reason: "DKG output too short for share length prefix",
447            code: CODE_MALFORMED_SHARE,
448        });
449    }
450    let share_len = u32::from_be_bytes([
451        blob[pk_end],
452        blob[pk_end + 1],
453        blob[pk_end + 2],
454        blob[pk_end + 3],
455    ]) as usize;
456    if share_len != group::SCALAR_BYTES {
457        return Err(FrostError::MalformedShare {
458            reason: "unexpected share length",
459            code: CODE_MALFORMED_SHARE,
460        });
461    }
462    let share_end = pk_end + 4 + share_len;
463    if blob.len() < share_end {
464        return Err(FrostError::MalformedShare {
465            reason: "DKG output truncated",
466            code: CODE_MALFORMED_SHARE,
467        });
468    }
469    let mut pk = [0u8; group::ELEMENT_BYTES];
470    pk.copy_from_slice(&blob[4..pk_end]);
471    let mut share = [0u8; group::SCALAR_BYTES];
472    share.copy_from_slice(&blob[pk_end + 4..share_end]);
473    Ok((pk, share))
474}
475
476/// Encode the DKG output blob.
477fn encode_dkg_output(
478    pubkey: &[u8; group::ELEMENT_BYTES],
479    share: &[u8; group::SCALAR_BYTES],
480) -> Vec<u8> {
481    let mut out = Vec::with_capacity(4 + pubkey.len() + 4 + share.len());
482    out.extend_from_slice(&(pubkey.len() as u32).to_be_bytes());
483    out.extend_from_slice(pubkey);
484    out.extend_from_slice(&(share.len() as u32).to_be_bytes());
485    out.extend_from_slice(share);
486    out
487}
488
489// ---------------------------------------------------------------------------
490// Wire formats
491// ---------------------------------------------------------------------------
492
493/// Round-1 broadcast: `tag | sender_idx:u32 BE | n_commits:u32 BE | commits…`
494fn encode_round1_broadcast(idx: u32, commits: &[[u8; group::ELEMENT_BYTES]]) -> Vec<u8> {
495    let mut out = Vec::with_capacity(1 + 4 + 4 + commits.len() * group::ELEMENT_BYTES);
496    out.push(MSG_ROUND1_BROADCAST);
497    out.extend_from_slice(&idx.to_be_bytes());
498    out.extend_from_slice(&(commits.len() as u32).to_be_bytes());
499    for c in commits {
500        out.extend_from_slice(c);
501    }
502    out
503}
504
505fn decode_round1_broadcast(p: &[u8]) -> Result<(u32, Vec<[u8; group::ELEMENT_BYTES]>)> {
506    if p.len() < 1 + 4 + 4 || p[0] != MSG_ROUND1_BROADCAST {
507        return Err(FrostError::MalformedMessage {
508            reason: "bad round-1 broadcast header",
509            code: CODE_MALFORMED_MESSAGE,
510        });
511    }
512    let idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
513    let n = u32::from_be_bytes([p[5], p[6], p[7], p[8]]) as usize;
514    let need = 1 + 4 + 4 + n * group::ELEMENT_BYTES;
515    if p.len() < need {
516        return Err(FrostError::MalformedMessage {
517            reason: "round-1 broadcast truncated",
518            code: CODE_MALFORMED_MESSAGE,
519        });
520    }
521    let mut commits = Vec::with_capacity(n);
522    let mut off = 9;
523    for _ in 0..n {
524        let mut c = [0u8; group::ELEMENT_BYTES];
525        c.copy_from_slice(&p[off..off + group::ELEMENT_BYTES]);
526        off += group::ELEMENT_BYTES;
527        commits.push(c);
528    }
529    Ok((idx, commits))
530}
531
532/// Round-1 directed share: `tag | sender_idx:u32 BE | share[32]`
533fn encode_round1_directed(sender_idx: u32, frag: &Scalar) -> Vec<u8> {
534    let mut out = Vec::with_capacity(1 + 4 + group::SCALAR_BYTES);
535    out.push(MSG_ROUND1_DIRECTED);
536    out.extend_from_slice(&sender_idx.to_be_bytes());
537    out.extend_from_slice(&group::scalar_to_bytes(frag));
538    out
539}
540
541fn decode_round1_directed(p: &[u8]) -> Result<(u32, Scalar)> {
542    if p.len() != 1 + 4 + group::SCALAR_BYTES || p[0] != MSG_ROUND1_DIRECTED {
543        return Err(FrostError::MalformedMessage {
544            reason: "bad round-1 directed share",
545            code: CODE_MALFORMED_MESSAGE,
546        });
547    }
548    let sender_idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
549    let mut s = [0u8; group::SCALAR_BYTES];
550    s.copy_from_slice(&p[5..5 + group::SCALAR_BYTES]);
551    Ok((sender_idx, group::scalar_from_bytes_mod_order(&s)))
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use crate::group;
558
559    #[test]
560    fn round1_broadcast_round_trips() {
561        let commits = vec![[1u8; 32], [2u8; 32], [3u8; 32]];
562        let enc = encode_round1_broadcast(7, &commits);
563        let (idx, back) = decode_round1_broadcast(&enc).expect("decode");
564        assert_eq!(idx, 7);
565        assert_eq!(back, commits);
566    }
567
568    #[test]
569    fn round1_directed_round_trips() {
570        let s = Scalar::from_bytes_mod_order([9u8; 32]);
571        let enc = encode_round1_directed(3, &s);
572        let (idx, back) = decode_round1_directed(&enc).expect("decode");
573        assert_eq!(idx, 3);
574        assert_eq!(group::scalar_to_bytes(&back), group::scalar_to_bytes(&s));
575    }
576
577    #[test]
578    fn output_round_trips() {
579        let pk = [0xAAu8; 32];
580        let share = [0xBBu8; 32];
581        let blob = encode_dkg_output(&pk, &share);
582        let (pk2, share2) = parse_output(&blob).expect("parse");
583        assert_eq!(pk2, pk);
584        assert_eq!(share2, share);
585    }
586
587    #[test]
588    fn parse_output_rejects_truncated() {
589        let err = parse_output(&[0u8; 3]).unwrap_err();
590        match err {
591            FrostError::MalformedShare { .. } => {}
592            other => panic!("expected MalformedShare, got {other:?}"),
593        }
594    }
595}