From 327809e8374837fda44ee6530bdd4d561e99dc90 Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 19 Feb 2026 18:29:30 -0800 Subject: [PATCH] Add high level api --- frost-taproot/Cargo.lock | 1 + frost-taproot/Cargo.toml | 1 + frost-taproot/src/frost/dealer.rs | 109 ++++ frost-taproot/src/frost/ecdh.rs | 113 ++++ frost-taproot/src/frost/mod.rs | 33 ++ frost-taproot/src/frost/nonce.rs | 95 +++ frost-taproot/src/frost/signing.rs | 231 ++++++++ frost-taproot/src/frost/tests.rs | 908 +++++++++++++++++++++++++++++ frost-taproot/src/frost/types.rs | 150 +++++ frost-taproot/src/lib.rs | 1 + 10 files changed, 1642 insertions(+) create mode 100644 frost-taproot/src/frost/dealer.rs create mode 100644 frost-taproot/src/frost/ecdh.rs create mode 100644 frost-taproot/src/frost/mod.rs create mode 100644 frost-taproot/src/frost/nonce.rs create mode 100644 frost-taproot/src/frost/signing.rs create mode 100644 frost-taproot/src/frost/tests.rs create mode 100644 frost-taproot/src/frost/types.rs diff --git a/frost-taproot/Cargo.lock b/frost-taproot/Cargo.lock index a534d33..662e708 100644 --- a/frost-taproot/Cargo.lock +++ b/frost-taproot/Cargo.lock @@ -136,6 +136,7 @@ name = "frost-taproot" version = "0.1.0" dependencies = [ "hex", + "hmac", "k256", "rand", "sha2", diff --git a/frost-taproot/Cargo.toml b/frost-taproot/Cargo.toml index fcfe75a..66efc11 100644 --- a/frost-taproot/Cargo.toml +++ b/frost-taproot/Cargo.toml @@ -6,6 +6,7 @@ edition = "2024" [dependencies] k256 = { version = "0.13", features = ["arithmetic", "hash2curve", "schnorr"] } sha2 = "0.10" +hmac = "0.12" rand = "0.8" thiserror = "1" signature = "2" diff --git a/frost-taproot/src/frost/dealer.rs b/frost-taproot/src/frost/dealer.rs new file mode 100644 index 0000000..90d2469 --- /dev/null +++ b/frost-taproot/src/frost/dealer.rs @@ -0,0 +1,109 @@ +/// Trusted dealer key generation and group management. +use sha2::{Digest, Sha256}; + +use crate::group::create_dealer_set; +use crate::helpers::get_pubkey; +use crate::Error; + +use super::types::{DealerPackage, GroupPackage, MemberPackage, SharePackage}; + +/// Generate a complete dealer package: group info + all secret shares. +/// +/// `secrets` seeds the polynomial deterministically; pass `&[]` for a +/// fully random group key. +/// +/// # Example +/// ```rust +/// use frost_taproot::frost::dealer::generate_dealer_package; +/// let pkg = generate_dealer_package(2, 3, &[]).unwrap(); +/// assert_eq!(pkg.shares.len(), 3); +/// assert_eq!(pkg.group.threshold, 2); +/// ``` +pub fn generate_dealer_package( + threshold: usize, + share_count: u32, + secrets: &[[u8; 32]], +) -> Result { + let dealer_set = create_dealer_set(threshold, share_count, secrets)?; + + let shares: Vec = dealer_set + .shares + .iter() + .map(|s| SharePackage { + idx: s.idx, + seckey: s.seckey, + }) + .collect(); + + let members: Vec = dealer_set + .shares + .iter() + .map(|s| MemberPackage { + idx: s.idx, + pubkey: get_pubkey(&s.seckey), + }) + .collect(); + + let group = GroupPackage { + group_pk: dealer_set.group_pk, + threshold, + members, + }; + + Ok(DealerPackage { group, shares }) +} + +/// Compute a stable group identifier: SHA-256(group_pk || threshold_u32_be || sorted_member_pubkeys). +pub fn get_group_id(group: &GroupPackage) -> [u8; 32] { + let mut sorted = group.members.clone(); + sorted.sort_by_key(|m| m.idx); + + let mut hasher = Sha256::new(); + hasher.update(&group.group_pk); + hasher.update((group.threshold as u32).to_be_bytes()); + for m in &sorted { + hasher.update(&m.pubkey); + } + hasher.finalize().into() +} + +/// Check whether a share belongs to the given group. +/// +/// Verifies that the share's derived public key matches the member entry +/// at the same index. +pub fn is_group_member(group: &GroupPackage, share: &SharePackage) -> bool { + let pubkey = get_pubkey(&share.seckey); + group + .members + .iter() + .any(|m| m.idx == share.idx && m.pubkey == pubkey) +} + +/// Verify all shares in a dealer package against the group's VSS commitments. +/// +/// Returns `Ok(true)` if every share is valid, `Ok(false)` if any fails, +/// or an `Err` on a crypto error. +pub fn verify_dealer_package(pkg: &DealerPackage) -> Result { + // Re-derive VSS commits from the group's member pubkeys. + // The group_pk is the first VSS commit (constant term * G). + // We can verify membership via is_group_member for each share. + for share in &pkg.shares { + if !is_group_member(&pkg.group, share) { + return Ok(false); + } + } + Ok(true) +} + +/// Look up a member by index. +pub fn get_member_by_idx(group: &GroupPackage, idx: u32) -> Option<&MemberPackage> { + group.members.iter().find(|m| m.idx == idx) +} + +/// Look up a member by public key. +pub fn get_member_by_pubkey<'a>( + group: &'a GroupPackage, + pubkey: &[u8; 33], +) -> Option<&'a MemberPackage> { + group.members.iter().find(|m| &m.pubkey == pubkey) +} diff --git a/frost-taproot/src/frost/ecdh.rs b/frost-taproot/src/frost/ecdh.rs new file mode 100644 index 0000000..4b298c1 --- /dev/null +++ b/frost-taproot/src/frost/ecdh.rs @@ -0,0 +1,113 @@ +/// High-level threshold ECDH key derivation. +/// +/// Allows a threshold quorum to collaboratively derive a shared secret +/// with any external public key, without any single member knowing the +/// full group private key. +use crate::ecdh::{create_ecdh_share, derive_ecdh_secret}; +use crate::types::{PublicShare, SecretShare as LowSecretShare}; +use crate::Error; + +use super::types::{EcdhEntry, EcdhPackage, SharePackage}; + +/// Create an ECDH package for a single target public key. +/// +/// `members` is the set of participant indices in the quorum (used for +/// Lagrange interpolation). The result contains this member's keyshare +/// contribution toward the shared secret with `ecdh_pk`. +pub fn create_ecdh_pkg( + members: &[u32], + ecdh_pk: &[u8; 33], + share: &SharePackage, +) -> Result { + let low_share = LowSecretShare { + idx: share.idx, + seckey: share.seckey, + }; + let ecdh_share = create_ecdh_share(members, &low_share, ecdh_pk)?; + Ok(EcdhPackage { + idx: share.idx, + members: members.to_vec(), + entries: vec![EcdhEntry { + ecdh_pk: *ecdh_pk, + keyshare: ecdh_share.pubkey, + }], + }) +} + +/// Create an ECDH package for multiple target public keys in one call. +/// +/// More efficient than calling [`create_ecdh_pkg`] in a loop when you +/// need shared secrets with several keys at once. +pub fn create_batched_ecdh_pkg( + members: &[u32], + ecdh_pks: &[[u8; 33]], + share: &SharePackage, +) -> Result { + let low_share = LowSecretShare { + idx: share.idx, + seckey: share.seckey, + }; + let entries: Vec = ecdh_pks + .iter() + .map(|pk| { + let ecdh_share = create_ecdh_share(members, &low_share, pk)?; + Ok(EcdhEntry { + ecdh_pk: *pk, + keyshare: ecdh_share.pubkey, + }) + }) + .collect::>()?; + + Ok(EcdhPackage { + idx: share.idx, + members: members.to_vec(), + entries, + }) +} + +/// Combine ECDH packages from all quorum members to derive the shared secret +/// for a single target public key. +/// +/// `pkgs` must contain one package per quorum member, each with an entry +/// for `ecdh_pk`. +pub fn combine_ecdh_pkgs(pkgs: &[EcdhPackage], ecdh_pk: &[u8; 33]) -> Result<[u8; 33], Error> { + let keyshares: Vec = pkgs + .iter() + .map(|pkg| { + let entry = pkg + .entries + .iter() + .find(|e| &e.ecdh_pk == ecdh_pk) + .ok_or_else(|| { + Error::Assertion(format!( + "ecdh_pk not found in package from member {}", + pkg.idx + )) + })?; + Ok(PublicShare { + idx: pkg.idx, + pubkey: entry.keyshare, + }) + }) + .collect::>()?; + + derive_ecdh_secret(&keyshares) +} + +/// Combine ECDH packages for all target keys present in the batch. +/// +/// Returns a `Vec` of `(ecdh_pk, shared_secret)` pairs, one per unique +/// target key found in the first package. +pub fn combine_batched_ecdh_pkgs(pkgs: &[EcdhPackage]) -> Result, Error> { + if pkgs.is_empty() { + return Ok(vec![]); + } + pkgs[0] + .entries + .iter() + .map(|entry| { + let secret = combine_ecdh_pkgs(pkgs, &entry.ecdh_pk)?; + Ok((entry.ecdh_pk, secret)) + }) + .collect() +} diff --git a/frost-taproot/src/frost/mod.rs b/frost-taproot/src/frost/mod.rs new file mode 100644 index 0000000..30ebebd --- /dev/null +++ b/frost-taproot/src/frost/mod.rs @@ -0,0 +1,33 @@ +/// High-level FROST threshold signing API. +/// +/// This module provides use-case-focused utilities for: +/// - **Distributed key generation** (trusted dealer): [`dealer`] +/// - **Nonce management**: [`nonce`] +/// - **Collaborative signing**: [`signing`] +/// - **Shared key derivation** (threshold ECDH): [`ecdh`] +/// +/// # Quick start +/// +/// ```rust +/// use frost_taproot::frost::{dealer, nonce, signing, ecdh}; +/// +/// // 1. Generate a 2-of-3 group (trusted dealer). +/// let pkg = dealer::generate_dealer_package(2, 3, &[]).unwrap(); +/// +/// // 2. Each signer generates a nonce pair before each signing round. +/// let nonce_pair = nonce::generate_nonce_pair(&pkg.shares[0].seckey); +/// +/// // 3. Collect nonces from all participating signers, then create a session. +/// // 4. Each signer produces a partial signature. +/// // 5. Combine partial signatures into a final BIP340 signature. +/// ``` +pub mod dealer; +pub mod ecdh; +pub mod nonce; +pub mod signing; +pub mod types; + +#[cfg(test)] +mod tests; + +pub use types::*; diff --git a/frost-taproot/src/frost/nonce.rs b/frost-taproot/src/frost/nonce.rs new file mode 100644 index 0000000..e7ec9fe --- /dev/null +++ b/frost-taproot/src/frost/nonce.rs @@ -0,0 +1,95 @@ +/// Nonce generation and derivation for FROST signing sessions. +/// +/// Uses HMAC-SHA256 to derive secret nonces from a share secret and a +/// random 32-byte code. Only the code needs to be stored; secrets are +/// re-derived on demand during signing, eliminating the need to persist +/// secret nonce material. +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +use crate::helpers::get_pubkey; +use crate::util::helpers::random_bytes_32; + +use super::types::{DerivedNonce, MemberNonce, SecretNoncePair}; + +type HmacSha256 = Hmac; + +const DOMAIN_BINDER: &[u8] = b"bifrost/nonce/binder/v1"; +const DOMAIN_HIDDEN: &[u8] = b"bifrost/nonce/hidden/v1"; + +/// Derive a secret nonce component via HMAC-SHA256. +/// +/// `key = share_secret`, `msg = code || domain` +fn derive_nonce_secret(share_secret: &[u8; 32], code: &[u8; 32], domain: &[u8]) -> [u8; 32] { + let mut mac = HmacSha256::new_from_slice(share_secret).expect("HMAC accepts any key size"); + mac.update(code); + mac.update(domain); + mac.finalize().into_bytes().into() +} + +/// Generate a fresh nonce pair from a share secret. +/// +/// A random 32-byte code is generated; the secret nonces are derived from +/// it via HMAC. Only the `DerivedNonce` (containing the code and public +/// points) needs to be stored or transmitted — secrets are re-derived +/// during signing via [`derive_secret_nonce`]. +pub fn generate_nonce_pair(share_secret: &[u8; 32]) -> DerivedNonce { + let code = random_bytes_32(); + let binder_sn = derive_nonce_secret(share_secret, &code, DOMAIN_BINDER); + let hidden_sn = derive_nonce_secret(share_secret, &code, DOMAIN_HIDDEN); + let binder_pn = get_pubkey(&binder_sn); + let hidden_pn = get_pubkey(&hidden_sn); + DerivedNonce { + binder_pn, + hidden_pn, + code, + } +} + +/// Generate `count` nonce pairs from a share secret. +pub fn generate_nonce_pairs(share_secret: &[u8; 32], count: usize) -> Vec { + (0..count) + .map(|_| generate_nonce_pair(share_secret)) + .collect() +} + +/// Re-derive the secret nonce pair from a code and share secret. +/// +/// Call this during signing when you need the secret values but only +/// stored the code. +pub fn derive_secret_nonce(share_secret: &[u8; 32], code: &[u8; 32]) -> SecretNoncePair { + let binder_sn = derive_nonce_secret(share_secret, code, DOMAIN_BINDER); + let hidden_sn = derive_nonce_secret(share_secret, code, DOMAIN_HIDDEN); + SecretNoncePair { + code: *code, + binder_sn, + hidden_sn, + } +} + +/// Verify that a code produces the expected public nonces. +/// +/// Use this to authenticate a code sent back by a peer during signing. +pub fn verify_nonce_code(share_secret: &[u8; 32], nonce: &MemberNonce) -> bool { + let derived = derive_secret_nonce(share_secret, &nonce.code); + let binder_pn = get_pubkey(&derived.binder_sn); + let hidden_pn = get_pubkey(&derived.hidden_sn); + // Constant-time comparison + binder_pn == nonce.binder_pn && hidden_pn == nonce.hidden_pn +} + +/// Attach a member index to a `DerivedNonce`, producing a `MemberNonce`. +pub fn to_member_nonce(nonce: DerivedNonce, idx: u32) -> MemberNonce { + MemberNonce { + idx, + binder_pn: nonce.binder_pn, + hidden_pn: nonce.hidden_pn, + code: nonce.code, + } +} + +/// Validate that a `DerivedNonce` contains well-formed curve points. +pub fn validate_nonce(nonce: &DerivedNonce) -> bool { + use crate::ecc::util::lift_x; + lift_x(&nonce.binder_pn).is_ok() && lift_x(&nonce.hidden_pn).is_ok() +} diff --git a/frost-taproot/src/frost/signing.rs b/frost-taproot/src/frost/signing.rs new file mode 100644 index 0000000..556cc7c --- /dev/null +++ b/frost-taproot/src/frost/signing.rs @@ -0,0 +1,231 @@ +/// High-level signing session management and partial signature operations. +use sha2::{Digest, Sha256}; + +use crate::context::get_group_signing_ctx; +use crate::sign::{ + combine_partial_sigs as low_combine, sign_msg, verify_final_sig, + verify_partial_sig as low_verify, +}; +use crate::types::{ + PublicNonce as LowPublicNonce, SecretNonce as LowSecretNonce, SecretShare as LowSecretShare, + ShareSignature, +}; +use crate::Error; + +use super::dealer::get_group_id; +use super::types::{ + GroupPackage, MemberNonce, PartialSig, PartialSigPackage, SecretNoncePair, SharePackage, + SignSession, Signature, +}; + +/// Create a signing session for a set of messages and participating members. +/// +/// `messages` is a list of `(message_bytes, tweaks)` pairs. Tweaks are +/// applied to the group key before signing (e.g. for BIP-32 derivation). +/// `nonces` must contain one `MemberNonce` per participating member. +/// +/// The session ID is deterministically derived from the group ID, members, +/// and messages, making it safe to compare sessions across participants. +pub fn create_sign_session( + group: &GroupPackage, + members: Vec, + messages: Vec<(Vec, Vec<[u8; 32]>)>, + nonces: Vec, +) -> Result { + if nonces.len() != members.len() { + return Err(Error::Assertion(format!( + "nonce count ({}) must equal member count ({})", + nonces.len(), + members.len() + ))); + } + + let mut sorted_members = members; + sorted_members.sort(); + + let sid = compute_session_id(group, &sorted_members, &messages); + + Ok(SignSession { + sid, + group_pk: group.group_pk, + members: sorted_members, + messages, + nonces, + }) +} + +/// Compute a deterministic session ID: +/// SHA-256(group_id || members[4B each] || messages[len+bytes+tweaks]) +fn compute_session_id( + group: &GroupPackage, + members: &[u32], + messages: &[(Vec, Vec<[u8; 32]>)], +) -> [u8; 32] { + let gid = get_group_id(group); + let mut hasher = Sha256::new(); + hasher.update(gid); + for &m in members { + hasher.update(m.to_be_bytes()); + } + for (msg, tweaks) in messages { + hasher.update((msg.len() as u32).to_be_bytes()); + hasher.update(msg); + for t in tweaks { + hasher.update(t); + } + } + hasher.finalize().into() +} + +/// Produce a partial signature package for all messages in a session. +/// +/// The caller provides the `SecretNoncePair` for their slot in the session +/// (re-derived from the stored code via [`crate::frost::nonce::derive_secret_nonce`]). +pub fn create_partial_sig_package( + session: &SignSession, + share: &SharePackage, + secret_nonce: &SecretNoncePair, +) -> Result { + let low_share = LowSecretShare { + idx: share.idx, + seckey: share.seckey, + }; + let low_snonce = LowSecretNonce { + idx: share.idx, + binder_sn: secret_nonce.binder_sn, + hidden_sn: secret_nonce.hidden_sn, + }; + + let pnonces = build_public_nonces(&session.nonces); + let mut psigs = Vec::with_capacity(session.messages.len()); + + for (message, tweaks) in &session.messages { + let ctx = get_group_signing_ctx(&session.group_pk, &pnonces, message, tweaks)?; + let sig = sign_msg(&ctx, &low_share, &low_snonce)?; + psigs.push(PartialSig { + message: message.clone(), + psig: sig.psig, + }); + } + + let pubkey = crate::helpers::get_pubkey(&share.seckey); + + Ok(PartialSigPackage { + idx: share.idx, + pubkey, + sid: session.sid, + psigs, + }) +} + +/// Verify a partial signature package from one member. +/// +/// Returns `Ok(None)` if valid, or `Ok(Some(reason))` if invalid. +pub fn verify_partial_sig_package( + session: &SignSession, + group: &GroupPackage, + pkg: &PartialSigPackage, +) -> Result, Error> { + if pkg.sid != session.sid { + return Ok(Some("session id mismatch".to_string())); + } + + let member_pubkeys: Vec<[u8; 33]> = group.members.iter().map(|m| m.pubkey).collect(); + if !member_pubkeys.contains(&pkg.pubkey) { + return Ok(Some("pubkey not found in group".to_string())); + } + + let pnonces = build_public_nonces(&session.nonces); + let pnonce = match pnonces.iter().find(|n| n.idx == pkg.idx) { + Some(n) => n, + None => return Ok(Some(format!("no nonce for member {}", pkg.idx))), + }; + + for (i, (message, tweaks)) in session.messages.iter().enumerate() { + let psig_entry = match pkg.psigs.get(i) { + Some(e) => e, + None => return Ok(Some(format!("missing partial sig for message {i}"))), + }; + + let ctx = get_group_signing_ctx(&session.group_pk, &pnonces, message, tweaks)?; + + if !low_verify(&ctx, pnonce, &pkg.pubkey, &psig_entry.psig)? { + return Ok(Some(format!("partial sig invalid for message {i}"))); + } + } + + Ok(None) +} + +/// Combine partial signature packages into final BIP340 signatures. +/// +/// All packages must cover the same session. Returns one `Signature` +/// per message in the session. The combined signatures are verified +/// before being returned. +pub fn combine_signatures( + session: &SignSession, + group: &GroupPackage, + pkgs: &[PartialSigPackage], +) -> Result, Error> { + if pkgs.len() < group.threshold { + return Err(Error::Assertion(format!( + "need at least {} partial sigs, got {}", + group.threshold, + pkgs.len() + ))); + } + + let pnonces = build_public_nonces(&session.nonces); + let mut signatures = Vec::with_capacity(session.messages.len()); + + for (i, (message, tweaks)) in session.messages.iter().enumerate() { + let ctx = get_group_signing_ctx(&session.group_pk, &pnonces, message, tweaks)?; + + let share_sigs: Vec = pkgs + .iter() + .map(|pkg| { + let psig = pkg.psigs.get(i).ok_or_else(|| { + Error::Assertion(format!( + "missing psig at index {i} in pkg from member {}", + pkg.idx + )) + })?; + Ok(ShareSignature { + idx: pkg.idx, + pubkey: pkg.pubkey, + psig: psig.psig, + }) + }) + .collect::>()?; + + let sig = low_combine(&ctx, &share_sigs)?; + + let key_ctx = ctx.key_context(); + if !verify_final_sig(&key_ctx, message, &sig)? { + return Err(Error::Assertion(format!( + "combined signature failed BIP340 verification for message {i}" + ))); + } + + signatures.push(Signature { + message: message.clone(), + pubkey: ctx.group_pk, + sig, + }); + } + + Ok(signatures) +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn build_public_nonces(nonces: &[MemberNonce]) -> Vec { + nonces + .iter() + .map(|n| LowPublicNonce { + idx: n.idx, + binder_pn: n.binder_pn, + hidden_pn: n.hidden_pn, + }) + .collect() +} diff --git a/frost-taproot/src/frost/tests.rs b/frost-taproot/src/frost/tests.rs new file mode 100644 index 0000000..92f8707 --- /dev/null +++ b/frost-taproot/src/frost/tests.rs @@ -0,0 +1,908 @@ +#[cfg(test)] +mod dealer_tests { + use crate::frost::dealer::*; + use crate::frost::types::*; + + fn s32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + const S0: &str = "0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f"; + const S1: &str = "0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"; + + #[test] + fn generate_dealer_package_structure() { + let pkg = generate_dealer_package(2, 3, &[]).unwrap(); + assert_eq!(pkg.group.threshold, 2); + assert_eq!(pkg.group.members.len(), 3); + assert_eq!(pkg.shares.len(), 3); + for (i, share) in pkg.shares.iter().enumerate() { + assert_eq!(share.idx as usize, i + 1); + assert_eq!(pkg.group.members[i].idx, share.idx); + } + } + + #[test] + fn generate_dealer_package_deterministic_with_secrets() { + let secrets = [s32(S0), s32(S1)]; + let a = generate_dealer_package(2, 3, &secrets).unwrap(); + let b = generate_dealer_package(2, 3, &secrets).unwrap(); + assert_eq!(a.group.group_pk, b.group.group_pk); + for (sa, sb) in a.shares.iter().zip(b.shares.iter()) { + assert_eq!(sa.seckey, sb.seckey); + } + } + + #[test] + fn generate_dealer_package_random_without_secrets() { + let a = generate_dealer_package(2, 3, &[]).unwrap(); + let b = generate_dealer_package(2, 3, &[]).unwrap(); + assert_ne!(a.group.group_pk, b.group.group_pk); + } + + #[test] + fn generate_dealer_package_member_pubkeys_match_shares() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + for share in &pkg.shares { + let expected_pk = crate::helpers::get_pubkey(&share.seckey); + let member = pkg + .group + .members + .iter() + .find(|m| m.idx == share.idx) + .unwrap(); + assert_eq!(member.pubkey, expected_pk); + } + } + + #[test] + fn generate_dealer_package_matches_fixture() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + assert_eq!( + hex::encode(pkg.group.group_pk), + "021ae63bc9ddaffe52d44c3018e83115bfb22195bd8112fcad112310714e6fd5ec" + ); + assert_eq!( + hex::encode(pkg.shares[0].seckey), + "0e74411caa9ef5ba058d0ccf4e54e1ee773e625fb7d6258097466cedab0de152" + ); + } + + #[test] + fn get_group_id_is_deterministic() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let id1 = get_group_id(&pkg.group); + let id2 = get_group_id(&pkg.group); + assert_eq!(id1, id2); + } + + #[test] + fn get_group_id_differs_for_different_groups() { + let a = generate_dealer_package(2, 3, &[]).unwrap(); + let b = generate_dealer_package(2, 3, &[]).unwrap(); + assert_ne!(get_group_id(&a.group), get_group_id(&b.group)); + } + + #[test] + fn get_group_id_differs_for_different_thresholds() { + let secrets = [s32(S0), s32(S1)]; + let a = generate_dealer_package(2, 3, &secrets).unwrap(); + // Same secrets but different threshold → different group + let b = generate_dealer_package(3, 3, &secrets).unwrap(); + assert_ne!(get_group_id(&a.group), get_group_id(&b.group)); + } + + #[test] + fn is_group_member_valid_shares() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + for share in &pkg.shares { + assert!(is_group_member(&pkg.group, share)); + } + } + + #[test] + fn is_group_member_wrong_seckey_fails() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let tampered = SharePackage { + idx: pkg.shares[0].idx, + seckey: s32("1111111111111111111111111111111111111111111111111111111111111111"), + }; + assert!(!is_group_member(&pkg.group, &tampered)); + } + + #[test] + fn is_group_member_wrong_idx_fails() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let tampered = SharePackage { + idx: 99, + seckey: pkg.shares[0].seckey, + }; + assert!(!is_group_member(&pkg.group, &tampered)); + } + + #[test] + fn verify_dealer_package_valid() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + assert!(verify_dealer_package(&pkg).unwrap()); + } + + #[test] + fn verify_dealer_package_tampered_share_fails() { + let secrets = [s32(S0), s32(S1)]; + let mut pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + pkg.shares[0].seckey[0] ^= 0xff; + assert!(!verify_dealer_package(&pkg).unwrap()); + } + + #[test] + fn get_member_by_idx_found() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let m = get_member_by_idx(&pkg.group, 2).unwrap(); + assert_eq!(m.idx, 2); + } + + #[test] + fn get_member_by_idx_not_found() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + assert!(get_member_by_idx(&pkg.group, 99).is_none()); + } + + #[test] + fn get_member_by_pubkey_found() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let pk = pkg.group.members[1].pubkey; + let m = get_member_by_pubkey(&pkg.group, &pk).unwrap(); + assert_eq!(m.idx, 2); + } +} + +#[cfg(test)] +mod nonce_tests { + use crate::frost::nonce::*; + use crate::frost::types::*; + + fn s32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + const SECRET: &str = "0e74411caa9ef5ba058d0ccf4e54e1ee773e625fb7d6258097466cedab0de152"; + + #[test] + fn generate_nonce_pair_produces_valid_points() { + let secret = s32(SECRET); + let nonce = generate_nonce_pair(&secret); + assert!(validate_nonce(&nonce)); + } + + #[test] + fn generate_nonce_pair_is_random() { + let secret = s32(SECRET); + let a = generate_nonce_pair(&secret); + let b = generate_nonce_pair(&secret); + // Different codes → different nonces + assert_ne!(a.code, b.code); + assert_ne!(a.binder_pn, b.binder_pn); + assert_ne!(a.hidden_pn, b.hidden_pn); + } + + #[test] + fn generate_nonce_pairs_count() { + let secret = s32(SECRET); + let pairs = generate_nonce_pairs(&secret, 5); + assert_eq!(pairs.len(), 5); + // All codes should be unique + let codes: std::collections::HashSet<_> = pairs.iter().map(|p| p.code).collect(); + assert_eq!(codes.len(), 5); + } + + #[test] + fn derive_secret_nonce_roundtrip() { + let secret = s32(SECRET); + let nonce = generate_nonce_pair(&secret); + let derived = derive_secret_nonce(&secret, &nonce.code); + // Re-derived public nonces must match the originals + let binder_pn = crate::helpers::get_pubkey(&derived.binder_sn); + let hidden_pn = crate::helpers::get_pubkey(&derived.hidden_sn); + assert_eq!(binder_pn, nonce.binder_pn); + assert_eq!(hidden_pn, nonce.hidden_pn); + } + + #[test] + fn derive_secret_nonce_deterministic() { + let secret = s32(SECRET); + let code = [0x42u8; 32]; + let a = derive_secret_nonce(&secret, &code); + let b = derive_secret_nonce(&secret, &code); + assert_eq!(a.binder_sn, b.binder_sn); + assert_eq!(a.hidden_sn, b.hidden_sn); + } + + #[test] + fn derive_secret_nonce_different_codes_differ() { + let secret = s32(SECRET); + let code_a = [0x01u8; 32]; + let code_b = [0x02u8; 32]; + let a = derive_secret_nonce(&secret, &code_a); + let b = derive_secret_nonce(&secret, &code_b); + assert_ne!(a.binder_sn, b.binder_sn); + assert_ne!(a.hidden_sn, b.hidden_sn); + } + + #[test] + fn derive_secret_nonce_different_secrets_differ() { + let secret_a = s32(SECRET); + let secret_b = s32("1c77b7c3c2a14987be430edb4d63bc410e9f3cc59eeb8bbeb89951abdad59595"); + let code = [0x42u8; 32]; + let a = derive_secret_nonce(&secret_a, &code); + let b = derive_secret_nonce(&secret_b, &code); + assert_ne!(a.binder_sn, b.binder_sn); + } + + #[test] + fn verify_nonce_code_valid() { + let secret = s32(SECRET); + let derived = generate_nonce_pair(&secret); + let member_nonce = to_member_nonce(derived, 1); + assert!(verify_nonce_code(&secret, &member_nonce)); + } + + #[test] + fn verify_nonce_code_wrong_secret_fails() { + let secret = s32(SECRET); + let wrong_secret = s32("1c77b7c3c2a14987be430edb4d63bc410e9f3cc59eeb8bbeb89951abdad59595"); + let derived = generate_nonce_pair(&secret); + let member_nonce = to_member_nonce(derived, 1); + assert!(!verify_nonce_code(&wrong_secret, &member_nonce)); + } + + #[test] + fn verify_nonce_code_tampered_code_fails() { + let secret = s32(SECRET); + let derived = generate_nonce_pair(&secret); + let mut member_nonce = to_member_nonce(derived, 1); + member_nonce.code[0] ^= 0xff; + assert!(!verify_nonce_code(&secret, &member_nonce)); + } + + #[test] + fn to_member_nonce_attaches_idx() { + let secret = s32(SECRET); + let derived = generate_nonce_pair(&secret); + let binder_pn = derived.binder_pn; + let hidden_pn = derived.hidden_pn; + let code = derived.code; + let member = to_member_nonce(derived, 42); + assert_eq!(member.idx, 42); + assert_eq!(member.binder_pn, binder_pn); + assert_eq!(member.hidden_pn, hidden_pn); + assert_eq!(member.code, code); + } + + #[test] + fn validate_nonce_valid() { + let secret = s32(SECRET); + let nonce = generate_nonce_pair(&secret); + assert!(validate_nonce(&nonce)); + } + + #[test] + fn validate_nonce_invalid_point_fails() { + let bad = DerivedNonce { + binder_pn: [0u8; 33], + hidden_pn: [0u8; 33], + code: [0u8; 32], + }; + assert!(!validate_nonce(&bad)); + } +} + +#[cfg(test)] +mod signing_tests { + use crate::frost::dealer::generate_dealer_package; + use crate::frost::nonce::{derive_secret_nonce, generate_nonce_pair, to_member_nonce}; + use crate::frost::signing::*; + use crate::frost::types::*; + + fn s32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + const S0: &str = "0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f"; + const S1: &str = "0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"; + + /// Build a complete 2-of-3 signing setup with deterministic inputs. + fn setup_session( + message: &[u8], + tweaks: Vec<[u8; 32]>, + ) -> ( + crate::frost::types::DealerPackage, + SignSession, + Vec, + ) { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + + // Participants 1 and 2 sign + let signing_shares = &pkg.shares[..2]; + + // Each generates a nonce pair + let nonce_pairs: Vec<_> = signing_shares + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + + let member_nonces: Vec = signing_shares + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + + let secret_nonces: Vec = signing_shares + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| derive_secret_nonce(&s.seckey, &n.code)) + .collect(); + + let members: Vec = signing_shares.iter().map(|s| s.idx).collect(); + let session = create_sign_session( + &pkg.group, + members, + vec![(message.to_vec(), tweaks)], + member_nonces, + ) + .unwrap(); + + (pkg, session, secret_nonces) + } + + // ── create_sign_session ─────────────────────────────────────────────────── + + #[test] + fn create_sign_session_sorts_members() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let nonces: Vec = pkg.shares[..2] + .iter() + .map(|s| to_member_nonce(generate_nonce_pair(&s.seckey), s.idx)) + .collect(); + // Pass members in reverse order + let session = create_sign_session( + &pkg.group, + vec![2, 1], + vec![(b"hello".to_vec(), vec![])], + nonces, + ) + .unwrap(); + assert_eq!(session.members, vec![1, 2]); + } + + #[test] + fn create_sign_session_nonce_count_mismatch_errors() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let nonces: Vec = pkg.shares[..1] + .iter() + .map(|s| to_member_nonce(generate_nonce_pair(&s.seckey), s.idx)) + .collect(); + let result = create_sign_session( + &pkg.group, + vec![1, 2], // 2 members but only 1 nonce + vec![(b"hello".to_vec(), vec![])], + nonces, + ); + assert!(result.is_err()); + } + + #[test] + fn create_sign_session_id_is_deterministic() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let msg = b"test message"; + + // Build two sessions with the same nonces + let nonce_pairs: Vec<_> = pkg.shares[..2] + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + let member_nonces: Vec = pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + + let s1 = create_sign_session( + &pkg.group, + vec![1, 2], + vec![(msg.to_vec(), vec![])], + member_nonces.clone(), + ) + .unwrap(); + let s2 = create_sign_session( + &pkg.group, + vec![1, 2], + vec![(msg.to_vec(), vec![])], + member_nonces, + ) + .unwrap(); + assert_eq!(s1.sid, s2.sid); + } + + #[test] + fn create_sign_session_id_differs_for_different_messages() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let nonce_pairs: Vec<_> = pkg.shares[..2] + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + let make_nonces = || { + pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect::>() + }; + let s1 = create_sign_session( + &pkg.group, + vec![1, 2], + vec![(b"msg1".to_vec(), vec![])], + make_nonces(), + ) + .unwrap(); + let s2 = create_sign_session( + &pkg.group, + vec![1, 2], + vec![(b"msg2".to_vec(), vec![])], + make_nonces(), + ) + .unwrap(); + assert_ne!(s1.sid, s2.sid); + } + + // ── create_partial_sig_package ──────────────────────────────────────────── + + #[test] + fn create_partial_sig_package_produces_correct_count() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let psig = create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + assert_eq!(psig.psigs.len(), 1); + assert_eq!(psig.idx, 1); + assert_eq!(psig.sid, session.sid); + } + + #[test] + fn create_partial_sig_package_multi_message() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let nonce_pairs: Vec<_> = pkg.shares[..2] + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + let member_nonces: Vec = pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + let secret_nonces: Vec = pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| derive_secret_nonce(&s.seckey, &n.code)) + .collect(); + + let messages = vec![ + (b"message one".to_vec(), vec![]), + (b"message two".to_vec(), vec![]), + ]; + let session = create_sign_session(&pkg.group, vec![1, 2], messages, member_nonces).unwrap(); + + let psig = create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + assert_eq!(psig.psigs.len(), 2); + } + + // ── verify_partial_sig_package ──────────────────────────────────────────── + + #[test] + fn verify_partial_sig_package_valid() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let psig = create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + let result = verify_partial_sig_package(&session, &pkg.group, &psig).unwrap(); + assert!(result.is_none(), "expected valid, got: {:?}", result); + } + + #[test] + fn verify_partial_sig_package_wrong_sid_fails() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let mut psig = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + psig.sid = [0xff; 32]; + let result = verify_partial_sig_package(&session, &pkg.group, &psig).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn verify_partial_sig_package_wrong_pubkey_fails() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let mut psig = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + psig.pubkey = [0x02; 33]; // not in group + let result = verify_partial_sig_package(&session, &pkg.group, &psig).unwrap(); + assert!(result.is_some()); + } + + #[test] + fn verify_partial_sig_package_tampered_psig_fails() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let mut psig = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + psig.psigs[0].psig[0] ^= 0xff; + let result = verify_partial_sig_package(&session, &pkg.group, &psig).unwrap(); + assert!(result.is_some()); + } + + // ── combine_signatures ──────────────────────────────────────────────────── + + #[test] + fn combine_signatures_produces_valid_bip340() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + + let psig1 = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + let psig2 = + create_partial_sig_package(&session, &pkg.shares[1], &secret_nonces[1]).unwrap(); + + let sigs = combine_signatures(&session, &pkg.group, &[psig1, psig2]).unwrap(); + assert_eq!(sigs.len(), 1); + assert_eq!(sigs[0].message, msg); + assert_eq!(sigs[0].sig.len(), 64); + } + + #[test] + fn combine_signatures_with_tweaks() { + let msg = b"tweaked message"; + let tweak = s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let (pkg, session, secret_nonces) = setup_session(msg, vec![tweak]); + + let psig1 = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + let psig2 = + create_partial_sig_package(&session, &pkg.shares[1], &secret_nonces[1]).unwrap(); + + let sigs = combine_signatures(&session, &pkg.group, &[psig1, psig2]).unwrap(); + assert_eq!(sigs.len(), 1); + // The pubkey in the signature should be the tweaked group key + assert_ne!(sigs[0].pubkey, pkg.group.group_pk); + } + + #[test] + fn combine_signatures_multi_message() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let nonce_pairs: Vec<_> = pkg.shares[..2] + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + let member_nonces: Vec = pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + let secret_nonces: Vec = pkg.shares[..2] + .iter() + .zip(nonce_pairs.iter()) + .map(|(s, n)| derive_secret_nonce(&s.seckey, &n.code)) + .collect(); + + let messages = vec![ + (b"first message".to_vec(), vec![]), + (b"second message".to_vec(), vec![]), + ]; + let session = + create_sign_session(&pkg.group, vec![1, 2], messages.clone(), member_nonces).unwrap(); + + let psig1 = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + let psig2 = + create_partial_sig_package(&session, &pkg.shares[1], &secret_nonces[1]).unwrap(); + + let sigs = combine_signatures(&session, &pkg.group, &[psig1, psig2]).unwrap(); + assert_eq!(sigs.len(), 2); + assert_eq!(sigs[0].message, messages[0].0); + assert_eq!(sigs[1].message, messages[1].0); + } + + #[test] + fn combine_signatures_below_threshold_errors() { + let msg = b"hello world"; + let (pkg, session, secret_nonces) = setup_session(msg, vec![]); + let psig1 = + create_partial_sig_package(&session, &pkg.shares[0], &secret_nonces[0]).unwrap(); + // Only 1 partial sig for a 2-of-3 group + let result = combine_signatures(&session, &pkg.group, &[psig1]); + assert!(result.is_err()); + } + + #[test] + fn full_signing_flow_matches_fixture() { + // Use the same deterministic inputs as the integration test fixture + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + + let hidden_seed = s32("0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f"); + let binder_seed = s32("0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"); + + // Build nonces using the low-level generate_nonce to match fixture + let hidden_sn1 = crate::helpers::generate_nonce(&pkg.shares[0].seckey, Some(&hidden_seed)); + let binder_sn1 = crate::helpers::generate_nonce(&pkg.shares[0].seckey, Some(&binder_seed)); + let hidden_sn2 = crate::helpers::generate_nonce(&pkg.shares[1].seckey, Some(&hidden_seed)); + let binder_sn2 = crate::helpers::generate_nonce(&pkg.shares[1].seckey, Some(&binder_seed)); + + let member_nonces = vec![ + MemberNonce { + idx: 1, + hidden_pn: crate::helpers::get_pubkey(&hidden_sn1), + binder_pn: crate::helpers::get_pubkey(&binder_sn1), + code: [0u8; 32], // code unused in this path + }, + MemberNonce { + idx: 2, + hidden_pn: crate::helpers::get_pubkey(&hidden_sn2), + binder_pn: crate::helpers::get_pubkey(&binder_sn2), + code: [0u8; 32], + }, + ]; + + let tweaks = vec![ + s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + s32("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + ]; + let message = hex::decode("68656c6c6f20776f726c6421").unwrap(); + + let session = create_sign_session( + &pkg.group, + vec![1, 2], + vec![(message.clone(), tweaks)], + member_nonces, + ) + .unwrap(); + + let secret_nonce1 = SecretNoncePair { + code: [0u8; 32], + hidden_sn: hidden_sn1, + binder_sn: binder_sn1, + }; + let secret_nonce2 = SecretNoncePair { + code: [0u8; 32], + hidden_sn: hidden_sn2, + binder_sn: binder_sn2, + }; + + let psig1 = create_partial_sig_package(&session, &pkg.shares[0], &secret_nonce1).unwrap(); + let psig2 = create_partial_sig_package(&session, &pkg.shares[1], &secret_nonce2).unwrap(); + + // Verify each partial sig + assert!(verify_partial_sig_package(&session, &pkg.group, &psig1) + .unwrap() + .is_none()); + assert!(verify_partial_sig_package(&session, &pkg.group, &psig2) + .unwrap() + .is_none()); + + // Combine and verify final signature + let sigs = combine_signatures(&session, &pkg.group, &[psig1, psig2]).unwrap(); + assert_eq!(sigs.len(), 1); + assert_eq!( + hex::encode(sigs[0].sig), + "e76328e49c27c12392a117d39ef9f5def368590d5e72438907fb63c1006fd5891d715fa750b5840610aaf531949f633c4555ac20caf290c3f22cc0771f074447" + ); + } +} + +#[cfg(test)] +mod ecdh_tests { + use crate::frost::dealer::generate_dealer_package; + use crate::frost::ecdh::*; + use crate::frost::types::*; + + fn s32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + fn p33(hex: &str) -> [u8; 33] { + hex::decode(hex).unwrap().try_into().unwrap() + } + + const S0: &str = "0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f"; + const S1: &str = "0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"; + + fn demo_keypair() -> ([u8; 32], [u8; 33]) { + let aux = s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let sk = crate::helpers::generate_seckey(Some(&aux)); + let pk = crate::helpers::get_pubkey(&sk); + (sk, pk) + } + + // ── create_ecdh_pkg ─────────────────────────────────────────────────────── + + #[test] + fn create_ecdh_pkg_structure() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk) = demo_keypair(); + let members = [1u32, 3u32]; + + let ecdh = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[0]).unwrap(); + assert_eq!(ecdh.idx, 1); + assert_eq!(ecdh.members, members); + assert_eq!(ecdh.entries.len(), 1); + assert_eq!(ecdh.entries[0].ecdh_pk, demo_pk); + } + + #[test] + fn create_ecdh_pkg_matches_fixture() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk) = demo_keypair(); + let members = [1u32, 3u32]; + + let ecdh1 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[0]).unwrap(); + let ecdh3 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[2]).unwrap(); + + assert_eq!( + hex::encode(ecdh1.entries[0].keyshare), + "0386c5b0f4bace78ef17d02b09e339b5a39f659dbbf1f3f531b9825df6836cfea9" + ); + assert_eq!( + hex::encode(ecdh3.entries[0].keyshare), + "023edaf055945d35006e1c52dd7a388e0c10b36eb55aa9d117853af87903cb54c0" + ); + } + + // ── create_batched_ecdh_pkg ─────────────────────────────────────────────── + + #[test] + fn create_batched_ecdh_pkg_multiple_keys() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk1) = demo_keypair(); + // Second key: use share[1]'s pubkey as a target + let demo_pk2 = pkg.group.members[1].pubkey; + let members = [1u32, 3u32]; + + let ecdh = + create_batched_ecdh_pkg(&members, &[demo_pk1, demo_pk2], &pkg.shares[0]).unwrap(); + assert_eq!(ecdh.entries.len(), 2); + assert_eq!(ecdh.entries[0].ecdh_pk, demo_pk1); + assert_eq!(ecdh.entries[1].ecdh_pk, demo_pk2); + } + + #[test] + fn create_batched_ecdh_pkg_empty_keys() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let members = [1u32, 3u32]; + let ecdh = create_batched_ecdh_pkg(&members, &[], &pkg.shares[0]).unwrap(); + assert!(ecdh.entries.is_empty()); + } + + // ── combine_ecdh_pkgs ───────────────────────────────────────────────────── + + #[test] + fn combine_ecdh_pkgs_matches_master_secret() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (demo_sk, demo_pk) = demo_keypair(); + let members = [1u32, 3u32]; + + let ecdh1 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[0]).unwrap(); + let ecdh3 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[2]).unwrap(); + + let frost_secret = combine_ecdh_pkgs(&[ecdh1, ecdh3], &demo_pk).unwrap(); + + // master_shared_secret = group_pk * demo_sk = tweak_pubkey(demo_pk, group_secret) + let master_secret = crate::helpers::tweak_pubkey(&demo_pk, &s32(S0)).unwrap(); + assert_eq!(frost_secret, master_secret); + } + + #[test] + fn combine_ecdh_pkgs_matches_fixture() { + let ecdh1 = EcdhPackage { + idx: 1, + members: vec![1, 3], + entries: vec![EcdhEntry { + ecdh_pk: p33("02ebd8227a6d7a03a98a1f86271d0687a6b6570187c37b39d21158a7d7835ba450"), + keyshare: p33("0386c5b0f4bace78ef17d02b09e339b5a39f659dbbf1f3f531b9825df6836cfea9"), + }], + }; + let ecdh3 = EcdhPackage { + idx: 3, + members: vec![1, 3], + entries: vec![EcdhEntry { + ecdh_pk: p33("02ebd8227a6d7a03a98a1f86271d0687a6b6570187c37b39d21158a7d7835ba450"), + keyshare: p33("023edaf055945d35006e1c52dd7a388e0c10b36eb55aa9d117853af87903cb54c0"), + }], + }; + let demo_pk = p33("02ebd8227a6d7a03a98a1f86271d0687a6b6570187c37b39d21158a7d7835ba450"); + let secret = combine_ecdh_pkgs(&[ecdh1, ecdh3], &demo_pk).unwrap(); + assert_eq!( + hex::encode(secret), + "020b6417cef5530ed4b82681945d4565ea7027f423a97b60247d07386ca3619585" + ); + } + + #[test] + fn combine_ecdh_pkgs_missing_entry_errors() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk) = demo_keypair(); + let wrong_pk = pkg.group.members[0].pubkey; // different key + let members = [1u32, 3u32]; + + let ecdh1 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[0]).unwrap(); + let ecdh3 = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[2]).unwrap(); + + // Ask for a key that's not in the packages + let result = combine_ecdh_pkgs(&[ecdh1, ecdh3], &wrong_pk); + assert!(result.is_err()); + } + + // ── combine_batched_ecdh_pkgs ───────────────────────────────────────────── + + #[test] + fn combine_batched_ecdh_pkgs_all_keys() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk1) = demo_keypair(); + let demo_pk2 = pkg.group.members[1].pubkey; + let members = [1u32, 3u32]; + + let batch1 = + create_batched_ecdh_pkg(&members, &[demo_pk1, demo_pk2], &pkg.shares[0]).unwrap(); + let batch3 = + create_batched_ecdh_pkg(&members, &[demo_pk1, demo_pk2], &pkg.shares[2]).unwrap(); + + let results = combine_batched_ecdh_pkgs(&[batch1, batch3]).unwrap(); + assert_eq!(results.len(), 2); + assert_eq!(results[0].0, demo_pk1); + assert_eq!(results[1].0, demo_pk2); + } + + #[test] + fn combine_batched_ecdh_pkgs_empty_input() { + let results = combine_batched_ecdh_pkgs(&[]).unwrap(); + assert!(results.is_empty()); + } + + #[test] + fn combine_batched_matches_single_combine() { + let secrets = [s32(S0), s32(S1)]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let (_, demo_pk) = demo_keypair(); + let members = [1u32, 3u32]; + + let ecdh1_single = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[0]).unwrap(); + let ecdh3_single = create_ecdh_pkg(&members, &demo_pk, &pkg.shares[2]).unwrap(); + let single_secret = combine_ecdh_pkgs(&[ecdh1_single, ecdh3_single], &demo_pk).unwrap(); + + let batch1 = create_batched_ecdh_pkg(&members, &[demo_pk], &pkg.shares[0]).unwrap(); + let batch3 = create_batched_ecdh_pkg(&members, &[demo_pk], &pkg.shares[2]).unwrap(); + let batched = combine_batched_ecdh_pkgs(&[batch1, batch3]).unwrap(); + + assert_eq!(batched[0].1, single_secret); + } +} diff --git a/frost-taproot/src/frost/types.rs b/frost-taproot/src/frost/types.rs new file mode 100644 index 0000000..507a9c2 --- /dev/null +++ b/frost-taproot/src/frost/types.rs @@ -0,0 +1,150 @@ +/// High-level types for the FROST threshold signing protocol. +/// +/// These mirror the bifrost TypeScript types, using raw byte arrays +/// rather than hex strings for efficiency. + +/// A member's secret share of the group key. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SharePackage { + /// Participant index (1-based). + pub idx: u32, + /// 32-byte secret scalar. + pub seckey: [u8; 32], +} + +/// A member's public identity within a group. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MemberPackage { + /// Participant index (1-based). + pub idx: u32, + /// 33-byte compressed public key derived from the secret share. + pub pubkey: [u8; 33], +} + +/// The group's public state: group key + member roster + threshold. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GroupPackage { + /// 33-byte compressed group public key. + pub group_pk: [u8; 33], + /// Minimum number of signers required. + pub threshold: usize, + /// All member public packages. + pub members: Vec, +} + +/// Output of the trusted dealer: group info + all secret shares. +#[derive(Clone, Debug)] +pub struct DealerPackage { + pub group: GroupPackage, + pub shares: Vec, +} + +/// A public nonce commitment (no secret material). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PublicNonce { + /// 33-byte compressed binder nonce point. + pub binder_pn: [u8; 33], + /// 33-byte compressed hidden nonce point. + pub hidden_pn: [u8; 33], +} + +/// A public nonce with a derivation code for secret re-derivation. +/// Store the code; re-derive secrets on demand during signing. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DerivedNonce { + /// 33-byte compressed binder nonce point. + pub binder_pn: [u8; 33], + /// 33-byte compressed hidden nonce point. + pub hidden_pn: [u8; 33], + /// 32-byte random derivation code. + pub code: [u8; 32], +} + +/// A derived nonce tagged with the owning member's index. +/// Used in signing wire format. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MemberNonce { + pub idx: u32, + pub binder_pn: [u8; 33], + pub hidden_pn: [u8; 33], + pub code: [u8; 32], +} + +/// Secret nonce pair re-derived from a code during signing. +#[derive(Clone, Debug)] +pub struct SecretNoncePair { + /// The derivation code this was derived from. + pub code: [u8; 32], + /// 32-byte secret binder nonce scalar. + pub binder_sn: [u8; 32], + /// 32-byte secret hidden nonce scalar. + pub hidden_sn: [u8; 32], +} + +/// A signing session: the set of messages and participating members. +#[derive(Clone, Debug)] +pub struct SignSession { + /// Unique session identifier (SHA-256 of session contents). + pub sid: [u8; 32], + /// 33-byte compressed group public key (after any tweaks are applied). + pub group_pk: [u8; 33], + /// Sorted list of participating member indices. + pub members: Vec, + /// Messages to sign. Each entry is `(message_bytes, tweaks)`. + pub messages: Vec<(Vec, Vec<[u8; 32]>)>, + /// Public nonces from all participating members. + pub nonces: Vec, +} + +/// A partial signature produced by one member for one message. +#[derive(Clone, Debug)] +pub struct PartialSig { + /// The message this partial sig covers. + pub message: Vec, + /// 32-byte partial signature scalar. + pub psig: [u8; 32], +} + +/// A partial signature package from one member covering all session messages. +#[derive(Clone, Debug)] +pub struct PartialSigPackage { + /// The member's index. + pub idx: u32, + /// The member's public key. + pub pubkey: [u8; 33], + /// Session ID this package belongs to. + pub sid: [u8; 32], + /// One partial sig per message in the session. + pub psigs: Vec, +} + +/// A completed signature for one message. +#[derive(Clone, Debug)] +pub struct Signature { + /// The message that was signed. + pub message: Vec, + /// 33-byte compressed group public key. + pub pubkey: [u8; 33], + /// 64-byte BIP340 Schnorr signature. + pub sig: [u8; 64], +} + +/// One member's ECDH keyshare for a single target public key. +#[derive(Clone, Debug)] +pub struct EcdhEntry { + /// The target public key this share is for. + pub ecdh_pk: [u8; 33], + /// 33-byte compressed ECDH keyshare point. + pub keyshare: [u8; 33], +} + +/// An ECDH package from one member, covering one or more target keys. +#[derive(Clone, Debug)] +pub struct EcdhPackage { + /// The member's index. + pub idx: u32, + /// The quorum members used for Lagrange interpolation. + pub members: Vec, + /// One entry per target public key. + pub entries: Vec, +} diff --git a/frost-taproot/src/lib.rs b/frost-taproot/src/lib.rs index 76e831e..faebc80 100644 --- a/frost-taproot/src/lib.rs +++ b/frost-taproot/src/lib.rs @@ -1,4 +1,5 @@ pub mod ecc; +pub mod frost; pub mod types; pub mod util;