From 506959d26d12d734978f47c839ab62f6fb157915 Mon Sep 17 00:00:00 2001 From: maclane Date: Wed, 3 Jun 2026 21:40:13 -0400 Subject: [PATCH 1/3] Expose interactive FROST DKG signer ABI --- pkg/tbtc/signer/include/frost_tbtc.h | 7 + pkg/tbtc/signer/src/api.rs | 124 +++++ pkg/tbtc/signer/src/engine.rs | 661 ++++++++++++++++++++++++++- pkg/tbtc/signer/src/lib.rs | 307 ++++++++++++- 4 files changed, 1071 insertions(+), 28 deletions(-) diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index 42b7230d87..c01b432801 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -33,6 +33,13 @@ TbtcSignerResult frost_tbtc_rollback_canary(const uint8_t* request_ptr, size_t r void frost_tbtc_free_buffer(uint8_t* ptr, size_t len); TbtcSignerResult frost_tbtc_run_dkg(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_generate_nonces_and_commitments(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_new_signing_package(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_sign_share(const uint8_t* request_ptr, size_t request_len); +TbtcSignerResult frost_tbtc_aggregate(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_start_sign_round(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_finalize_sign_round(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_build_taproot_tx(const uint8_t* request_ptr, size_t request_len); diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 98441d905a..13a719e52c 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -22,6 +22,130 @@ pub struct DkgResult { pub created_at_unix: u64, } +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgRound1Package { + pub identifier: String, + pub package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgRound2Package { + pub identifier: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sender_identifier: Option, + pub package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart1Request { + pub participant_identifier: String, + pub max_signers: u16, + pub min_signers: u16, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart1Result { + pub secret_package_hex: String, + pub package: DkgRound1Package, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Request { + pub secret_package_hex: String, + pub round1_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart2Result { + pub secret_package_hex: String, + pub packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostKeyPackage { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostPublicKeyPackage { + pub verifying_shares: std::collections::BTreeMap, + pub verifying_key: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart3Request { + pub secret_package_hex: String, + pub round1_packages: Vec, + pub round2_packages: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct DkgPart3Result { + pub key_package: NativeFrostKeyPackage, + pub public_key_package: NativeFrostPublicKeyPackage, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostCommitment { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NativeFrostSignatureShare { + pub identifier: String, + pub data_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GenerateNoncesAndCommitmentsRequest { + pub key_package_identifier: String, + pub key_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct GenerateNoncesAndCommitmentsResult { + pub nonces_hex: String, + pub commitment: NativeFrostCommitment, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NewSigningPackageRequest { + pub message_hex: String, + pub commitments: Vec, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct NewSigningPackageResult { + pub signing_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignShareRequest { + pub signing_package_hex: String, + pub nonces_hex: String, + pub key_package_identifier: String, + pub key_package_hex: String, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct SignShareResult { + pub signature_share: NativeFrostSignatureShare, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AggregateRequest { + pub signing_package_hex: String, + pub signature_shares: Vec, + pub public_key_package: NativeFrostPublicKeyPackage, +} + +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] +pub struct AggregateResult { + pub signature_hex: String, +} + #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct StartSignRoundRequest { pub session_id: String, diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index 4c9462a7f7..80a29f2ce9 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -24,7 +24,7 @@ use std::str::FromStr; use std::sync::{mpsc, Mutex, OnceLock}; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; -use frost_secp256k1_tr as frost; +use frost_secp256k1_tr::{self as frost, keys::EvenY}; use rand_chacha::rand_core::{CryptoRng, Error as RandCoreError, RngCore, SeedableRng}; use rand_chacha::ChaCha20Rng; use serde::{Deserialize, Serialize}; @@ -32,17 +32,22 @@ use sha2::{Digest, Sha256}; use zeroize::{Zeroize, Zeroizing}; use crate::api::{ - AttemptContext, AttemptExclusionEvidence, AttemptTransitionEvidence, - AttemptTransitionTelemetry, BlameProofVerificationResult, BuildTaprootTxRequest, - CanaryRolloutStatusResult, DifferentialDivergence, DifferentialFuzzRequest, - DifferentialFuzzResult, DkgResult, FinalizeSignRoundRequest, PromoteCanaryRequest, - PromoteCanaryResult, QuarantineStatusRequest, QuarantineStatusResult, - RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, - RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, RollbackCanaryResult, - RoundContribution, RoundState, RunDkgRequest, ShareMaterial, SignatureResult, - SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, TranscriptAuditRecord, - TranscriptAuditRequest, TranscriptAuditResult, TriggerEmergencyRekeyRequest, - TriggerEmergencyRekeyResult, VerifyBlameProofRequest, + AggregateRequest, AggregateResult, AttemptContext, AttemptExclusionEvidence, + AttemptTransitionEvidence, AttemptTransitionTelemetry, BlameProofVerificationResult, + BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialDivergence, + DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, + DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgResult, DkgRound1Package, + DkgRound2Package, FinalizeSignRoundRequest, GenerateNoncesAndCommitmentsRequest, + GenerateNoncesAndCommitmentsResult, NativeFrostCommitment, NativeFrostKeyPackage, + NativeFrostPublicKeyPackage, NativeFrostSignatureShare, NewSigningPackageRequest, + NewSigningPackageResult, PromoteCanaryRequest, PromoteCanaryResult, QuarantineStatusRequest, + QuarantineStatusResult, RefreshCadenceStatusRequest, RefreshCadenceStatusResult, + RefreshSharesRequest, RefreshSharesResult, RoastLivenessPolicyResult, RollbackCanaryRequest, + RollbackCanaryResult, RoundContribution, RoundState, RunDkgRequest, ShareMaterial, + SignShareRequest, SignShareResult, SignatureResult, SignerHardeningMetricsResult, + StartSignRoundRequest, TransactionResult, TranscriptAuditRecord, TranscriptAuditRequest, + TranscriptAuditResult, TriggerEmergencyRekeyRequest, TriggerEmergencyRekeyResult, + VerifyBlameProofRequest, }; use crate::errors::EngineError; use crate::go_math_rand::select_coordinator_identifier; @@ -4188,6 +4193,638 @@ fn participant_identifier_to_frost_identifier( }) } +fn frost_identifier_to_go_string(identifier: frost::Identifier) -> String { + serde_json::to_string(&hex::encode(identifier.serialize())) + .expect("serializing hex identifier as JSON string cannot fail") +} + +fn parse_frost_identifier( + operation: &str, + field_name: &str, + raw_identifier: &str, +) -> Result { + if raw_identifier.trim().is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: {field_name} is empty" + ))); + } + + let trimmed = raw_identifier.trim(); + let normalized_hex = if trimmed.starts_with('"') { + serde_json::from_str::(trimmed).map_err(|e| { + EngineError::Validation(format!( + "{operation}: {field_name} must be a JSON string-wrapped hex identifier: {e}" + )) + })? + } else { + trimmed.to_string() + }; + + let bytes = hex::decode(&normalized_hex).map_err(|_| { + EngineError::Validation(format!( + "{operation}: {field_name} must be a hex-encoded FROST identifier" + )) + })?; + + frost::Identifier::deserialize(&bytes) + .map_err(|e| EngineError::Validation(format!("{operation}: invalid {field_name}: {e}"))) +} + +fn decode_hex_field( + operation: &str, + field_name: &str, + value: &str, +) -> Result, EngineError> { + if value.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: {field_name} is empty" + ))); + } + + hex::decode(value).map_err(|_| { + EngineError::Validation(format!("{operation}: {field_name} must be valid hex")) + }) +} + +fn zeroizing_rng_from_os() -> ZeroizingChaCha20Rng { + let mut seed = [0u8; 32]; + OsRng.fill_bytes(&mut seed); + let rng = ZeroizingChaCha20Rng::from_seed(seed); + seed.zeroize(); + rng +} + +fn decode_round1_package_map( + operation: &str, + packages: &[DkgRound1Package], +) -> Result, EngineError> { + if packages.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: round1_packages must not be empty" + ))); + } + + let mut package_map = BTreeMap::new(); + for (index, package) in packages.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("round1_packages[{index}].identifier"), + &package.identifier, + )?; + let package_bytes = decode_hex_field( + operation, + &format!("round1_packages[{index}].package_hex"), + &package.package_hex, + )?; + let round1_package = frost::keys::dkg::round1::Package::deserialize(&package_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid round1 package [{index}]: {e}" + )) + })?; + + if package_map.insert(identifier, round1_package).is_some() { + return Err(EngineError::Validation(format!( + "{operation}: duplicate round1 package identifier [{}]", + package.identifier + ))); + } + } + + Ok(package_map) +} + +fn decode_round2_package_map( + operation: &str, + packages: &[DkgRound2Package], + expected_recipient: Option, +) -> Result, EngineError> { + if packages.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: round2_packages must not be empty" + ))); + } + + let mut package_map = BTreeMap::new(); + for (index, package) in packages.iter().enumerate() { + let recipient_identifier = parse_frost_identifier( + operation, + &format!("round2_packages[{index}].identifier"), + &package.identifier, + )?; + if let Some(expected_recipient) = expected_recipient { + if recipient_identifier != expected_recipient { + return Err(EngineError::Validation(format!( + "{operation}: round2 package [{index}] recipient identifier does not match local DKG participant" + ))); + } + } + + let sender_identifier = package.sender_identifier.as_ref().ok_or_else(|| { + EngineError::Validation(format!( + "{operation}: round2_packages[{index}].sender_identifier is empty" + )) + })?; + let sender_identifier = parse_frost_identifier( + operation, + &format!("round2_packages[{index}].sender_identifier"), + sender_identifier, + )?; + let package_bytes = decode_hex_field( + operation, + &format!("round2_packages[{index}].package_hex"), + &package.package_hex, + )?; + let round2_package = frost::keys::dkg::round2::Package::deserialize(&package_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid round2 package [{index}]: {e}" + )) + })?; + + if package_map + .insert(sender_identifier, round2_package) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate round2 package sender identifier" + ))); + } + } + + Ok(package_map) +} + +fn x_only_verifying_key_hex( + public_key_package: &frost::keys::PublicKeyPackage, +) -> Result { + let compressed = public_key_package + .verifying_key() + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize verifying key: {e}")))?; + + if compressed.len() != 33 || compressed[0] != 0x02 { + return Err(EngineError::Internal( + "expected even-Y compressed FROST verifying key".to_string(), + )); + } + + Ok(hex::encode(&compressed[1..])) +} + +fn native_public_key_package_from_frost( + public_key_package: &frost::keys::PublicKeyPackage, +) -> Result { + let mut verifying_shares = BTreeMap::new(); + for (identifier, verifying_share) in public_key_package.verifying_shares() { + let share_bytes = verifying_share.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize verifying share: {e}")) + })?; + verifying_shares.insert( + frost_identifier_to_go_string(*identifier), + hex::encode(share_bytes), + ); + } + + Ok(NativeFrostPublicKeyPackage { + verifying_shares, + verifying_key: x_only_verifying_key_hex(public_key_package)?, + }) +} + +fn native_public_key_package_to_frost( + operation: &str, + public_key_package: &NativeFrostPublicKeyPackage, +) -> Result { + if public_key_package.verifying_key.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_key is empty" + ))); + } + if public_key_package.verifying_shares.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_shares is empty" + ))); + } + + let mut verifying_key_bytes = decode_hex_field( + operation, + "public_key_package.verifying_key", + &public_key_package.verifying_key, + )?; + if verifying_key_bytes.len() != 32 { + verifying_key_bytes.zeroize(); + return Err(EngineError::Validation(format!( + "{operation}: public_key_package.verifying_key must be a 32-byte x-only key" + ))); + } + + let mut compressed_verifying_key = Vec::with_capacity(33); + compressed_verifying_key.push(0x02); + compressed_verifying_key.extend_from_slice(&verifying_key_bytes); + verifying_key_bytes.zeroize(); + let verifying_key = + frost::VerifyingKey::deserialize(&compressed_verifying_key).map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid public_key_package.verifying_key: {e}" + )) + })?; + compressed_verifying_key.zeroize(); + + let mut verifying_shares = BTreeMap::new(); + for (identifier, share_hex) in &public_key_package.verifying_shares { + let identifier = parse_frost_identifier( + operation, + "public_key_package.verifying_shares identifier", + identifier, + )?; + let share_bytes = decode_hex_field( + operation, + "public_key_package.verifying_shares value", + share_hex, + )?; + let verifying_share = + frost::keys::VerifyingShare::deserialize(&share_bytes).map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid public_key_package verifying share: {e}" + )) + })?; + if verifying_shares + .insert(identifier, verifying_share) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate public_key_package verifying share identifier" + ))); + } + } + + Ok(frost::keys::PublicKeyPackage::new( + verifying_shares, + verifying_key, + None, + )) +} + +fn decode_key_package( + operation: &str, + key_package_identifier: &str, + key_package_hex: &str, +) -> Result { + let expected_identifier = + parse_frost_identifier(operation, "key_package_identifier", key_package_identifier)?; + let mut key_package_bytes = decode_hex_field(operation, "key_package_hex", key_package_hex)?; + let key_package = frost::keys::KeyPackage::deserialize(&key_package_bytes) + .map_err(|e| EngineError::Validation(format!("{operation}: invalid key package: {e}")))?; + key_package_bytes.zeroize(); + + if *key_package.identifier() != expected_identifier { + return Err(EngineError::Validation(format!( + "{operation}: key_package_identifier does not match serialized key package" + ))); + } + + Ok(key_package) +} + +fn decode_signing_commitment_map( + operation: &str, + commitments: &[NativeFrostCommitment], +) -> Result, EngineError> { + if commitments.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: commitments must not be empty" + ))); + } + + let mut commitment_map = BTreeMap::new(); + for (index, commitment) in commitments.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("commitments[{index}].identifier"), + &commitment.identifier, + )?; + let commitment_bytes = decode_hex_field( + operation, + &format!("commitments[{index}].data_hex"), + &commitment.data_hex, + )?; + let signing_commitment = frost::round1::SigningCommitments::deserialize(&commitment_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid signing commitment [{index}]: {e}" + )) + })?; + if commitment_map + .insert(identifier, signing_commitment) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate commitment identifier [{}]", + commitment.identifier + ))); + } + } + + Ok(commitment_map) +} + +fn decode_signature_share_map( + operation: &str, + signature_shares: &[NativeFrostSignatureShare], +) -> Result, EngineError> { + if signature_shares.is_empty() { + return Err(EngineError::Validation(format!( + "{operation}: signature_shares must not be empty" + ))); + } + + let mut signature_share_map = BTreeMap::new(); + for (index, signature_share) in signature_shares.iter().enumerate() { + let identifier = parse_frost_identifier( + operation, + &format!("signature_shares[{index}].identifier"), + &signature_share.identifier, + )?; + let mut signature_share_bytes = decode_hex_field( + operation, + &format!("signature_shares[{index}].data_hex"), + &signature_share.data_hex, + )?; + let signature_share = frost::round2::SignatureShare::deserialize(&signature_share_bytes) + .map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid signature share [{index}]: {e}" + )) + })?; + signature_share_bytes.zeroize(); + if signature_share_map + .insert(identifier, signature_share) + .is_some() + { + return Err(EngineError::Validation(format!( + "{operation}: duplicate signature share identifier" + ))); + } + } + + Ok(signature_share_map) +} + +pub fn dkg_part1(request: DkgPart1Request) -> Result { + enforce_provenance_gate()?; + + if request.max_signers == 0 { + return Err(EngineError::Validation( + "DKGPart1: max_signers is zero".to_string(), + )); + } + if request.min_signers == 0 { + return Err(EngineError::Validation( + "DKGPart1: min_signers is zero".to_string(), + )); + } + if request.min_signers > request.max_signers { + return Err(EngineError::Validation( + "DKGPart1: min_signers exceeds max_signers".to_string(), + )); + } + + let identifier = parse_frost_identifier( + "DKGPart1", + "participant_identifier", + &request.participant_identifier, + )?; + let rng = zeroizing_rng_from_os(); + let (mut secret_package, package) = + frost::keys::dkg::part1(identifier, request.max_signers, request.min_signers, rng) + .map_err(|e| EngineError::Validation(format!("DKGPart1 failed: {e}")))?; + + let mut secret_package_bytes = secret_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part1 secret: {e}")))?; + secret_package.zeroize(); + let package_bytes = package.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize DKG part1 package: {e}")) + })?; + + let result = DkgPart1Result { + secret_package_hex: hex::encode(&secret_package_bytes), + package: DkgRound1Package { + identifier: frost_identifier_to_go_string(identifier), + package_hex: hex::encode(package_bytes), + }, + }; + secret_package_bytes.zeroize(); + + Ok(result) +} + +pub fn dkg_part2(request: DkgPart2Request) -> Result { + enforce_provenance_gate()?; + + let mut secret_package_bytes = decode_hex_field( + "DKGPart2", + "secret_package_hex", + &request.secret_package_hex, + )?; + let secret_package = frost::keys::dkg::round1::SecretPackage::deserialize( + &secret_package_bytes, + ) + .map_err(|e| EngineError::Validation(format!("DKGPart2: invalid secret package: {e}")))?; + secret_package_bytes.zeroize(); + + let round1_packages = decode_round1_package_map("DKGPart2", &request.round1_packages)?; + let (mut round2_secret_package, round2_packages) = + frost::keys::dkg::part2(secret_package, &round1_packages) + .map_err(|e| EngineError::Validation(format!("DKGPart2 failed: {e}")))?; + + let mut round2_secret_package_bytes = round2_secret_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part2 secret: {e}")))?; + round2_secret_package.zeroize(); + + let mut packages = Vec::with_capacity(round2_packages.len()); + for (identifier, package) in round2_packages { + let package_bytes = package.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize DKG part2 package: {e}")) + })?; + packages.push(DkgRound2Package { + identifier: frost_identifier_to_go_string(identifier), + sender_identifier: None, + package_hex: hex::encode(package_bytes), + }); + } + + let result = DkgPart2Result { + secret_package_hex: hex::encode(&round2_secret_package_bytes), + packages, + }; + round2_secret_package_bytes.zeroize(); + + Ok(result) +} + +pub fn dkg_part3(request: DkgPart3Request) -> Result { + enforce_provenance_gate()?; + + let mut secret_package_bytes = decode_hex_field( + "DKGPart3", + "secret_package_hex", + &request.secret_package_hex, + )?; + let mut secret_package = frost::keys::dkg::round2::SecretPackage::deserialize( + &secret_package_bytes, + ) + .map_err(|e| EngineError::Validation(format!("DKGPart3: invalid secret package: {e}")))?; + secret_package_bytes.zeroize(); + + let round1_packages = decode_round1_package_map("DKGPart3", &request.round1_packages)?; + let round2_packages = decode_round2_package_map( + "DKGPart3", + &request.round2_packages, + Some(*secret_package.identifier()), + )?; + let (key_package, public_key_package) = + frost::keys::dkg::part3(&secret_package, &round1_packages, &round2_packages) + .map_err(|e| EngineError::Validation(format!("DKGPart3 failed: {e}")))?; + secret_package.zeroize(); + + let is_even_y = public_key_package.has_even_y(); + let key_package = key_package.into_even_y(Some(is_even_y)); + let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + + let mut key_package_bytes = key_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG key package: {e}")))?; + let native_public_key_package = native_public_key_package_from_frost(&public_key_package)?; + let result = DkgPart3Result { + key_package: NativeFrostKeyPackage { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(&key_package_bytes), + }, + public_key_package: native_public_key_package, + }; + key_package_bytes.zeroize(); + + Ok(result) +} + +pub fn generate_nonces_and_commitments( + request: GenerateNoncesAndCommitmentsRequest, +) -> Result { + enforce_provenance_gate()?; + + let key_package = decode_key_package( + "GenerateNoncesAndCommitments", + &request.key_package_identifier, + &request.key_package_hex, + )?; + let mut rng = zeroizing_rng_from_os(); + let (mut nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); + let mut nonces_bytes = nonces + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize signing nonces: {e}")))?; + nonces.zeroize(); + let commitment_bytes = commitments.serialize().map_err(|e| { + EngineError::Internal(format!("failed to serialize signing commitments: {e}")) + })?; + + let result = GenerateNoncesAndCommitmentsResult { + nonces_hex: hex::encode(&nonces_bytes), + commitment: NativeFrostCommitment { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(commitment_bytes), + }, + }; + nonces_bytes.zeroize(); + + Ok(result) +} + +pub fn new_signing_package( + request: NewSigningPackageRequest, +) -> Result { + enforce_provenance_gate()?; + + let message = if request.message_hex.is_empty() { + Vec::new() + } else { + hex::decode(&request.message_hex).map_err(|_| { + EngineError::Validation("NewSigningPackage: message_hex must be valid hex".to_string()) + })? + }; + let commitments = decode_signing_commitment_map("NewSigningPackage", &request.commitments)?; + let signing_package = frost::SigningPackage::new(commitments, &message); + let signing_package_bytes = signing_package + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize signing package: {e}")))?; + + Ok(NewSigningPackageResult { + signing_package_hex: hex::encode(signing_package_bytes), + }) +} + +pub fn sign_share(request: SignShareRequest) -> Result { + enforce_provenance_gate()?; + + let signing_package_bytes = decode_hex_field( + "SignShare", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package = frost::SigningPackage::deserialize(&signing_package_bytes) + .map_err(|e| EngineError::Validation(format!("SignShare: invalid signing package: {e}")))?; + + let mut nonces_bytes = decode_hex_field("SignShare", "nonces_hex", &request.nonces_hex)?; + let mut nonces = frost::round1::SigningNonces::deserialize(&nonces_bytes) + .map_err(|e| EngineError::Validation(format!("SignShare: invalid nonces: {e}")))?; + nonces_bytes.zeroize(); + + let key_package = decode_key_package( + "SignShare", + &request.key_package_identifier, + &request.key_package_hex, + )?; + let signature_share = frost::round2::sign(&signing_package, &nonces, &key_package) + .map_err(|e| EngineError::Validation(format!("SignShare failed: {e}")))?; + nonces.zeroize(); + let mut signature_share_bytes = signature_share.serialize(); + let result = SignShareResult { + signature_share: NativeFrostSignatureShare { + identifier: frost_identifier_to_go_string(*key_package.identifier()), + data_hex: hex::encode(&signature_share_bytes), + }, + }; + signature_share_bytes.zeroize(); + + Ok(result) +} + +pub fn aggregate(request: AggregateRequest) -> Result { + enforce_provenance_gate()?; + + let signing_package_bytes = decode_hex_field( + "Aggregate", + "signing_package_hex", + &request.signing_package_hex, + )?; + let signing_package = frost::SigningPackage::deserialize(&signing_package_bytes) + .map_err(|e| EngineError::Validation(format!("Aggregate: invalid signing package: {e}")))?; + let signature_shares = decode_signature_share_map("Aggregate", &request.signature_shares)?; + let public_key_package = + native_public_key_package_to_frost("Aggregate", &request.public_key_package)?; + let signature = frost::aggregate(&signing_package, &signature_shares, &public_key_package) + .map_err(|e| EngineError::Validation(format!("Aggregate failed: {e}")))?; + let signature_bytes = signature + .serialize() + .map_err(|e| EngineError::Internal(format!("failed to serialize aggregate: {e}")))?; + + Ok(AggregateResult { + signature_hex: hex::encode(signature_bytes), + }) +} + fn build_deterministic_round_nonce_and_commitment( key_package: &frost::keys::KeyPackage, session_id: &str, diff --git a/pkg/tbtc/signer/src/lib.rs b/pkg/tbtc/signer/src/lib.rs index 9835e0d1c1..b40dfa55b5 100644 --- a/pkg/tbtc/signer/src/lib.rs +++ b/pkg/tbtc/signer/src/lib.rs @@ -8,10 +8,12 @@ mod go_math_rand; use std::sync::OnceLock; use api::{ - BuildTaprootTxRequest, DifferentialFuzzRequest, FinalizeSignRoundRequest, PromoteCanaryRequest, + AggregateRequest, BuildTaprootTxRequest, DifferentialFuzzRequest, DkgPart1Request, + DkgPart2Request, DkgPart3Request, FinalizeSignRoundRequest, + GenerateNoncesAndCommitmentsRequest, NewSigningPackageRequest, PromoteCanaryRequest, QuarantineStatusRequest, RefreshCadenceStatusRequest, RefreshSharesRequest, - RollbackCanaryRequest, RunDkgRequest, StartSignRoundRequest, TranscriptAuditRequest, - TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, + RollbackCanaryRequest, RunDkgRequest, SignShareRequest, StartSignRoundRequest, + TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use ffi::{ ffi_entry, free_buffer, parse_request, serialize_response, success_from_string, @@ -225,6 +227,90 @@ pub extern "C" fn frost_tbtc_run_dkg( }) } +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part1( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart1Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part1(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part2( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart2Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part2(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_dkg_part3( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: DkgPart3Request = parse_request(request_ptr, request_len)?; + let response = engine::dkg_part3(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_generate_nonces_and_commitments( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: GenerateNoncesAndCommitmentsRequest = parse_request(request_ptr, request_len)?; + let response = engine::generate_nonces_and_commitments(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_new_signing_package( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: NewSigningPackageRequest = parse_request(request_ptr, request_len)?; + let response = engine::new_signing_package(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_sign_share( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: SignShareRequest = parse_request(request_ptr, request_len)?; + let response = engine::sign_share(request)?; + serialize_response(&response) + }) +} + +#[no_mangle] +pub extern "C" fn frost_tbtc_aggregate( + request_ptr: *const u8, + request_len: usize, +) -> TbtcSignerResult { + ffi_entry(|| { + let request: AggregateRequest = parse_request(request_ptr, request_len)?; + let response = engine::aggregate(request)?; + serialize_response(&response) + }) +} + #[no_mangle] pub extern "C" fn frost_tbtc_start_sign_round( request_ptr: *const u8, @@ -276,26 +362,36 @@ pub extern "C" fn frost_tbtc_refresh_shares( #[cfg(test)] mod tests { use bitcoin::consensus::encode::deserialize; + use bitcoin::secp256k1::{ + schnorr::Signature as SchnorrSignature, Message as SecpMessage, Secp256k1, XOnlyPublicKey, + }; use pretty_assertions::assert_eq; use sha2::{Digest, Sha256}; use crate::api::{ - BuildTaprootTxRequest, CanaryRolloutStatusResult, DifferentialFuzzRequest, - DifferentialFuzzResult, DkgParticipant, ErrorResponse, FinalizeSignRoundRequest, - PromoteCanaryRequest, QuarantineStatusRequest, QuarantineStatusResult, - RefreshCadenceStatusRequest, RefreshCadenceStatusResult, RefreshSharesRequest, - RoastLivenessPolicyResult, RollbackCanaryRequest, RoundContribution, RunDkgRequest, - ShareMaterial, SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, + AggregateRequest, AggregateResult, BuildTaprootTxRequest, CanaryRolloutStatusResult, + DifferentialFuzzRequest, DifferentialFuzzResult, DkgPart1Request, DkgPart1Result, + DkgPart2Request, DkgPart2Result, DkgPart3Request, DkgPart3Result, DkgParticipant, + DkgRound1Package, DkgRound2Package, ErrorResponse, FinalizeSignRoundRequest, + GenerateNoncesAndCommitmentsRequest, GenerateNoncesAndCommitmentsResult, + NewSigningPackageRequest, NewSigningPackageResult, PromoteCanaryRequest, + QuarantineStatusRequest, QuarantineStatusResult, RefreshCadenceStatusRequest, + RefreshCadenceStatusResult, RefreshSharesRequest, RoastLivenessPolicyResult, + RollbackCanaryRequest, RoundContribution, RunDkgRequest, ShareMaterial, SignShareRequest, + SignShareResult, SignerHardeningMetricsResult, StartSignRoundRequest, TransactionResult, TranscriptAuditRequest, TriggerEmergencyRekeyRequest, VerifyBlameProofRequest, }; use crate::{ - frost_tbtc_build_taproot_tx, frost_tbtc_canary_rollout_status, - frost_tbtc_finalize_sign_round, frost_tbtc_free_buffer, frost_tbtc_hardening_metrics, - frost_tbtc_promote_canary, frost_tbtc_quarantine_status, frost_tbtc_refresh_cadence_status, - frost_tbtc_refresh_shares, frost_tbtc_roast_liveness_policy, - frost_tbtc_roast_transcript_audit, frost_tbtc_rollback_canary, - frost_tbtc_run_differential_fuzzing, frost_tbtc_run_dkg, frost_tbtc_start_sign_round, - frost_tbtc_trigger_emergency_rekey, frost_tbtc_verify_blame_proof, + frost_tbtc_aggregate, frost_tbtc_build_taproot_tx, frost_tbtc_canary_rollout_status, + frost_tbtc_dkg_part1, frost_tbtc_dkg_part2, frost_tbtc_dkg_part3, + frost_tbtc_finalize_sign_round, frost_tbtc_free_buffer, + frost_tbtc_generate_nonces_and_commitments, frost_tbtc_hardening_metrics, + frost_tbtc_new_signing_package, frost_tbtc_promote_canary, frost_tbtc_quarantine_status, + frost_tbtc_refresh_cadence_status, frost_tbtc_refresh_shares, + frost_tbtc_roast_liveness_policy, frost_tbtc_roast_transcript_audit, + frost_tbtc_rollback_canary, frost_tbtc_run_differential_fuzzing, frost_tbtc_run_dkg, + frost_tbtc_sign_share, frost_tbtc_start_sign_round, frost_tbtc_trigger_emergency_rekey, + frost_tbtc_verify_blame_proof, }; fn bootstrap_synthetic_share_hex( @@ -486,6 +582,185 @@ mod tests { assert_eq!(error.recovery_class, "recoverable"); } + fn native_frost_identifier(member_index: u8) -> String { + let mut identifier = [0u8; 32]; + identifier[0] = member_index; + serde_json::to_string(&hex::encode(identifier)) + .expect("identifier JSON encoding cannot fail") + } + + #[test] + fn interactive_frost_dkg_and_signing_ffi_roundtrip() { + let _profile_env = EnvVarGuard::set(super::TBTC_SIGNER_PROFILE_ENV, "development"); + let _provenance_env = EnvVarGuard::set("TBTC_SIGNER_ENFORCE_PROVENANCE_GATE", "false"); + + let participant_ids = [1u8, 2u8, 3u8]; + let participant_identifiers: std::collections::BTreeMap = participant_ids + .iter() + .map(|id| (*id, native_frost_identifier(*id))) + .collect(); + + let mut part1_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let request = DkgPart1Request { + participant_identifier: participant_identifiers[&id].clone(), + max_signers: 3, + min_signers: 2, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part1); + assert_eq!(status, 0); + let result: DkgPart1Result = + serde_json::from_slice(&payload).expect("part1 response decode"); + assert_eq!(result.package.identifier, participant_identifiers[&id]); + assert!(!result.secret_package_hex.is_empty()); + assert!(!result.package.package_hex.is_empty()); + part1_results.insert(id, result); + } + + let mut part2_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let round1_packages: Vec = participant_ids + .iter() + .filter(|other_id| **other_id != id) + .map(|other_id| part1_results[other_id].package.clone()) + .collect(); + let request = DkgPart2Request { + secret_package_hex: part1_results[&id].secret_package_hex.clone(), + round1_packages, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part2); + assert_eq!(status, 0); + let result: DkgPart2Result = + serde_json::from_slice(&payload).expect("part2 response decode"); + assert_eq!(result.packages.len(), 2); + assert!(result + .packages + .iter() + .all(|pkg| pkg.sender_identifier.is_none())); + part2_results.insert(id, result); + } + + let mut part3_results = std::collections::BTreeMap::new(); + for id in participant_ids { + let round1_packages: Vec = participant_ids + .iter() + .filter(|other_id| **other_id != id) + .map(|other_id| part1_results[other_id].package.clone()) + .collect(); + let round2_packages: Vec = participant_ids + .iter() + .filter(|sender_id| **sender_id != id) + .map(|sender_id| { + let mut package = part2_results[sender_id] + .packages + .iter() + .find(|pkg| pkg.identifier == participant_identifiers[&id]) + .expect("round2 package for recipient") + .clone(); + package.sender_identifier = Some(participant_identifiers[sender_id].clone()); + package + }) + .collect(); + let request = DkgPart3Request { + secret_package_hex: part2_results[&id].secret_package_hex.clone(), + round1_packages, + round2_packages, + }; + let (status, payload) = call_ffi(&request, frost_tbtc_dkg_part3); + assert_eq!(status, 0); + let result: DkgPart3Result = + serde_json::from_slice(&payload).expect("part3 response decode"); + assert_eq!(result.key_package.identifier, participant_identifiers[&id]); + assert_eq!(result.public_key_package.verifying_key.len(), 64); + assert_eq!(result.public_key_package.verifying_shares.len(), 3); + part3_results.insert(id, result); + } + + let verifying_key = part3_results[&1].public_key_package.verifying_key.clone(); + for id in participant_ids { + assert_eq!( + part3_results[&id].public_key_package.verifying_key, + verifying_key + ); + assert_eq!( + part3_results[&id].public_key_package.verifying_shares, + part3_results[&1].public_key_package.verifying_shares + ); + } + + let signing_participants = [1u8, 2u8]; + let mut commitments = Vec::new(); + let mut nonces_by_participant = std::collections::BTreeMap::new(); + for id in signing_participants { + let request = GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_generate_nonces_and_commitments); + assert_eq!(status, 0); + let result: GenerateNoncesAndCommitmentsResult = + serde_json::from_slice(&payload).expect("nonce response decode"); + commitments.push(result.commitment); + nonces_by_participant.insert(id, result.nonces_hex); + } + + let message = [0x42u8; 32]; + let request = NewSigningPackageRequest { + message_hex: hex::encode(message), + commitments: commitments.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_new_signing_package); + assert_eq!(status, 0); + let signing_package: NewSigningPackageResult = + serde_json::from_slice(&payload).expect("signing package response decode"); + + let mut signature_shares = Vec::new(); + for id in signing_participants { + let request = SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant[&id].clone(), + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_sign_share); + assert_eq!(status, 0); + let result: SignShareResult = + serde_json::from_slice(&payload).expect("signature share response decode"); + signature_shares.push(result.signature_share); + } + + let request = AggregateRequest { + signing_package_hex: signing_package.signing_package_hex, + signature_shares, + public_key_package: part3_results[&1].public_key_package.clone(), + }; + let (status, payload) = call_ffi(&request, frost_tbtc_aggregate); + assert_eq!(status, 0); + let aggregate: AggregateResult = + serde_json::from_slice(&payload).expect("aggregate response decode"); + + let signature_bytes = hex::decode(aggregate.signature_hex).expect("signature hex"); + assert_eq!(signature_bytes.len(), 64); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(verifying_key).expect("verifying key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only public key"); + let message = SecpMessage::from_digest(message); + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, &public_key) + .expect("aggregate verifies under DKG x-only key"); + + let commitment_identifiers: Vec = commitments + .into_iter() + .map(|commitment| commitment.identifier) + .collect(); + let share_identifiers: Vec = request + .signature_shares + .into_iter() + .map(|share| share.identifier) + .collect(); + assert_eq!(commitment_identifiers, share_identifiers); + } + #[test] fn roast_liveness_policy_reports_default_contract() { let _guard = crate::engine::lock_test_state(); From 4f775b94db7df3b6ef24355925e161480c9d6129 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 6 Jun 2026 16:49:43 -0400 Subject: [PATCH 2/3] Document interactive FROST nonce contract --- pkg/tbtc/signer/include/frost_tbtc.h | 11 ++ pkg/tbtc/signer/src/api.rs | 10 ++ pkg/tbtc/signer/src/engine.rs | 225 +++++++++++++++++++++++++++ 3 files changed, 246 insertions(+) diff --git a/pkg/tbtc/signer/include/frost_tbtc.h b/pkg/tbtc/signer/include/frost_tbtc.h index c01b432801..dd798fe7eb 100644 --- a/pkg/tbtc/signer/include/frost_tbtc.h +++ b/pkg/tbtc/signer/include/frost_tbtc.h @@ -36,6 +36,17 @@ TbtcSignerResult frost_tbtc_run_dkg(const uint8_t* request_ptr, size_t request_l TbtcSignerResult frost_tbtc_dkg_part1(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_dkg_part2(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_dkg_part3(const uint8_t* request_ptr, size_t request_len); + +/* + * Stateless interactive signing nonce contract: + * + * frost_tbtc_generate_nonces_and_commitments returns `nonces_hex`, a secret + * one-time FROST nonce package. The caller owns that secret after it crosses + * the FFI boundary and must pass it to frost_tbtc_sign_share at most once. + * Reusing the same `nonces_hex` for a different signing package/message can + * reveal the caller's private signing share. The caller should erase its copy + * immediately after the single frost_tbtc_sign_share call. + */ TbtcSignerResult frost_tbtc_generate_nonces_and_commitments(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_new_signing_package(const uint8_t* request_ptr, size_t request_len); TbtcSignerResult frost_tbtc_sign_share(const uint8_t* request_ptr, size_t request_len); diff --git a/pkg/tbtc/signer/src/api.rs b/pkg/tbtc/signer/src/api.rs index 13a719e52c..2489524933 100644 --- a/pkg/tbtc/signer/src/api.rs +++ b/pkg/tbtc/signer/src/api.rs @@ -106,6 +106,12 @@ pub struct GenerateNoncesAndCommitmentsRequest { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct GenerateNoncesAndCommitmentsResult { + /// Secret one-time FROST signing nonces serialized as hex. + /// + /// The caller owns this secret after it crosses the FFI boundary. It must + /// be supplied to `SignShareRequest::nonces_hex` at most once and erased by + /// the caller immediately afterward. Reuse for another signing package or + /// message can reveal the private signing share. pub nonces_hex: String, pub commitment: NativeFrostCommitment, } @@ -124,6 +130,10 @@ pub struct NewSigningPackageResult { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] pub struct SignShareRequest { pub signing_package_hex: String, + /// Secret one-time nonces returned by `GenerateNoncesAndCommitmentsResult`. + /// + /// This stateless endpoint cannot remember consumed nonces across FFI + /// calls. The caller is cryptographically responsible for single use. pub nonces_hex: String, pub key_package_identifier: String, pub key_package_hex: String, diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index 80a29f2ce9..93271f3772 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -7216,6 +7216,231 @@ mod tests { serde_json::from_slice(&vector_bytes).expect("attempt-context vectors decode") } + struct InteractiveDkgFixture { + pre_normalization_even_y: bool, + part3_requests: BTreeMap, + } + + fn deterministic_interactive_dkg_fixture(seed: u8) -> InteractiveDkgFixture { + let participant_ids = [1u16, 2, 3]; + let participant_identifiers: BTreeMap = participant_ids + .iter() + .copied() + .map(|id| { + ( + id, + participant_identifier_to_frost_identifier(id).expect("participant identifier"), + ) + }) + .collect(); + let participant_id_by_identifier_hex: BTreeMap = participant_identifiers + .iter() + .map(|(id, identifier)| (hex::encode(identifier.serialize()), *id)) + .collect(); + + let mut part1_secrets = BTreeMap::new(); + let mut part1_packages = BTreeMap::new(); + for id in participant_ids { + let mut rng_seed = [0u8; 32]; + rng_seed[0] = seed; + rng_seed[1..3].copy_from_slice(&id.to_be_bytes()); + let rng = ZeroizingChaCha20Rng::from_seed(rng_seed); + let (secret_package, package) = frost::keys::dkg::part1( + participant_identifiers[&id], + participant_ids.len() as u16, + 2, + rng, + ) + .expect("DKG part1"); + + part1_secrets.insert(id, secret_package); + part1_packages.insert( + id, + DkgRound1Package { + identifier: frost_identifier_to_go_string(participant_identifiers[&id]), + package_hex: hex::encode(package.serialize().expect("round1 package")), + }, + ); + } + + let round1_packages_for = |recipient_id: u16| -> Vec { + participant_ids + .iter() + .copied() + .filter(|id| *id != recipient_id) + .map(|id| part1_packages[&id].clone()) + .collect() + }; + + let mut part2_secrets = BTreeMap::new(); + let mut round2_packages_by_recipient: BTreeMap> = + BTreeMap::new(); + for sender_id in participant_ids { + let round1_packages = + decode_round1_package_map("TestDKGPart2", &round1_packages_for(sender_id)) + .expect("round1 package map"); + let (round2_secret, round2_packages) = frost::keys::dkg::part2( + part1_secrets + .remove(&sender_id) + .expect("part1 secret package"), + &round1_packages, + ) + .expect("DKG part2"); + + part2_secrets.insert(sender_id, round2_secret); + for (recipient_identifier, package) in round2_packages { + let recipient_id = participant_id_by_identifier_hex + .get(&hex::encode(recipient_identifier.serialize())) + .copied() + .expect("recipient identifier mapping"); + round2_packages_by_recipient + .entry(recipient_id) + .or_default() + .push(DkgRound2Package { + identifier: frost_identifier_to_go_string(recipient_identifier), + sender_identifier: Some(frost_identifier_to_go_string( + participant_identifiers[&sender_id], + )), + package_hex: hex::encode(package.serialize().expect("round2 package")), + }); + } + } + + let first_participant = participant_ids[0]; + let round1_packages = + decode_round1_package_map("TestDKGPart3", &round1_packages_for(first_participant)) + .expect("round1 package map"); + let round2_packages = decode_round2_package_map( + "TestDKGPart3", + &round2_packages_by_recipient[&first_participant], + Some(participant_identifiers[&first_participant]), + ) + .expect("round2 package map"); + let (_, pre_normalization_public_key_package) = frost::keys::dkg::part3( + part2_secrets + .get(&first_participant) + .expect("round2 secret package"), + &round1_packages, + &round2_packages, + ) + .expect("DKG part3"); + + let mut part3_requests = BTreeMap::new(); + for id in participant_ids { + let secret_package = part2_secrets.get(&id).expect("round2 secret package"); + let secret_package_bytes = secret_package.serialize().expect("round2 secret"); + part3_requests.insert( + id, + DkgPart3Request { + secret_package_hex: hex::encode(secret_package_bytes), + round1_packages: round1_packages_for(id), + round2_packages: round2_packages_by_recipient + .get(&id) + .expect("round2 packages") + .clone(), + }, + ); + } + + InteractiveDkgFixture { + pre_normalization_even_y: pre_normalization_public_key_package.has_even_y(), + part3_requests, + } + } + + fn deterministic_odd_y_interactive_dkg_fixture() -> InteractiveDkgFixture { + for seed in 0u8..=u8::MAX { + let fixture = deterministic_interactive_dkg_fixture(seed); + if !fixture.pre_normalization_even_y { + return fixture; + } + } + + panic!("could not find deterministic odd-Y DKG fixture"); + } + + #[test] + fn dkg_part3_normalizes_odd_y_group_key_and_secret_shares() { + let _guard = lock_test_state(); + reset_for_tests(); + + let fixture = deterministic_odd_y_interactive_dkg_fixture(); + assert!( + !fixture.pre_normalization_even_y, + "fixture must exercise the odd-Y normalization branch" + ); + + let mut part3_results = BTreeMap::new(); + for (id, request) in fixture.part3_requests { + let result = dkg_part3(request).expect("DKG part3"); + let expected_identifier = frost_identifier_to_go_string( + participant_identifier_to_frost_identifier(id).unwrap(), + ); + assert_eq!(result.key_package.identifier, expected_identifier); + assert_eq!(result.public_key_package.verifying_key.len(), 64); + part3_results.insert(id, result); + } + + let exported_x_only_key = part3_results[&1].public_key_package.verifying_key.clone(); + for result in part3_results.values() { + assert_eq!(result.public_key_package.verifying_key, exported_x_only_key); + assert_eq!( + result.public_key_package.verifying_shares, + part3_results[&1].public_key_package.verifying_shares + ); + } + + let signing_participants = [1u16, 2]; + let mut commitments = Vec::new(); + let mut nonces_by_participant = BTreeMap::new(); + for id in signing_participants { + let result = generate_nonces_and_commitments(GenerateNoncesAndCommitmentsRequest { + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }) + .expect("generate nonces"); + commitments.push(result.commitment); + nonces_by_participant.insert(id, result.nonces_hex); + } + + let message = [0x42u8; 32]; + let signing_package = new_signing_package(NewSigningPackageRequest { + message_hex: hex::encode(message), + commitments, + }) + .expect("new signing package"); + + let mut signature_shares = Vec::new(); + for id in signing_participants { + let result = sign_share(SignShareRequest { + signing_package_hex: signing_package.signing_package_hex.clone(), + nonces_hex: nonces_by_participant + .remove(&id) + .expect("participant nonces"), + key_package_identifier: part3_results[&id].key_package.identifier.clone(), + key_package_hex: part3_results[&id].key_package.data_hex.clone(), + }) + .expect("sign share"); + signature_shares.push(result.signature_share); + } + + let aggregate = aggregate(AggregateRequest { + signing_package_hex: signing_package.signing_package_hex, + signature_shares, + public_key_package: part3_results[&1].public_key_package.clone(), + }) + .expect("aggregate"); + + let signature_bytes = hex::decode(aggregate.signature_hex).expect("signature hex"); + let signature = SchnorrSignature::from_slice(&signature_bytes).expect("BIP340 signature"); + let public_key_bytes = hex::decode(exported_x_only_key).expect("verifying key hex"); + let public_key = XOnlyPublicKey::from_slice(&public_key_bytes).expect("x-only public key"); + let message = SecpMessage::from_digest(message); + Secp256k1::verification_only() + .verify_schnorr(&signature, &message, &public_key) + .expect("aggregate verifies under normalized x-only key"); + } + fn seeded_round_state(session_id: &str) -> RoundState { let run_dkg_request = RunDkgRequest { session_id: session_id.to_string(), From 57461c38feb9353c817bd688905dcb4e6ad78dd3 Mon Sep 17 00:00:00 2001 From: maclane Date: Sat, 6 Jun 2026 17:04:28 -0400 Subject: [PATCH 3/3] Zeroize interactive FROST secret buffers --- pkg/tbtc/signer/src/engine.rs | 155 ++++++++++++++++++++++------------ 1 file changed, 101 insertions(+), 54 deletions(-) diff --git a/pkg/tbtc/signer/src/engine.rs b/pkg/tbtc/signer/src/engine.rs index 93271f3772..f9af2b1e66 100644 --- a/pkg/tbtc/signer/src/engine.rs +++ b/pkg/tbtc/signer/src/engine.rs @@ -4330,17 +4330,18 @@ fn decode_round2_package_map( &format!("round2_packages[{index}].sender_identifier"), sender_identifier, )?; - let package_bytes = decode_hex_field( + let mut package_bytes = decode_hex_field( operation, &format!("round2_packages[{index}].package_hex"), &package.package_hex, )?; - let round2_package = frost::keys::dkg::round2::Package::deserialize(&package_bytes) - .map_err(|e| { - EngineError::Validation(format!( - "{operation}: invalid round2 package [{index}]: {e}" - )) - })?; + let round2_package_result = frost::keys::dkg::round2::Package::deserialize(&package_bytes); + package_bytes.zeroize(); + let round2_package = round2_package_result.map_err(|e| { + EngineError::Validation(format!( + "{operation}: invalid round2 package [{index}]: {e}" + )) + })?; if package_map .insert(sender_identifier, round2_package) @@ -4474,9 +4475,10 @@ fn decode_key_package( let expected_identifier = parse_frost_identifier(operation, "key_package_identifier", key_package_identifier)?; let mut key_package_bytes = decode_hex_field(operation, "key_package_hex", key_package_hex)?; - let key_package = frost::keys::KeyPackage::deserialize(&key_package_bytes) - .map_err(|e| EngineError::Validation(format!("{operation}: invalid key package: {e}")))?; + let key_package_result = frost::keys::KeyPackage::deserialize(&key_package_bytes); key_package_bytes.zeroize(); + let key_package = key_package_result + .map_err(|e| EngineError::Validation(format!("{operation}: invalid key package: {e}")))?; if *key_package.identifier() != expected_identifier { return Err(EngineError::Validation(format!( @@ -4600,13 +4602,19 @@ pub fn dkg_part1(request: DkgPart1Request) -> Result package_bytes, + Err(err) => { + secret_package.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize DKG part1 package: {err}" + ))); + } + }; + let secret_package_bytes_result = secret_package.serialize(); secret_package.zeroize(); - let package_bytes = package.serialize().map_err(|e| { - EngineError::Internal(format!("failed to serialize DKG part1 package: {e}")) - })?; + let mut secret_package_bytes = secret_package_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part1 secret: {e}")))?; let result = DkgPart1Result { secret_package_hex: hex::encode(&secret_package_bytes), @@ -4628,34 +4636,47 @@ pub fn dkg_part2(request: DkgPart2Request) -> Result round1_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; let (mut round2_secret_package, round2_packages) = frost::keys::dkg::part2(secret_package, &round1_packages) .map_err(|e| EngineError::Validation(format!("DKGPart2 failed: {e}")))?; - let mut round2_secret_package_bytes = round2_secret_package - .serialize() - .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part2 secret: {e}")))?; - round2_secret_package.zeroize(); - let mut packages = Vec::with_capacity(round2_packages.len()); for (identifier, package) in round2_packages { - let package_bytes = package.serialize().map_err(|e| { - EngineError::Internal(format!("failed to serialize DKG part2 package: {e}")) - })?; + let mut package_bytes = match package.serialize() { + Ok(package_bytes) => package_bytes, + Err(err) => { + round2_secret_package.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize DKG part2 package: {err}" + ))); + } + }; packages.push(DkgRound2Package { identifier: frost_identifier_to_go_string(identifier), sender_identifier: None, - package_hex: hex::encode(package_bytes), + package_hex: hex::encode(&package_bytes), }); + package_bytes.zeroize(); } + let round2_secret_package_bytes_result = round2_secret_package.serialize(); + round2_secret_package.zeroize(); + let mut round2_secret_package_bytes = round2_secret_package_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize DKG part2 secret: {e}")))?; + let result = DkgPart2Result { secret_package_hex: hex::encode(&round2_secret_package_bytes), packages, @@ -4673,31 +4694,43 @@ pub fn dkg_part3(request: DkgPart3Request) -> Result round1_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; + let round2_packages = match decode_round2_package_map( "DKGPart3", &request.round2_packages, Some(*secret_package.identifier()), - )?; - let (key_package, public_key_package) = - frost::keys::dkg::part3(&secret_package, &round1_packages, &round2_packages) - .map_err(|e| EngineError::Validation(format!("DKGPart3 failed: {e}")))?; + ) { + Ok(round2_packages) => round2_packages, + Err(err) => { + secret_package.zeroize(); + return Err(err); + } + }; + let dkg_result = frost::keys::dkg::part3(&secret_package, &round1_packages, &round2_packages); secret_package.zeroize(); + let (key_package, public_key_package) = + dkg_result.map_err(|e| EngineError::Validation(format!("DKGPart3 failed: {e}")))?; let is_even_y = public_key_package.has_even_y(); let key_package = key_package.into_even_y(Some(is_even_y)); let public_key_package = public_key_package.into_even_y(Some(is_even_y)); + let native_public_key_package = native_public_key_package_from_frost(&public_key_package)?; let mut key_package_bytes = key_package .serialize() .map_err(|e| EngineError::Internal(format!("failed to serialize DKG key package: {e}")))?; - let native_public_key_package = native_public_key_package_from_frost(&public_key_package)?; let result = DkgPart3Result { key_package: NativeFrostKeyPackage { identifier: frost_identifier_to_go_string(*key_package.identifier()), @@ -4722,13 +4755,19 @@ pub fn generate_nonces_and_commitments( )?; let mut rng = zeroizing_rng_from_os(); let (mut nonces, commitments) = frost::round1::commit(key_package.signing_share(), &mut rng); - let mut nonces_bytes = nonces - .serialize() - .map_err(|e| EngineError::Internal(format!("failed to serialize signing nonces: {e}")))?; + let commitment_bytes = match commitments.serialize() { + Ok(commitment_bytes) => commitment_bytes, + Err(err) => { + nonces.zeroize(); + return Err(EngineError::Internal(format!( + "failed to serialize signing commitments: {err}" + ))); + } + }; + let nonces_bytes_result = nonces.serialize(); nonces.zeroize(); - let commitment_bytes = commitments.serialize().map_err(|e| { - EngineError::Internal(format!("failed to serialize signing commitments: {e}")) - })?; + let mut nonces_bytes = nonces_bytes_result + .map_err(|e| EngineError::Internal(format!("failed to serialize signing nonces: {e}")))?; let result = GenerateNoncesAndCommitmentsResult { nonces_hex: hex::encode(&nonces_bytes), @@ -4777,18 +4816,26 @@ pub fn sign_share(request: SignShareRequest) -> Result key_package, + Err(err) => { + nonces.zeroize(); + return Err(err); + } + }; + let signature_share_result = frost::round2::sign(&signing_package, &nonces, &key_package); nonces.zeroize(); + let signature_share = signature_share_result + .map_err(|e| EngineError::Validation(format!("SignShare failed: {e}")))?; let mut signature_share_bytes = signature_share.serialize(); let result = SignShareResult { signature_share: NativeFrostSignatureShare {