Files

466 lines
17 KiB
Rust

/// 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<frost_taproot::frost::types::Signature> {
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<MemberNonce> = 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<MemberNonce> = 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<[u8; 32]>> = 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"
);
}