Adds the next named federation layer above the BFT consensus core: protected operations (protected-branch commits, membership changes, release tags) are authorized by a t-of-n FROST (Ed25519) threshold signature over the op's op_id, verifiable against the cluster's single group key. No member holds the whole signing key; fewer than t cannot authorize; the aggregate binds exactly one op. joule-code-collab gains a `protected` module: ProtectedAuthorization carries the ownerless verification side (group key + aggregate signature -> verify(op_id)). The dealer keygen + two-round FROST signing + aggregation flow is exercised in tests via the ZF frost-ed25519 crate. Tests: a 2-of-3 threshold authorizes a protected op and binds exactly that op_id (rejects a different one); 2 signers of a 3-of-5 cluster cannot produce a valid authorization. What remains above consensus + threshold-auth: group keying (MLS) and network transport (CommunicationOS/MoQT). cargo test --workspace green; conformance pack still passes with no skips. Co-Authored-By: Claude Opus 4.8 <[email protected]>
128 lines
5.1 KiB
Rust
128 lines
5.1 KiB
Rust
//! 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<frost::Identifier, frost::keys::KeyPackage>,
|
|
pubkeys: &frost::keys::PublicKeyPackage,
|
|
signers: &[frost::Identifier],
|
|
rng: &mut StdRng,
|
|
) -> Result<frost::Signature, frost::Error> {
|
|
// 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::Identifier, frost::keys::KeyPackage>,
|
|
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");
|
|
}
|
|
}
|