From fd7ec05b1740737f88dde18a8e7c68831252bf9c Mon Sep 17 00:00:00 2001 From: Jon Staab Date: Thu, 19 Feb 2026 19:00:55 -0800 Subject: [PATCH] Add integration tests to make sure both dkg and dealer flows work --- .fdignore | 1 + frost-taproot/Cargo.toml | 4 + frost-taproot/tests/integration/flows.rs | 465 +++++++++++++++++++++++ 3 files changed, 470 insertions(+) create mode 100644 frost-taproot/tests/integration/flows.rs diff --git a/.fdignore b/.fdignore index eb5a316..2e5c3bc 100644 --- a/.fdignore +++ b/.fdignore @@ -1 +1,2 @@ target +ref diff --git a/frost-taproot/Cargo.toml b/frost-taproot/Cargo.toml index 66efc11..0753577 100644 --- a/frost-taproot/Cargo.toml +++ b/frost-taproot/Cargo.toml @@ -17,3 +17,7 @@ hex = "0.4" [[test]] name = "interop" path = "tests/integration/interop.rs" + +[[test]] +name = "flows" +path = "tests/integration/flows.rs" diff --git a/frost-taproot/tests/integration/flows.rs b/frost-taproot/tests/integration/flows.rs new file mode 100644 index 0000000..2bd248d --- /dev/null +++ b/frost-taproot/tests/integration/flows.rs @@ -0,0 +1,465 @@ +/// End-to-end flow tests for the high-level frost API. +/// +/// These tests exercise complete real-world usage scenarios without pinning +/// to specific byte values — they verify that the pieces compose correctly +/// and that outputs satisfy their cryptographic invariants. +use frost_taproot::{ + frost::{ + dealer::generate_dealer_package, + dkg::{dkg_finalize, dkg_round1, dkg_round2}, + ecdh::{combine_ecdh_pkgs, create_ecdh_pkg}, + nonce::{derive_secret_nonce, generate_nonce_pair, to_member_nonce}, + signing::{ + combine_signatures, create_partial_sig_package, create_sign_session, + verify_partial_sig_package, + }, + types::MemberNonce, + }, + shares::derive_shares_secret, + types::SecretShare, +}; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn s32(hex: &str) -> [u8; 32] { + hex::decode(hex).unwrap().try_into().unwrap() +} + +/// Run a complete signing round for `signers` (indices into `shares`) and +/// return the final signatures. Asserts partial sig verification passes for +/// each signer before combining. +fn sign_message( + group: &frost_taproot::frost::types::GroupPackage, + shares: &[frost_taproot::frost::types::SharePackage], + signer_indices: &[usize], + message: &[u8], + tweaks: Vec<[u8; 32]>, +) -> Vec { + let signers: Vec<_> = signer_indices.iter().map(|&i| &shares[i]).collect(); + + let nonce_pairs: Vec<_> = signers + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + + let member_nonces: Vec = signers + .iter() + .zip(&nonce_pairs) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + + let secret_nonces: Vec<_> = signers + .iter() + .zip(&nonce_pairs) + .map(|(s, n)| derive_secret_nonce(&s.seckey, &n.code)) + .collect(); + + let session = create_sign_session( + group, + signers.iter().map(|s| s.idx).collect(), + vec![(message.to_vec(), tweaks)], + member_nonces, + ) + .unwrap(); + + let psigs: Vec<_> = signers + .iter() + .zip(&secret_nonces) + .map(|(share, snonce)| { + let pkg = create_partial_sig_package(&session, share, snonce).unwrap(); + let err = verify_partial_sig_package(&session, group, &pkg).unwrap(); + assert!(err.is_none(), "partial sig invalid: {:?}", err); + pkg + }) + .collect(); + + combine_signatures(&session, group, &psigs).unwrap() +} + +// ── Dealer flow ─────────────────────────────────────────────────────────────── + +/// Full dealer → sign → ECDH → secret recovery flow. +/// +/// 1. Trusted dealer generates a 2-of-3 group. +/// 2. Every valid threshold subset can sign a message. +/// 3. Tweaked signing produces a different pubkey but still verifies. +/// 4. Threshold ECDH derives the same shared secret as a direct scalar mult. +/// 5. Any threshold subset of shares recovers the group secret. +#[test] +fn dealer_full_flow() { + let secrets = [ + s32("0070ca75929ca1ec4cd70ac34f46079bdfdd87f9d0c0bf4275f3882f7b462d0f"), + s32("0e0376a7180253cdb8b6020bff0eda529760da65e715663e2152e4be2fc7b443"), + ]; + let pkg = generate_dealer_package(2, 3, &secrets).unwrap(); + let group = &pkg.group; + let shares = &pkg.shares; + + assert_eq!(group.threshold, 2); + assert_eq!(shares.len(), 3); + for m in &group.members { + assert_eq!(m.identity_pk, None); + } + + // ── Signing: all three 2-of-3 subsets ──────────────────────────────────── + + let message = b"hello from the dealer flow"; + + for subset in [[0, 1], [0, 2], [1, 2]] { + let sigs = sign_message(group, shares, &subset, message, vec![]); + assert_eq!(sigs.len(), 1); + assert_eq!(sigs[0].message, message); + assert_eq!( + sigs[0].pubkey, group.group_pk, + "untweaked sig pubkey should equal group_pk" + ); + } + + // ── Signing: with tweaks ────────────────────────────────────────────────── + + let tweak = s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let tweaked_sigs = sign_message(group, shares, &[0, 1], message, vec![tweak]); + assert_eq!(tweaked_sigs.len(), 1); + assert_ne!( + tweaked_sigs[0].pubkey, group.group_pk, + "tweaked sig pubkey should differ from group_pk" + ); + + // ── Signing: multiple messages in one session ───────────────────────────── + + let nonce_pairs: Vec<_> = shares[..2] + .iter() + .map(|s| generate_nonce_pair(&s.seckey)) + .collect(); + let member_nonces: Vec = shares[..2] + .iter() + .zip(&nonce_pairs) + .map(|(s, n)| to_member_nonce(n.clone(), s.idx)) + .collect(); + let secret_nonces: Vec<_> = shares[..2] + .iter() + .zip(&nonce_pairs) + .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(group, vec![1, 2], messages.clone(), member_nonces).unwrap(); + let psigs: Vec<_> = shares[..2] + .iter() + .zip(&secret_nonces) + .map(|(s, sn)| create_partial_sig_package(&session, s, sn).unwrap()) + .collect(); + let multi_sigs = combine_signatures(&session, group, &psigs).unwrap(); + assert_eq!(multi_sigs.len(), 2); + assert_eq!(multi_sigs[0].message, messages[0].0); + assert_eq!(multi_sigs[1].message, messages[1].0); + + // ── ECDH ───────────────────────────────────────────────────────────────── + + // Generate an external keypair to derive a shared secret with. + let ext_seckey = s32("1111111111111111111111111111111111111111111111111111111111111111"); + let ext_pubkey = frost_taproot::helpers::get_pubkey(&ext_seckey); + + // Use members 1 and 3 as the quorum. + let ecdh_members = [1u32, 3u32]; + let ecdh1 = create_ecdh_pkg(&ecdh_members, &ext_pubkey, &shares[0]).unwrap(); + let ecdh3 = create_ecdh_pkg(&ecdh_members, &ext_pubkey, &shares[2]).unwrap(); + let frost_shared = combine_ecdh_pkgs(&[ecdh1, ecdh3], &ext_pubkey).unwrap(); + + // The shared secret must equal ext_pubkey * group_secret = group_pk * ext_seckey. + // Verify by computing it the direct way: tweak_pubkey(ext_pubkey, group_secret). + let group_secret = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + let direct_shared = frost_taproot::helpers::tweak_pubkey(&ext_pubkey, &group_secret).unwrap(); + assert_eq!( + frost_shared, direct_shared, + "FROST ECDH shared secret must match direct computation" + ); + + // ── Secret recovery ─────────────────────────────────────────────────────── + + // Any threshold subset recovers the same secret. + let secret_12 = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[1].idx, + seckey: shares[1].seckey, + }, + ]) + .unwrap(); + let secret_13 = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + let secret_23 = derive_shares_secret(&[ + SecretShare { + idx: shares[1].idx, + seckey: shares[1].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + + assert_eq!( + secret_12, secret_13, + "all subsets must recover the same secret" + ); + assert_eq!(secret_13, secret_23); + + // The recovered secret's public key must equal the group public key. + let recovered_pk = frost_taproot::helpers::get_pubkey(&secret_12); + assert_eq!( + recovered_pk, group.group_pk, + "recovered secret * G must equal group_pk" + ); +} + +// ── DKG flow ────────────────────────────────────────────────────────────────── + +/// Full DKG → sign → ECDH → secret recovery flow. +/// +/// 1. Three participants run Pedersen DKG to produce a shared group key +/// without any trusted dealer. +/// 2. All participants agree on the same group public key. +/// 3. Member pubkeys are share pubkeys (usable for partial sig verification). +/// 4. Member identity_pks are the per-participant first VSS commits. +/// 5. Every valid threshold subset can sign a message. +/// 6. Tweaked signing works correctly. +/// 7. Threshold ECDH derives the same shared secret as a direct scalar mult. +/// 8. Any threshold subset of aggregate shares recovers the group secret. +#[test] +fn dkg_full_flow() { + // Three participants, threshold 2, fully deterministic seeds. + let participant_seeds: Vec> = vec![ + vec![ + s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"), + s32("1111111111111111111111111111111111111111111111111111111111111111"), + ], + vec![ + s32("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + s32("2222222222222222222222222222222222222222222222222222222222222222"), + ], + vec![ + s32("cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"), + s32("3333333333333333333333333333333333333333333333333333333333333333"), + ], + ]; + + // ── Round 1 ─────────────────────────────────────────────────────────────── + + let round1: Vec<_> = (1u32..=3) + .zip(&participant_seeds) + .map(|(idx, seeds)| dkg_round1(idx, 2, seeds)) + .collect(); + + let all_commits: Vec<_> = round1.iter().map(|(_, c)| c.clone()).collect(); + + // ── Round 2 ─────────────────────────────────────────────────────────────── + + let all_shares: Vec<_> = round1 + .iter() + .flat_map(|(coeffs, commit)| { + (1u32..=3).map(move |recipient| dkg_round2(commit.idx, coeffs, recipient).unwrap()) + }) + .collect(); + + // ── Finalize ────────────────────────────────────────────────────────────── + + let outputs: Vec<_> = (0..3) + .map(|i| { + let my_idx = (i + 1) as u32; + let (my_coeffs, _) = &round1[i]; + let received: Vec<_> = all_shares + .iter() + .filter(|s| s.recipient_idx == my_idx && s.sender_idx != my_idx) + .cloned() + .collect(); + dkg_finalize(my_idx, my_coeffs, &received, &all_commits, 2).unwrap() + }) + .collect(); + + // All participants must agree on the same group public key. + let group_pk = outputs[0].group.group_pk; + for output in &outputs { + assert_eq!( + output.group.group_pk, group_pk, + "all participants must agree on group_pk" + ); + assert_eq!(output.group.threshold, 2); + } + + // Member pubkeys must be share pubkeys (seckey * G), not identity keys. + for output in &outputs { + let expected_pk = frost_taproot::helpers::get_pubkey(&output.share.seckey); + let member = output + .group + .members + .iter() + .find(|m| m.idx == output.share.idx) + .unwrap(); + assert_eq!( + member.pubkey, expected_pk, + "member.pubkey must equal share pubkey for participant {}", + output.share.idx + ); + } + + // identity_pk must be set and equal to each participant's first VSS commit. + for (i, output) in outputs.iter().enumerate() { + let (_, commit) = &round1[i]; + let member = output + .group + .members + .iter() + .find(|m| m.idx == commit.idx) + .unwrap(); + assert_eq!( + member.identity_pk, + Some(commit.vss_commits[0]), + "identity_pk must equal first VSS commit for participant {}", + commit.idx + ); + } + + // ── Signing: all three 2-of-3 subsets ──────────────────────────────────── + + let message = b"hello from the dkg flow"; + let group = &outputs[0].group; + let shares: Vec<_> = outputs.iter().map(|o| o.share.clone()).collect(); + + for subset in [[0usize, 1], [0, 2], [1, 2]] { + let sigs = sign_message(group, &shares, &subset, message, vec![]); + assert_eq!(sigs.len(), 1); + assert_eq!(sigs[0].message, message); + assert_eq!( + sigs[0].pubkey, group_pk, + "untweaked sig pubkey should equal group_pk" + ); + } + + // ── Signing: with tweaks ────────────────────────────────────────────────── + + let tweak = s32("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + let tweaked_sigs = sign_message(group, &shares, &[0, 1], message, vec![tweak]); + assert_eq!(tweaked_sigs.len(), 1); + assert_ne!( + tweaked_sigs[0].pubkey, group_pk, + "tweaked sig pubkey should differ from group_pk" + ); + + // ── ECDH ───────────────────────────────────────────────────────────────── + + let ext_seckey = s32("1111111111111111111111111111111111111111111111111111111111111111"); + let ext_pubkey = frost_taproot::helpers::get_pubkey(&ext_seckey); + + let ecdh_members = [1u32, 3u32]; + let ecdh1 = create_ecdh_pkg(&ecdh_members, &ext_pubkey, &shares[0]).unwrap(); + let ecdh3 = create_ecdh_pkg(&ecdh_members, &ext_pubkey, &shares[2]).unwrap(); + let frost_shared = combine_ecdh_pkgs(&[ecdh1, ecdh3], &ext_pubkey).unwrap(); + + // Recover the group secret to verify ECDH directly. + let group_secret = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + let direct_shared = frost_taproot::helpers::tweak_pubkey(&ext_pubkey, &group_secret).unwrap(); + assert_eq!( + frost_shared, direct_shared, + "FROST ECDH shared secret must match direct computation" + ); + + // ── Secret recovery ─────────────────────────────────────────────────────── + + let secret_12 = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[1].idx, + seckey: shares[1].seckey, + }, + ]) + .unwrap(); + let secret_13 = derive_shares_secret(&[ + SecretShare { + idx: shares[0].idx, + seckey: shares[0].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + let secret_23 = derive_shares_secret(&[ + SecretShare { + idx: shares[1].idx, + seckey: shares[1].seckey, + }, + SecretShare { + idx: shares[2].idx, + seckey: shares[2].seckey, + }, + ]) + .unwrap(); + + assert_eq!( + secret_12, secret_13, + "all subsets must recover the same secret" + ); + assert_eq!(secret_13, secret_23); + + // The recovered secret's public key must equal the group public key. + let recovered_pk = frost_taproot::helpers::get_pubkey(&secret_12); + assert_eq!( + recovered_pk, group_pk, + "recovered secret * G must equal group_pk" + ); + + // The DKG group secret is the sum of all participants' constant terms. + // Verify: secret = s1_const + s2_const + s3_const (mod N). + use frost_taproot::ecc::util::{scalar_from_bytes, scalar_to_bytes}; + let sum = participant_seeds + .iter() + .fold(k256::Scalar::ZERO, |acc, seeds| { + acc + scalar_from_bytes(&seeds[0]) + }); + assert_eq!( + secret_12, + scalar_to_bytes(&sum), + "DKG secret must equal sum of participants' constant terms" + ); +}