From f1b9233230d7b8260e15ac68cefe57e4e4ca0829 Mon Sep 17 00:00:00 2001 From: Joey Yandle Date: Tue, 3 Jun 2025 21:49:55 -0400 Subject: [PATCH 1/3] add sign_threshold to coordinator config; verify that we get the correct number of nonces and signature shares when it is set --- src/net.rs | 2 +- src/state_machine/coordinator/fire.rs | 82 ++++++++++++++++++++++++++- src/state_machine/coordinator/mod.rs | 4 ++ 3 files changed, 86 insertions(+), 2 deletions(-) diff --git a/src/net.rs b/src/net.rs index 329494a..818eebc 100644 --- a/src/net.rs +++ b/src/net.rs @@ -199,7 +199,7 @@ impl DkgPublicShares { &self.comms, &self.kex_public_key, ); - &self.kex_proof.s * &G == &self.kex_proof.R + c * &self.kex_public_key + self.kex_proof.s * G == self.kex_proof.R + c * self.kex_public_key } /// construct a proof of knowledge of kex_private_key diff --git a/src/state_machine/coordinator/fire.rs b/src/state_machine/coordinator/fire.rs index cc6b1c7..3243883 100644 --- a/src/state_machine/coordinator/fire.rs +++ b/src/state_machine/coordinator/fire.rs @@ -1168,7 +1168,7 @@ impl Coordinator { threshold = %self.config.threshold, "Received NonceResponse" ); - if nonce_info.nonce_recv_key_ids.len() >= self.config.threshold as usize { + if nonce_info.nonce_recv_key_ids.len() >= self.config.sign_threshold as usize { // We have a winning message! self.message.clone_from(&nonce_response.message); let aggregate_nonce = self.compute_aggregate_nonce()?; @@ -3215,6 +3215,86 @@ pub mod test { } } + #[test] + #[cfg(feature = "with_v1")] + fn sign_threshold_sign_v1() { + sign_threshold_sign::(); + } + + #[test] + fn sign_threshold_sign_v2() { + sign_threshold_sign::(); + } + + fn sign_threshold_sign() { + let (mut coordinators, mut signers) = all_signers_dkg::(5, 2); + + // change the sign_threshold to num_keys + for coordinator in &mut coordinators { + coordinator.config.sign_threshold = coordinator.config.num_keys; + } + + // We have started a signing round + let msg = "It was many and many a year ago, in a kingdom by the sea" + .as_bytes() + .to_vec(); + let signature_type = SignatureType::Frost; + let message = coordinators + .first_mut() + .unwrap() + .start_signing_round(&msg, signature_type, None) + .unwrap(); + assert_eq!( + coordinators.first().unwrap().state, + State::NonceGather(signature_type) + ); + + // Send the message to all signers and gather responses by sharing with all other signers and coordinator + let (outbound_messages, operation_results) = + feedback_messages(&mut coordinators, &mut signers, &[message]); + assert!(operation_results.is_empty()); + for coordinator in &coordinators { + assert_eq!(coordinator.state, State::SigShareGather(signature_type)); + } + + // check to see that we have nonces from all signers + for coordinator in &coordinators { + let sign_round_info = &coordinator.message_nonces[&msg]; + assert_eq!(sign_round_info.public_nonces.len(), signers.len()); + } + + assert_eq!(outbound_messages.len(), 1); + assert!( + matches!(outbound_messages[0].msg, Message::SignatureShareRequest(_)), + "Expected SignatureShareRequest message" + ); + // Send the SignatureShareRequest message to all signers and share their responses with the coordinator and signers + let (outbound_messages, operation_results) = + feedback_messages(&mut coordinators, &mut signers, &outbound_messages); + + // check to see that we have signature shares from all signers + for coordinator in &coordinators { + assert_eq!(coordinator.signature_shares.len(), signers.len()); + } + + assert!(outbound_messages.is_empty()); + assert_eq!(operation_results.len(), 1); + let OperationResult::Sign(sig) = &operation_results[0] else { + panic!("Expected Signature Operation result") + }; + assert!(sig.verify( + &coordinators + .first() + .unwrap() + .aggregate_public_key + .expect("No aggregate public key set!"), + &msg + )); + for coordinator in &coordinators { + assert_eq!(coordinator.state, State::Idle); + } + } + #[test] #[cfg(feature = "with_v1")] fn minimum_signers_sign_v1() { diff --git a/src/state_machine/coordinator/mod.rs b/src/state_machine/coordinator/mod.rs index d6ef564..e54acc1 100644 --- a/src/state_machine/coordinator/mod.rs +++ b/src/state_machine/coordinator/mod.rs @@ -150,6 +150,8 @@ pub struct Config { pub threshold: u32, /// threshold of keys needed to complete DKG (must be >= threshold) pub dkg_threshold: u32, + /// threshold of keys needed to start a signing round (must be >= threshold) + pub sign_threshold: u32, /// private key used to sign network messages pub message_private_key: Scalar, /// timeout to gather DkgPublicShares messages @@ -199,6 +201,7 @@ impl Config { num_keys, threshold, dkg_threshold: num_keys, + sign_threshold: threshold, message_private_key, dkg_public_timeout: None, dkg_private_timeout: None, @@ -230,6 +233,7 @@ impl Config { num_keys, threshold, dkg_threshold, + sign_threshold: threshold, message_private_key, dkg_public_timeout, dkg_private_timeout, From bdb279e76f04f2b887d323cc45140ca8ebfdf301 Mon Sep 17 00:00:00 2001 From: Joey Yandle Date: Thu, 30 Apr 2026 07:15:55 +0200 Subject: [PATCH 2/3] update FROST coordinator to respect sign_threshold --- src/state_machine/coordinator/frost.rs | 123 ++++++++++++++++++++++--- 1 file changed, 108 insertions(+), 15 deletions(-) diff --git a/src/state_machine/coordinator/frost.rs b/src/state_machine/coordinator/frost.rs index e6b2969..a90d6f3 100644 --- a/src/state_machine/coordinator/frost.rs +++ b/src/state_machine/coordinator/frost.rs @@ -663,13 +663,20 @@ impl Coordinator { "NonceResponse received" ); } - if self.ids_to_await.is_empty() { + let recv_key_ids = self + .public_nonces + .values() + .flat_map(|nr| nr.key_ids.iter().copied()) + .collect::>(); + if recv_key_ids.len() >= self.config.sign_threshold as usize { let aggregate_nonce = self.compute_aggregate_nonce()?; info!( %aggregate_nonce, "Aggregate nonce" ); + // Lock in the set of signers that will participate in the signing round. + self.ids_to_await = self.public_nonces.keys().copied().collect(); self.move_to(State::SigShareRequest(signature_type))?; } Ok(()) @@ -681,8 +688,10 @@ impl Coordinator { sign_id = %self.current_sign_id, "Requesting Signature Shares" ); - let nonce_responses = (0..self.config.num_signers) - .map(|i| self.public_nonces[&i].clone()) + let nonce_responses = self + .public_nonces + .values() + .cloned() .collect::>(); let sig_share_request = SignatureShareRequest { dkg_id: self.current_dkg_id, @@ -698,7 +707,7 @@ impl Coordinator { .expect(""), msg: Message::SignatureShareRequest(sig_share_request), }; - self.ids_to_await = (0..self.config.num_signers).collect(); + self.ids_to_await = self.public_nonces.keys().copied().collect(); self.move_to(State::SigShareGather(signature_type))?; Ok(sig_share_request_msg) @@ -784,9 +793,11 @@ impl Coordinator { ); } if self.ids_to_await.is_empty() { - // Calculate the aggregate signature - let nonce_responses = (0..self.config.num_signers) - .map(|i| self.public_nonces[&i].clone()) + // Calculate the aggregate signature using the signers that participated. + let nonce_responses = self + .public_nonces + .values() + .cloned() .collect::>(); let nonces = nonce_responses @@ -1144,15 +1155,19 @@ pub mod test { curve::scalar::Scalar, net::{DkgBegin, Message, NonceRequest, Packet, SignatureShareResponse, SignatureType}, schnorr::{self, ID}, - state_machine::coordinator::{ - frost::Coordinator as FrostCoordinator, - test::{ - bad_signature_share_request, btc_sign_verify, check_signature_shares, - coordinator_state_machine, empty_private_shares, empty_public_shares, - equal_after_save_load, invalid_nonce, new_coordinator, run_dkg_sign, setup, - start_dkg_round, start_signing_round, verify_packet_sigs, + state_machine::{ + coordinator::{ + frost::Coordinator as FrostCoordinator, + test::{ + bad_signature_share_request, btc_sign_verify, check_signature_shares, + coordinator_state_machine, empty_private_shares, empty_public_shares, + equal_after_save_load, feedback_messages, invalid_nonce, new_coordinator, + run_dkg, run_dkg_sign, setup, start_dkg_round, start_signing_round, + verify_packet_sigs, + }, + Config, Coordinator as CoordinatorTrait, State, }, - Config, Coordinator as CoordinatorTrait, State, + OperationResult, }, traits::{Aggregator as AggregatorTrait, Signer as SignerTrait}, util::create_rng, @@ -1576,6 +1591,84 @@ pub mod test { run_dkg_sign::, v2::Signer>(5, 2); } + #[test] + #[cfg(feature = "with_v1")] + fn sign_threshold_sign_v1() { + sign_threshold_sign::(); + } + + #[test] + fn sign_threshold_sign_v2() { + sign_threshold_sign::(); + } + + fn sign_threshold_sign() { + let (mut coordinators, mut signers) = run_dkg::, Signer>(5, 2); + + // Require all key_ids to participate in the signing round. + for coordinator in &mut coordinators { + coordinator.config.sign_threshold = coordinator.config.num_keys; + } + + let msg = "It was many and many a year ago, in a kingdom by the sea" + .as_bytes() + .to_vec(); + let signature_type = SignatureType::Frost; + let message = coordinators + .first_mut() + .unwrap() + .start_signing_round(&msg, signature_type, None) + .unwrap(); + assert_eq!( + coordinators.first().unwrap().state, + State::NonceGather(signature_type) + ); + + // Drive nonce gathering: every signer responds, so we should reach the threshold. + let (outbound_messages, operation_results) = + feedback_messages(&mut coordinators, &mut signers, &[message]); + assert!(operation_results.is_empty()); + for coordinator in &coordinators { + assert_eq!(coordinator.state, State::SigShareGather(signature_type)); + } + + // All signers contributed nonces. + for coordinator in &coordinators { + assert_eq!(coordinator.public_nonces.len(), signers.len()); + } + + assert_eq!(outbound_messages.len(), 1); + assert!( + matches!(outbound_messages[0].msg, Message::SignatureShareRequest(_)), + "Expected SignatureShareRequest message" + ); + + // Drive sig share gathering and aggregation. + let (outbound_messages, operation_results) = + feedback_messages(&mut coordinators, &mut signers, &outbound_messages); + + for coordinator in &coordinators { + assert_eq!(coordinator.signature_shares.len(), signers.len()); + } + + assert!(outbound_messages.is_empty()); + assert_eq!(operation_results.len(), 1); + let OperationResult::Sign(sig) = &operation_results[0] else { + panic!("Expected Signature Operation result") + }; + assert!(sig.verify( + &coordinators + .first() + .unwrap() + .aggregate_public_key + .expect("No aggregate public key set!"), + &msg + )); + for coordinator in &coordinators { + assert_eq!(coordinator.state, State::Idle); + } + } + #[test] #[cfg(feature = "with_v1")] fn check_signature_shares_v1() { From 018d6e8e829171370e13539df87852c167428ed5 Mon Sep 17 00:00:00 2001 From: Joey Yandle Date: Thu, 30 Apr 2026 07:19:07 +0200 Subject: [PATCH 3/3] update doc comment for Config constructors --- src/state_machine/coordinator/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/state_machine/coordinator/mod.rs b/src/state_machine/coordinator/mod.rs index e54acc1..12c083a 100644 --- a/src/state_machine/coordinator/mod.rs +++ b/src/state_machine/coordinator/mod.rs @@ -190,6 +190,8 @@ impl fmt::Debug for Config { impl Config { /// Create a new config object with no timeouts + /// dkg_threshold defaults to num_keys + /// sign_threshold defaults to threshold, and must be >= threshold pub fn new( num_signers: u32, num_keys: u32, @@ -215,6 +217,7 @@ impl Config { #[allow(clippy::too_many_arguments)] /// Create a new config object with the passed timeouts + /// sign_threshold defaults to threshold, and must be >= threshold pub fn with_timeouts( num_signers: u32, num_keys: u32,