1use curve25519_dalek::edwards::EdwardsPoint;
51use curve25519_dalek::rand_core::UnwrapErr;
52use curve25519_dalek::scalar::Scalar;
53use curve25519_dalek::traits::Identity;
54
55use crate::error::{
56 CODE_AGG_VERIFY_FAILED, CODE_BELOW_THRESHOLD, CODE_INVALID_COMMITMENT, CODE_INVALID_SHARE_SIG,
57 CODE_MALFORMED_MESSAGE, CODE_MALFORMED_SHARE, CODE_MISSING_COMMITMENT, CODE_ROSTER_CONFIG,
58 CODE_ROUND_OVERFLOW, CODE_SESSION_NOT_COMPLETE, FrostError, Result,
59};
60use crate::group;
61use crate::polynomial::lagrange_coefficient;
62use crate::transcript;
63
64pub const SCHEME_NAME: &str = "FROST-ed25519";
66
67const MSG_ROUND1_COMMIT: u8 = 0x11;
69const MSG_ROUND2_RESPONSE: u8 = 0x12;
70
71pub struct FrostEd25519;
79
80impl confium_tc::registry::TcScheme for FrostEd25519 {
81 fn name(&self) -> &'static str {
82 SCHEME_NAME
83 }
84
85 fn kind(&self) -> confium_tc::registry::TcSchemeKind {
86 confium_tc::registry::TcSchemeKind::Signature
87 }
88
89 fn create_session(
90 &self,
91 params: &confium_tc::SessionParams,
92 ) -> confium_tc::error::Result<Box<dyn confium_tc::registry::SessionImpl>> {
93 FrostSession::new(params)
94 .map(|s| Box::new(s) as Box<dyn confium_tc::registry::SessionImpl>)
95 .map_err(FrostError::framework)
96 }
97}
98
99confium_tc::register_tc_scheme!(FrostEd25519);
101
102struct NoncePair {
108 d: Scalar,
110 e: Scalar,
112 d_commit: [u8; group::ELEMENT_BYTES],
114 e_commit: [u8; group::ELEMENT_BYTES],
116}
117
118impl NoncePair {
119 fn generate() -> Self {
120 let mut rng = UnwrapErr(getrandom::SysRng);
121 let d = Scalar::random(&mut rng);
122 let e = Scalar::random(&mut rng);
123 let d_point = group::mul_base(&d);
124 let e_point = group::mul_base(&e);
125 NoncePair {
126 d,
127 e,
128 d_commit: group::point_to_bytes(&d_point),
129 e_commit: group::point_to_bytes(&e_point),
130 }
131 }
132}
133
134#[derive(Clone)]
136struct Commitment {
137 party_id: String,
138 idx: u32,
139 d: [u8; group::ELEMENT_BYTES],
140 e: [u8; group::ELEMENT_BYTES],
141}
142
143struct ShareResponse {
145 party_id: String,
146 idx: u32,
147 z: Scalar,
148}
149
150struct FrostSession {
151 party_id: String,
152 party_index: u32,
153 threshold: u32,
154 secret_share: Scalar,
156 message: Vec<u8>,
158 nonce: Option<NoncePair>,
160 commitments: Vec<Commitment>,
162 participants: Vec<u32>,
165 r_point: Option<EdwardsPoint>,
167 r_bytes: Option<[u8; group::ELEMENT_BYTES]>,
168 pubkey_bytes: Option<[u8; group::ELEMENT_BYTES]>,
169 our_response: Option<Scalar>,
171 signature: Option<[u8; 64]>,
173 round_done: u8,
174}
175
176impl FrostSession {
177 fn new(params: &confium_tc::SessionParams) -> Result<Self> {
178 let threshold = params.threshold;
179 let roster: Vec<String> = params
180 .parties
181 .parties()
182 .iter()
183 .map(|p| p.id.clone())
184 .collect();
185 let n = roster.len();
186 if threshold == 0 {
187 return Err(FrostError::RosterConfig {
188 reason: "threshold must be >= 1",
189 code: CODE_ROSTER_CONFIG,
190 });
191 }
192 if (threshold as usize) > n {
193 return Err(FrostError::RosterConfig {
194 reason: "threshold exceeds party count",
195 code: CODE_ROSTER_CONFIG,
196 });
197 }
198 let this_idx = params.this_party_idx;
199 if this_idx >= n {
200 return Err(FrostError::RosterConfig {
201 reason: "this_party_idx out of range",
202 code: CODE_ROSTER_CONFIG,
203 });
204 }
205 let party_id = roster[this_idx].clone();
206 let party_index = (this_idx as u32) + 1;
207
208 let secret_share = params
211 .local_share
212 .as_ref()
213 .ok_or(FrostError::MalformedShare {
214 reason: "signing session requires a local share",
215 code: CODE_MALFORMED_SHARE,
216 })?;
217 let (secret_scalar, pubkey_bytes): (Scalar, Option<[u8; group::ELEMENT_BYTES]>) =
221 if secret_share.bytes().len() == 4 + group::ELEMENT_BYTES + 4 + group::SCALAR_BYTES
222 && crate::dkg::parse_output(secret_share.bytes()).is_ok()
223 {
224 let (pk, share) = crate::dkg::parse_output(secret_share.bytes())
225 .expect("checked length and parse above");
226 (group::scalar_from_slice(&share)?, Some(pk))
227 } else {
228 (group::scalar_from_slice(secret_share.bytes())?, None)
229 };
230
231 let message = params.message.clone().unwrap_or_default();
232
233 Ok(FrostSession {
234 party_id,
235 party_index,
236 threshold,
237 secret_share: secret_scalar,
238 message,
239 nonce: None,
240 commitments: Vec::new(),
241 participants: Vec::new(),
242 r_point: None,
243 r_bytes: None,
244 pubkey_bytes,
245 our_response: None,
246 signature: None,
247 round_done: 0,
248 })
249 }
250
251 fn round1(&mut self) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
253 let nonce = NoncePair::generate();
254 let payload = encode_round1_commit(self.party_index, &nonce.d_commit, &nonce.e_commit);
255 self.nonce = Some(nonce);
256 let msg = confium_tc::Message::broadcast(&self.party_id, 1, payload);
257 Ok(confium_tc::registry::RoundResult::new(vec![msg], false))
258 }
259
260 fn round2(
263 &mut self,
264 incoming: &[confium_tc::Message],
265 ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
266 let mut commits: Vec<Commitment> = Vec::new();
268 for m in incoming {
269 if m.round != 1 || m.payload.is_empty() {
270 continue;
271 }
272 if m.payload[0] != MSG_ROUND1_COMMIT {
273 continue;
274 }
275 let (idx, d, e) = match decode_round1_commit(&m.payload) {
276 Ok(v) => v,
277 Err(e) => return Err(e.framework()),
278 };
279 if group::point_from_slice(&d, &m.from_party_id).is_err() {
281 return Err(FrostError::InvalidCommitment {
282 party: m.from_party_id.clone(),
283 reason: "D commitment is not a valid curve point",
284 code: CODE_INVALID_COMMITMENT,
285 }
286 .framework());
287 }
288 if group::point_from_slice(&e, &m.from_party_id).is_err() {
289 return Err(FrostError::InvalidCommitment {
290 party: m.from_party_id.clone(),
291 reason: "E commitment is not a valid curve point",
292 code: CODE_INVALID_COMMITMENT,
293 }
294 .framework());
295 }
296 commits.push(Commitment {
297 party_id: m.from_party_id.clone(),
298 idx,
299 d,
300 e,
301 });
302 }
303
304 let nonce = self.nonce.as_ref().ok_or_else(|| {
307 FrostError::RoundOverflow {
308 round: self.round_done,
309 code: CODE_ROUND_OVERFLOW,
310 }
311 .framework()
312 })?;
313 commits.push(Commitment {
314 party_id: self.party_id.clone(),
315 idx: self.party_index,
316 d: nonce.d_commit,
317 e: nonce.e_commit,
318 });
319
320 commits.sort_by_key(|c| c.idx);
323 self.commitments = commits.clone();
324 self.participants = commits.iter().map(|c| c.idx).collect();
325
326 if (self.participants.len() as u32) < self.threshold {
328 return Err(FrostError::BelowThreshold {
329 have: self.participants.len() as u32,
330 need: self.threshold,
331 code: CODE_BELOW_THRESHOLD,
332 }
333 .framework());
334 }
335
336 let rho_input_bytes: Vec<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> =
338 commits.iter().map(|c| (c.idx, c.d, c.e)).collect();
339 let rho_input = transcript::rho_input(&self.message, &rho_input_bytes);
340
341 let mut r_point = EdwardsPoint::identity();
343 for c in &commits {
344 let rho_i = transcript::h1_binding_factor(&rho_input_with_party(&rho_input, c.idx));
345 let d = group::point_from_bytes(&c.d).expect("validated above");
346 let e = group::point_from_bytes(&c.e).expect("validated above");
347 r_point += d + (e * rho_i);
348 }
349 let r_bytes = group::point_to_bytes(&r_point);
350
351 let pubkey_bytes = self
366 .derive_pubkey_from_share()
367 .map_err(FrostError::framework)?;
368
369 let challenge = transcript::challenge(&r_bytes, &pubkey_bytes, &self.message);
371
372 let lambda_i = lagrange_coefficient(self.party_index, &self.participants);
375 let rho_i =
376 transcript::h1_binding_factor(&rho_input_with_party(&rho_input, self.party_index));
377 let z_i = (nonce.d + (nonce.e * rho_i)) + ((self.secret_share * lambda_i) * challenge);
378
379 self.r_point = Some(r_point);
380 self.r_bytes = Some(r_bytes);
381 self.pubkey_bytes = Some(pubkey_bytes);
382 self.our_response = Some(z_i);
383
384 let payload = encode_round2_response(self.party_index, &z_i);
386 let msg = confium_tc::Message::broadcast(&self.party_id, 2, payload);
387 Ok(confium_tc::registry::RoundResult::new(vec![msg], false))
388 }
389
390 fn round3(
393 &mut self,
394 incoming: &[confium_tc::Message],
395 ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
396 let mut responses: Vec<ShareResponse> = Vec::new();
398 for m in incoming {
399 if m.round != 2 || m.payload.is_empty() {
400 continue;
401 }
402 if m.payload[0] != MSG_ROUND2_RESPONSE {
403 continue;
404 }
405 let (idx, z) = match decode_round2_response(&m.payload) {
406 Ok(v) => v,
407 Err(e) => return Err(e.framework()),
408 };
409 responses.push(ShareResponse {
410 party_id: m.from_party_id.clone(),
411 idx,
412 z,
413 });
414 }
415 let our_z = self.our_response.ok_or_else(|| {
417 FrostError::RoundOverflow {
418 round: self.round_done,
419 code: CODE_ROUND_OVERFLOW,
420 }
421 .framework()
422 })?;
423 responses.push(ShareResponse {
424 party_id: self.party_id.clone(),
425 idx: self.party_index,
426 z: our_z,
427 });
428
429 let have: std::collections::HashSet<u32> = responses.iter().map(|r| r.idx).collect();
433 let need: std::collections::HashSet<u32> = self.commitments.iter().map(|c| c.idx).collect();
434 if !need.is_subset(&have) {
435 let missing_idx = *need.difference(&have).next().expect("non-empty diff");
436 let party = self
437 .commitments
438 .iter()
439 .find(|c| c.idx == missing_idx)
440 .map(|c| c.party_id.clone())
441 .unwrap_or_else(|| format!("idx-{missing_idx}"));
442 return Err(FrostError::MissingCommitment {
443 party,
444 code: CODE_MISSING_COMMITMENT,
445 }
446 .framework());
447 }
448
449 let rho_input_bytes: Vec<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> =
452 self.commitments.iter().map(|c| (c.idx, c.d, c.e)).collect();
453 let rho_input = transcript::rho_input(&self.message, &rho_input_bytes);
454 let r_bytes = self.r_bytes.expect("set in round 2");
455 let pubkey_bytes = self.pubkey_bytes.expect("set in round 2");
456 let challenge = transcript::challenge(&r_bytes, &pubkey_bytes, &self.message);
457
458 let mut z = Scalar::ZERO;
473 for r in &responses {
474 z += &r.z;
475 }
476
477 let zb = group::mul_base(&z);
479 let r_point = self.r_point.expect("set in round 2");
480 let a_point = group::point_from_bytes(&pubkey_bytes).ok_or_else(|| {
481 FrostError::InvalidCommitment {
482 party: "aggregate-public-key".to_string(),
483 reason: "aggregate public key is not a valid curve point",
484 code: CODE_INVALID_COMMITMENT,
485 }
486 .framework()
487 })?;
488 let expected = r_point + (a_point * challenge);
489 if zb != expected {
490 for r in &responses {
494 let commit = self
495 .commitments
496 .iter()
497 .find(|c| c.idx == r.idx)
498 .expect("response has a matching commitment");
499 let rho_i = transcript::h1_binding_factor(&rho_input_with_party(&rho_input, r.idx));
500 let lambda_i = lagrange_coefficient(r.idx, &self.participants);
501 if r.z == Scalar::ZERO {
508 return Err(FrostError::InvalidShareSignature {
509 party: r.party_id.clone(),
510 code: CODE_INVALID_SHARE_SIG,
511 }
512 .framework());
513 }
514 let _ = (commit, rho_i, lambda_i);
515 }
516 return Err(FrostError::AggregateVerificationFailed {
517 code: CODE_AGG_VERIFY_FAILED,
518 }
519 .framework());
520 }
521
522 let mut sig = [0u8; 64];
524 sig[..32].copy_from_slice(&r_bytes);
525 sig[32..].copy_from_slice(&group::scalar_to_bytes(&z));
526 self.signature = Some(sig);
527
528 Ok(confium_tc::registry::RoundResult::done())
529 }
530
531 fn derive_pubkey_from_share(&self) -> Result<[u8; group::ELEMENT_BYTES]> {
538 self.pubkey_bytes.ok_or(FrostError::MalformedShare {
539 reason: "aggregate public key not supplied — pass a DKG output blob as the share",
540 code: CODE_MALFORMED_SHARE,
541 })
542 }
543}
544
545impl confium_tc::registry::SessionImpl for FrostSession {
546 fn round(
547 &mut self,
548 incoming: &[confium_tc::Message],
549 ) -> confium_tc::error::Result<confium_tc::registry::RoundResult> {
550 self.round_done = self.round_done.checked_add(1).ok_or_else(|| {
551 FrostError::RoundOverflow {
552 round: self.round_done,
553 code: CODE_ROUND_OVERFLOW,
554 }
555 .framework()
556 })?;
557 match self.round_done {
558 1 => self.round1(),
559 2 => self.round2(incoming),
560 3 => self.round3(incoming),
561 other => Err(FrostError::RoundOverflow {
562 round: other,
563 code: CODE_ROUND_OVERFLOW,
564 }
565 .framework()),
566 }
567 }
568
569 fn result(&self) -> confium_tc::error::Result<Vec<u8>> {
570 self.signature.map(|s| s.to_vec()).ok_or_else(|| {
571 FrostError::SessionNotComplete {
572 code: CODE_SESSION_NOT_COMPLETE,
573 }
574 .framework()
575 })
576 }
577
578 fn destroy(&mut self) {
579 self.secret_share = Scalar::ZERO;
580 self.nonce = None;
581 self.our_response = None;
582 self.signature = None;
583 }
584}
585
586fn rho_input_with_party(rho_input: &[u8], idx: u32) -> Vec<u8> {
590 let mut out = Vec::with_capacity(rho_input.len() + 4);
591 out.extend_from_slice(rho_input);
592 out.extend_from_slice(&idx.to_be_bytes());
593 out
594}
595
596fn encode_round1_commit(
602 idx: u32,
603 d: &[u8; group::ELEMENT_BYTES],
604 e: &[u8; group::ELEMENT_BYTES],
605) -> Vec<u8> {
606 let mut out = Vec::with_capacity(1 + 4 + 2 * group::ELEMENT_BYTES);
607 out.push(MSG_ROUND1_COMMIT);
608 out.extend_from_slice(&idx.to_be_bytes());
609 out.extend_from_slice(d);
610 out.extend_from_slice(e);
611 out
612}
613
614fn decode_round1_commit(
615 p: &[u8],
616) -> Result<(u32, [u8; group::ELEMENT_BYTES], [u8; group::ELEMENT_BYTES])> {
617 let need = 1 + 4 + 2 * group::ELEMENT_BYTES;
618 if p.len() != need || p[0] != MSG_ROUND1_COMMIT {
619 return Err(FrostError::MalformedMessage {
620 reason: "bad round-1 commitment",
621 code: CODE_MALFORMED_MESSAGE,
622 });
623 }
624 let idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
625 let mut d = [0u8; group::ELEMENT_BYTES];
626 d.copy_from_slice(&p[5..5 + group::ELEMENT_BYTES]);
627 let mut e = [0u8; group::ELEMENT_BYTES];
628 e.copy_from_slice(&p[5 + group::ELEMENT_BYTES..5 + 2 * group::ELEMENT_BYTES]);
629 Ok((idx, d, e))
630}
631
632fn encode_round2_response(idx: u32, z: &Scalar) -> Vec<u8> {
634 let mut out = Vec::with_capacity(1 + 4 + group::SCALAR_BYTES);
635 out.push(MSG_ROUND2_RESPONSE);
636 out.extend_from_slice(&idx.to_be_bytes());
637 out.extend_from_slice(&group::scalar_to_bytes(z));
638 out
639}
640
641fn decode_round2_response(p: &[u8]) -> Result<(u32, Scalar)> {
642 let need = 1 + 4 + group::SCALAR_BYTES;
643 if p.len() != need || p[0] != MSG_ROUND2_RESPONSE {
644 return Err(FrostError::MalformedMessage {
645 reason: "bad round-2 response",
646 code: CODE_MALFORMED_MESSAGE,
647 });
648 }
649 let idx = u32::from_be_bytes([p[1], p[2], p[3], p[4]]);
650 let mut s = [0u8; group::SCALAR_BYTES];
651 s.copy_from_slice(&p[5..5 + group::SCALAR_BYTES]);
652 Ok((idx, group::scalar_from_bytes_mod_order(&s)))
653}
654
655#[cfg(test)]
656mod tests {
657 use super::*;
658
659 #[test]
660 fn round1_commit_round_trips() {
661 let d = [1u8; 32];
662 let e = [2u8; 32];
663 let enc = encode_round1_commit(5, &d, &e);
664 let (idx, d2, e2) = decode_round1_commit(&enc).expect("decode");
665 assert_eq!(idx, 5);
666 assert_eq!(d2, d);
667 assert_eq!(e2, e);
668 }
669
670 #[test]
671 fn round2_response_round_trips() {
672 let z = Scalar::from(99u64);
673 let enc = encode_round2_response(3, &z);
674 let (idx, z2) = decode_round2_response(&enc).expect("decode");
675 assert_eq!(idx, 3);
676 assert_eq!(group::scalar_to_bytes(&z2), group::scalar_to_bytes(&z));
677 }
678
679 #[test]
680 fn decode_rejects_bad_tag() {
681 let mut bad = encode_round1_commit(1, &[0u8; 32], &[0u8; 32]);
682 bad[0] = 0xFF;
683 assert!(decode_round1_commit(&bad).is_err());
684 }
685}