confium_tc_frost_ed25519/
dkg.rs1use 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
66pub const SCHEME_NAME: &str = "FROST-ed25519-dkg";
68
69const MSG_ROUND1_BROADCAST: u8 = 0x01;
71const MSG_ROUND1_DIRECTED: u8 = 0x02;
72
73pub 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
101confium_tc::register_tc_scheme!(FrostEd25519Dkg);
103
104struct DkgSession {
110 party_id: String,
111 party_index: u32,
114 threshold: u32,
115 roster_ids: Vec<String>,
117 id_to_index: std::collections::HashMap<String, u32>,
119 poly: Option<Polynomial>,
121 our_commitments: Vec<[u8; group::ELEMENT_BYTES]>,
123 peer_commitments: std::collections::HashMap<String, Vec<[u8; group::ELEMENT_BYTES]>>,
125 own_share: Scalar,
128 received_fragments: Vec<(String, Scalar)>,
129 aggregate_pubkey: Option<[u8; group::ELEMENT_BYTES]>,
131 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 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 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 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 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 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 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 fn round2(
258 &mut self,
259 incoming: &[confium_tc::Message],
260 ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
261 for m in incoming {
263 if m.round != 1 {
264 continue;
265 }
266 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 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 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 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 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 let mut pubkey_point = EdwardsPoint::identity();
335 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 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 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 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
417pub 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
476fn 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
489fn 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
532fn 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}