//! FROST threshold authorization of **protected** coding operations (SPEC ยง7). //! //! Some operations need more than a single author's signature โ€” a protected- //! branch commit, a membership change, a release tag. Joule Code authorizes //! those with a **`t`-of-`n` FROST threshold signature** (Flexible Round- //! Optimized Schnorr Threshold, over Ed25519) from the cluster members: at least //! `t` members co-sign the operation's `op_id`, and the result is a single //! aggregate signature verifiable against the cluster's one group key. No member //! holds the whole signing key; fewer than `t` cannot authorize. //! //! This crate carries the **verification** side โ€” the load-bearing, ownerless //! part: anyone holding the group verifying key and the aggregate signature can //! check a protected op offline, with no trust in the signers. The signing flow //! (dealer keygen + the two FROST rounds + aggregation) is exercised in the //! tests, and in production is driven by the cluster members. use joule_code_schema::ContentId; pub use frost_ed25519::{Signature, VerifyingKey}; /// A FROST aggregate authorization binding a protected operation's `op_id`. pub struct ProtectedAuthorization { /// The cluster's group verifying key (one key for the whole `t`-of-`n` set). pub group_key: VerifyingKey, /// The aggregate threshold signature over the `op_id`. pub signature: Signature, } impl ProtectedAuthorization { /// Verify the threshold authorization binds exactly this `op_id`. Returns /// `true` iff at least `t` members co-signed (the aggregate verifies under /// the group key). pub fn verify(&self, op_id: &ContentId) -> bool { self.group_key .verify(op_id.as_str().as_bytes(), &self.signature) .is_ok() } } #[cfg(test)] mod tests { use super::*; use frost_ed25519 as frost; use rand::rngs::StdRng; use rand::SeedableRng; use std::collections::BTreeMap; /// Run the FROST signing flow with `signers` of the `t`-of-`n` set over /// `message`, returning the aggregate signature (or an error if too few). fn threshold_sign( message: &[u8], key_packages: &BTreeMap, pubkeys: &frost::keys::PublicKeyPackage, signers: &[frost::Identifier], rng: &mut StdRng, ) -> Result { // Round 1: each signer produces nonces + commitments. let mut nonces = BTreeMap::new(); let mut commitments = BTreeMap::new(); for id in signers { let kp = &key_packages[id]; let (nonce, commitment) = frost::round1::commit(kp.signing_share(), rng); nonces.insert(*id, nonce); commitments.insert(*id, commitment); } let signing_package = frost::SigningPackage::new(commitments, message); // Round 2: each signer produces a signature share. let mut shares = BTreeMap::new(); for id in signers { let share = frost::round2::sign(&signing_package, &nonces[id], &key_packages[id])?; shares.insert(*id, share); } // Aggregate into one group signature. frost::aggregate(&signing_package, &shares, pubkeys) } fn keygen( n: u16, t: u16, rng: &mut StdRng, ) -> ( BTreeMap, frost::keys::PublicKeyPackage, ) { let (shares, pubkeys) = frost::keys::generate_with_dealer(n, t, frost::keys::IdentifierList::Default, rng) .expect("keygen"); let key_packages = shares .into_iter() .map(|(id, share)| (id, frost::keys::KeyPackage::try_from(share).expect("kp"))) .collect(); (key_packages, pubkeys) } #[test] fn threshold_of_members_authorizes_a_protected_op() { let mut rng = StdRng::seed_from_u64(7); // 2-of-3 cluster. let (key_packages, pubkeys) = keygen(3, 2, &mut rng); let op_id = ContentId("b3:protected-branch-commit".into()); let signers: Vec<_> = key_packages.keys().take(2).copied().collect(); let signature = threshold_sign(op_id.as_str().as_bytes(), &key_packages, &pubkeys, &signers, &mut rng) .expect("2 signers authorize"); let auth = ProtectedAuthorization { group_key: *pubkeys.verifying_key(), signature, }; assert!(auth.verify(&op_id)); // And it does NOT verify a different op_id (binds exactly one op). assert!(!auth.verify(&ContentId("b3:some-other-op".into()))); } #[test] fn below_threshold_cannot_authorize() { let mut rng = StdRng::seed_from_u64(11); // 3-of-5 cluster; only 2 signers attempt โ€” aggregation must fail. let (key_packages, pubkeys) = keygen(5, 3, &mut rng); let op_id = ContentId("b3:protected".into()); let signers: Vec<_> = key_packages.keys().take(2).copied().collect(); let result = threshold_sign(op_id.as_str().as_bytes(), &key_packages, &pubkeys, &signers, &mut rng); assert!(result.is_err(), "2 of 3-of-5 must not produce a valid authorization"); } }