Skip to main content

exo_root/
signing.rs

1//! Threshold root signing helpers.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use exo_core::Hash256;
6use frost_ristretto255 as frost;
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    GenesisCeremonyConfig, Result, RootError, RootKeyPackage, RootPublicKeyPackage,
11    dkg::{deserialize_frost, serialize_frost},
12};
13
14/// Serialized threshold signature over a root artifact.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct RootSignature {
17    /// Serialized FROST signature.
18    pub signature: Vec<u8>,
19    /// Signer identifiers used for this signature.
20    pub signer_ids: Vec<u16>,
21}
22
23fn frost_error(error: frost::Error) -> RootError {
24    RootError::Frost {
25        detail: error.to_string(),
26    }
27}
28
29fn frost_sign_share(
30    signing_package: &frost::SigningPackage,
31    nonces: &frost::round1::SigningNonces,
32    key_package: &frost::keys::KeyPackage,
33) -> Result<frost::round2::SignatureShare> {
34    frost::round2::sign(signing_package, nonces, key_package).map_err(frost_error)
35}
36
37fn frost_aggregate_signature(
38    signing_package: &frost::SigningPackage,
39    signature_shares: &BTreeMap<frost::Identifier, frost::round2::SignatureShare>,
40    public: &frost::keys::PublicKeyPackage,
41) -> Result<frost::Signature> {
42    frost::aggregate(signing_package, signature_shares, public).map_err(frost_error)
43}
44
45/// Create a FROST threshold signature from the exact predeclared signing set.
46pub fn threshold_sign<R>(
47    config: &GenesisCeremonyConfig,
48    public_key_package: &RootPublicKeyPackage,
49    shares: BTreeMap<u16, RootKeyPackage>,
50    message: &[u8],
51    rng: &mut R,
52) -> Result<RootSignature>
53where
54    R: frost::rand_core::RngCore + frost::rand_core::CryptoRng,
55{
56    config.validate()?;
57    if shares.len() < usize::from(config.threshold) {
58        let supplied = shares
59            .keys()
60            .take(usize::from(u16::MAX))
61            .fold(0u16, |count, _| count.saturating_add(1));
62        let error = RootError::ThresholdNotMet {
63            required: config.threshold,
64            supplied,
65        };
66        return Err(error);
67    }
68    let signer_ids: Vec<u16> = shares.keys().copied().collect();
69    validate_root_signer_ids(config, signer_ids.as_slice())?;
70
71    let public = deserialize_frost(public_key_package.public_key_package.as_slice())?;
72
73    let mut key_packages = BTreeMap::new();
74    let mut signing_nonces = BTreeMap::new();
75    let mut signing_commitments = BTreeMap::new();
76
77    for (identifier, share) in shares {
78        if share.frost_identifier != identifier {
79            let share_id = share.frost_identifier;
80            let detail = format!("share id {share_id} mismatches key {identifier}");
81            return Err(RootError::Frost { detail });
82        }
83        let frost_identifier = crate::dkg::frost_identifier(identifier)?;
84        let key_package: frost::keys::KeyPackage = deserialize_frost(share.key_package.as_slice())?;
85        if *key_package.identifier() != frost_identifier {
86            return Err(RootError::Frost {
87                detail: "deserialized key package identifier mismatch".to_owned(),
88            });
89        }
90        let (nonces, commitments) = frost::round1::commit(key_package.signing_share(), rng);
91        signing_nonces.insert(frost_identifier, nonces);
92        signing_commitments.insert(frost_identifier, commitments);
93        key_packages.insert(frost_identifier, key_package);
94    }
95
96    let signing_package = frost::SigningPackage::new(signing_commitments, message);
97    let mut signature_shares = BTreeMap::new();
98    for (identifier, key_package) in &key_packages {
99        let nonces = &signing_nonces[identifier];
100        let share = frost_sign_share(&signing_package, nonces, key_package)?;
101        signature_shares.insert(*identifier, share);
102    }
103
104    let sig = frost_aggregate_signature(&signing_package, &signature_shares, &public)?;
105    let signature = serialize_frost(&sig)?;
106
107    verify_root_signature(&public_key_package.root_public_key, message, &signature)?;
108
109    Ok(RootSignature {
110        signature,
111        signer_ids,
112    })
113}
114
115/// Verify a serialized root threshold signature against a root public key.
116pub fn verify_root_signature(
117    root_public_key: &[u8],
118    message: &[u8],
119    signature: &[u8],
120) -> Result<()> {
121    let verifying_key: frost::VerifyingKey = deserialize_frost(root_public_key)?;
122    let signature: frost::Signature = deserialize_frost(signature)?;
123    verifying_key
124        .verify(message, &signature)
125        .map_err(signature_rejected)
126}
127
128fn signature_rejected(error: frost::Error) -> RootError {
129    RootError::SignatureRejected {
130        reason: error.to_string(),
131    }
132}
133
134// ---------------------------------------------------------------------------
135// Distributed (online) threshold signing.
136//
137// `threshold_sign` above performs the whole protocol in one process and so
138// requires every key package in one place. The functions below run the same
139// FROST protocol as a two-round distributed exchange: each signer keeps its
140// own `RootKeyPackage` and only ever emits PUBLIC commitments and signature
141// shares. A coordinator (holding no secrets) assembles the signing package and
142// aggregates the shares. These map onto the portal's `RootSigningCommitment`
143// and `RootSignatureShare` payload kinds.
144// ---------------------------------------------------------------------------
145
146/// One signer's round-one PUBLIC commitment. Relay-safe: carries no secret
147/// material and is the only round-one artifact broadcast to the coordinator.
148/// Kept deliberately separate from [`RootSigningNonces`] so the secret nonces
149/// can never be co-serialized with, or mistaken for, relay-safe data.
150#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
151pub struct RootSigningCommitment {
152    /// Owner's FROST identifier.
153    pub frost_identifier: u16,
154    /// Serialized public signing commitments (broadcast to the coordinator).
155    pub commitments: Vec<u8>,
156}
157
158/// One signer's round-one SECRET signing nonces. **LOCAL-ONLY** — this artifact
159/// must never be broadcast, archived off the signer, copied to the coordinator,
160/// or submitted through the portal. In FROST, disclosure of these nonces
161/// together with the signer's later signature share can compromise the signer's
162/// secret key share. It derives `Serialize`/`Deserialize` only so a signer can
163/// persist it to a `0600` local file between `sign_commit` and `sign_share`; the
164/// distinct type name keeps it from being confused with relay-safe data.
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct RootSigningNonces {
167    /// Owner's FROST identifier.
168    pub frost_identifier: u16,
169    /// Ceremony these nonces were generated for; `sign_share` rejects reuse in a
170    /// different ceremony.
171    pub ceremony_id: String,
172    /// blake3 of the exact root artifact (message) these nonces commit to.
173    /// `sign_share` requires the message it signs to hash to this value, so a
174    /// given nonce set can only ever sign its one bound artifact — this is what
175    /// prevents a coordinator from getting the same nonces to sign two different
176    /// messages (FROST nonce reuse exposes the signer's key share).
177    pub artifact_hash: Hash256,
178    /// blake3 of the public commitment these nonces pair with. `sign_share`
179    /// requires this to equal the hash of the commitment bound for this signer in
180    /// the signing package.
181    pub commitment_hash: Hash256,
182    /// Serialized secret signing nonces (retained by the signer; never shared).
183    pub nonces: Vec<u8>,
184}
185
186/// blake3 of a signer's serialized public commitment, used to bind nonces to the
187/// signing instance.
188fn commitment_hash(commitment_bytes: &[u8]) -> Hash256 {
189    Hash256::digest(commitment_bytes)
190}
191
192/// Public signing package built by the coordinator from `>= threshold`
193/// commitments. Distributed to the participating signers for round two.
194#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
195pub struct RootSigningPackage {
196    /// Serialized FROST signing package (binds the commitments and message).
197    pub signing_package: Vec<u8>,
198    /// Identifiers whose commitments are bound into this package.
199    pub signer_ids: Vec<u16>,
200}
201
202/// One signer's round-two signature share. Public; reveals nothing about the
203/// signer's secret key share.
204#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
205pub struct RootSignatureShareOutput {
206    /// Owner's FROST identifier.
207    pub frost_identifier: u16,
208    /// Serialized FROST signature share.
209    pub signature_share: Vec<u8>,
210}
211
212fn ensure_rostered(config: &GenesisCeremonyConfig, identifier: u16, role: &str) -> Result<()> {
213    if config.certifier_by_identifier(identifier).is_none() {
214        return Err(RootError::InvalidConfig {
215            reason: format!("{role} {identifier} is not rostered"),
216        });
217    }
218    Ok(())
219}
220
221pub(crate) fn validate_root_signer_ids(
222    config: &GenesisCeremonyConfig,
223    signer_ids: &[u16],
224) -> Result<()> {
225    if signer_ids.len() != usize::from(config.threshold) {
226        return Err(RootError::InvalidConfig {
227            reason: format!(
228                "signing selection must contain exactly {} signers",
229                config.threshold
230            ),
231        });
232    }
233    let mut selection = BTreeSet::new();
234    for identifier in signer_ids {
235        if config.certifier_by_identifier(*identifier).is_none() {
236            return Err(RootError::InvalidConfig {
237                reason: format!("signer {identifier} is not rostered"),
238            });
239        }
240        if !selection.insert(*identifier) {
241            return Err(RootError::InvalidConfig {
242                reason: format!("duplicate signer {identifier}"),
243            });
244        }
245    }
246    config.validate_signing_selection(&selection)
247}
248
249/// Distributed signing — round one. Produce one signer's PUBLIC commitment and
250/// SECRET nonces as two distinct artifacts, **bound to the exact root `artifact`
251/// being signed**. Run by each participating certifier against its own share.
252/// The caller MUST broadcast only the [`RootSigningCommitment`] and retain the
253/// [`RootSigningNonces`] locally (never share, archive off-host, or submit it)
254/// until [`sign_share`]. The artifact must be the bytes emitted by
255/// `root_artifact_payload` and is known before commitments are produced.
256pub fn sign_commit<R>(
257    config: &GenesisCeremonyConfig,
258    key_package: &RootKeyPackage,
259    artifact: &[u8],
260    rng: &mut R,
261) -> Result<(RootSigningCommitment, RootSigningNonces)>
262where
263    R: frost::rand_core::RngCore + frost::rand_core::CryptoRng,
264{
265    config.validate()?;
266    ensure_rostered(config, key_package.frost_identifier, "signer")?;
267    let parsed: frost::keys::KeyPackage = deserialize_frost(key_package.key_package.as_slice())?;
268    let (nonces, commitments) = frost::round1::commit(parsed.signing_share(), rng);
269    let commitment_bytes = serialize_frost(&commitments)?;
270    let commitment = RootSigningCommitment {
271        frost_identifier: key_package.frost_identifier,
272        commitments: commitment_bytes.clone(),
273    };
274    let signing_nonces = RootSigningNonces {
275        frost_identifier: key_package.frost_identifier,
276        ceremony_id: config.ceremony_id.clone(),
277        artifact_hash: Hash256::digest(artifact),
278        commitment_hash: commitment_hash(commitment_bytes.as_slice()),
279        nonces: serialize_frost(&nonces)?,
280    };
281    Ok((commitment, signing_nonces))
282}
283
284/// Distributed signing — coordinator assembles the signing package from at
285/// the exact predeclared public commitments bound to `message` (the root artifact).
286pub fn build_signing_package(
287    config: &GenesisCeremonyConfig,
288    commitments: BTreeMap<u16, Vec<u8>>,
289    message: &[u8],
290) -> Result<RootSigningPackage> {
291    config.validate()?;
292    if commitments.len() < usize::from(config.threshold) {
293        return Err(RootError::ThresholdNotMet {
294            required: config.threshold,
295            supplied: u16::try_from(commitments.len()).unwrap_or(u16::MAX),
296        });
297    }
298    // The participating signers must be exactly the predeclared signing set. Any
299    // unavailability aborts the ceremony before commitments and requires a new
300    // config/ceremony id.
301    let signer_ids: Vec<u16> = commitments.keys().copied().collect();
302    validate_root_signer_ids(config, signer_ids.as_slice())?;
303    let mut parsed = BTreeMap::new();
304    for (identifier, bytes) in commitments {
305        ensure_rostered(config, identifier, "signer")?;
306        let frost_id = crate::dkg::frost_identifier(identifier)?;
307        let commitment: frost::round1::SigningCommitments = deserialize_frost(bytes.as_slice())?;
308        parsed.insert(frost_id, commitment);
309    }
310    let signing_package = frost::SigningPackage::new(parsed, message);
311    Ok(RootSigningPackage {
312        signing_package: serialize_frost(&signing_package)?,
313        signer_ids,
314    })
315}
316
317/// Distributed signing — round two. One signer produces its signature share from
318/// its key package, its retained local-only [`RootSigningNonces`], the
319/// coordinator's [`RootSigningPackage`], and the `message` (root artifact) it
320/// intends to sign.
321///
322/// The share is produced over a signing package **rebuilt from the supplied
323/// commitments and the caller's `message`**, never over a coordinator-controlled
324/// message. Combined with the nonce's `artifact_hash` binding, this means a given
325/// nonce set can only ever sign the one artifact it committed to: a coordinator
326/// cannot present two packages built from the same commitments but different
327/// messages to obtain two shares under one nonce (which would expose the signer's
328/// key share). The caller must additionally retire the nonces after one share.
329pub fn sign_share(
330    config: &GenesisCeremonyConfig,
331    key_package: &RootKeyPackage,
332    nonces: &RootSigningNonces,
333    signing_package: &RootSigningPackage,
334    message: &[u8],
335) -> Result<RootSignatureShareOutput> {
336    config.validate()?;
337    ensure_rostered(config, key_package.frost_identifier, "signer")?;
338    if nonces.frost_identifier != key_package.frost_identifier {
339        let nonce_id = nonces.frost_identifier;
340        let key_id = key_package.frost_identifier;
341        return Err(RootError::Frost {
342            detail: format!("nonces id {nonce_id} mismatches key {key_id}"),
343        });
344    }
345    if nonces.ceremony_id != config.ceremony_id {
346        return Err(RootError::Frost {
347            detail: "nonces were generated for a different ceremony".to_owned(),
348        });
349    }
350    // Bind the nonces to the exact artifact being signed. A nonce committed to one
351    // artifact can never be used to sign a different message.
352    if Hash256::digest(message) != nonces.artifact_hash {
353        return Err(RootError::Frost {
354            detail: "nonces are bound to a different artifact than the message".to_owned(),
355        });
356    }
357    // Enforce the ratified deterministic signer-selection policy signer-side too:
358    // a signer must refuse a signing package whose signer set is not exactly the
359    // predeclared set, even when a coordinator builds the package outside
360    // `build_signing_package`.
361    validate_root_signer_ids(config, signing_package.signer_ids.as_slice())?;
362    let parsed_key: frost::keys::KeyPackage =
363        deserialize_frost(key_package.key_package.as_slice())?;
364    let parsed_nonces: frost::round1::SigningNonces = deserialize_frost(nonces.nonces.as_slice())?;
365    let parsed_package: frost::SigningPackage =
366        deserialize_frost(signing_package.signing_package.as_slice())?;
367    // Rebuild the signing package from the supplied commitments and the caller's
368    // message, so the share is provably over `message` regardless of what message
369    // the coordinator embedded in the distributed package.
370    let mut commitments = BTreeMap::new();
371    for identifier in &signing_package.signer_ids {
372        let signer_frost_id = crate::dkg::frost_identifier(*identifier)?;
373        let commitment = parsed_package
374            .signing_commitment(&signer_frost_id)
375            .ok_or_else(|| RootError::Frost {
376                detail: format!("signing package is missing commitment for signer {identifier}"),
377            })?;
378        commitments.insert(signer_frost_id, commitment);
379    }
380    // The nonces must pair with this signer's commitment in the bound set.
381    let frost_id = crate::dkg::frost_identifier(key_package.frost_identifier)?;
382    let package_commitment = commitments.get(&frost_id).ok_or_else(|| RootError::Frost {
383        detail: "signer is not in the signing package's signer set".to_owned(),
384    })?;
385    if commitment_hash(serialize_frost(package_commitment)?.as_slice()) != nonces.commitment_hash {
386        return Err(RootError::Frost {
387            detail: "nonces are not bound to this signing package's commitment".to_owned(),
388        });
389    }
390    let rebuilt_package = frost::SigningPackage::new(commitments, message);
391    let share =
392        frost::round2::sign(&rebuilt_package, &parsed_nonces, &parsed_key).map_err(frost_error)?;
393    Ok(RootSignatureShareOutput {
394        frost_identifier: key_package.frost_identifier,
395        signature_share: serialize_frost(&share)?,
396    })
397}
398
399/// Distributed signing — coordinator aggregates the exact predeclared signature
400/// shares into the final root signature and verifies it against the root public key.
401pub fn aggregate_signature(
402    config: &GenesisCeremonyConfig,
403    public_key_package: &RootPublicKeyPackage,
404    signing_package: &[u8],
405    shares: BTreeMap<u16, Vec<u8>>,
406    message: &[u8],
407) -> Result<RootSignature> {
408    config.validate()?;
409    if shares.len() < usize::from(config.threshold) {
410        return Err(RootError::ThresholdNotMet {
411            required: config.threshold,
412            supplied: u16::try_from(shares.len()).unwrap_or(u16::MAX),
413        });
414    }
415    let signer_ids: Vec<u16> = shares.keys().copied().collect();
416    validate_root_signer_ids(config, signer_ids.as_slice())?;
417    let public = deserialize_frost(public_key_package.public_key_package.as_slice())?;
418    let parsed_package: frost::SigningPackage = deserialize_frost(signing_package)?;
419    let mut parsed_shares = BTreeMap::new();
420    for (identifier, bytes) in shares {
421        ensure_rostered(config, identifier, "signer")?;
422        let frost_id = crate::dkg::frost_identifier(identifier)?;
423        let share: frost::round2::SignatureShare = deserialize_frost(bytes.as_slice())?;
424        parsed_shares.insert(frost_id, share);
425    }
426    let aggregated =
427        frost::aggregate(&parsed_package, &parsed_shares, &public).map_err(frost_error)?;
428    let signature = serialize_frost(&aggregated)?;
429    verify_root_signature(&public_key_package.root_public_key, message, &signature)?;
430    Ok(RootSignature {
431        signature,
432        signer_ids,
433    })
434}
435
436#[cfg(test)]
437mod tests {
438    use exo_core::{Did, Hash256, PublicKey, Timestamp};
439    use rand::{SeedableRng, rngs::StdRng};
440
441    use super::*;
442    use crate::CertifierContact;
443
444    fn test_config() -> GenesisCeremonyConfig {
445        let certifiers = (1..=13)
446            .map(|identifier| {
447                let byte = u8::try_from(identifier).expect("identifier fits");
448                CertifierContact {
449                    did: Did::new(&format!("did:exo:signing-unit-{identifier:02}"))
450                        .expect("valid did"),
451                    frost_identifier: identifier,
452                    signing_public_key: PublicKey::from_bytes([byte; 32]),
453                    transport_public_key: [byte; 32],
454                }
455            })
456            .collect();
457        GenesisCeremonyConfig {
458            ceremony_id: "unit-root".into(),
459            network_id: "unit-net".into(),
460            repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".into(),
461            constitution_hash: Hash256::digest(b"constitution"),
462            threshold: 7,
463            max_signers: 13,
464            created_at: Timestamp::new(1, 0),
465            certifiers,
466            signing_set: (1..=7).collect(),
467        }
468    }
469
470    #[test]
471    fn frost_error_conversion_is_diagnostic() {
472        let error = frost::Identifier::try_from(0).expect_err("zero identifier");
473        let converted = frost_error(error);
474        assert!(converted.to_string().contains("frost operation failed"));
475    }
476
477    #[test]
478    fn signature_rejection_conversion_is_diagnostic() {
479        let error = frost::Identifier::try_from(0).expect_err("zero identifier");
480        let converted = signature_rejected(error);
481        assert!(
482            converted
483                .to_string()
484                .contains("signature verification failed")
485        );
486    }
487
488    #[test]
489    fn threshold_sign_rejects_too_few_shares_before_public_deserialization() {
490        let config = test_config();
491        let public_key_package = RootPublicKeyPackage {
492            public_key_package: b"not a public package".to_vec(),
493            root_public_key: b"not a verifying key".to_vec(),
494            verifying_shares: BTreeMap::new(),
495        };
496        let mut rng = StdRng::seed_from_u64(7);
497        let error = threshold_sign(
498            &config,
499            &public_key_package,
500            BTreeMap::new(),
501            b"root artifact",
502            &mut rng,
503        )
504        .expect_err("empty signer set");
505        assert_eq!(
506            error,
507            RootError::ThresholdNotMet {
508                required: 7,
509                supplied: 0
510            }
511        );
512    }
513
514    #[test]
515    fn signer_id_validation_rejects_wrong_count_and_duplicates() {
516        let config = test_config();
517        let wrong_count = validate_root_signer_ids(&config, &[1, 2, 3, 4, 5, 6])
518            .expect_err("signer set must match threshold count");
519        assert!(wrong_count.to_string().contains("exactly 7 signers"));
520
521        let duplicate = validate_root_signer_ids(&config, &[1, 2, 3, 4, 5, 6, 6])
522            .expect_err("signer set must reject duplicates");
523        assert!(duplicate.to_string().contains("duplicate signer 6"));
524    }
525
526    #[test]
527    fn threshold_sign_covers_success_and_share_mismatch_paths() {
528        let config = test_config();
529        let mut rng = StdRng::seed_from_u64(71);
530        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
531        let selected: BTreeMap<u16, _> = dkg
532            .key_packages
533            .iter()
534            .take(7)
535            .map(|(identifier, share)| (*identifier, share.clone()))
536            .collect();
537        let message = b"unit root signing artifact";
538        let signature = threshold_sign(
539            &config,
540            &dkg.public_key_package,
541            selected.clone(),
542            message,
543            &mut rng,
544        )
545        .expect("signature");
546
547        verify_root_signature(
548            &dkg.public_key_package.root_public_key,
549            message,
550            &signature.signature,
551        )
552        .expect("signature verifies");
553
554        let mut mismatched = selected;
555        let mut share = mismatched.remove(&1).expect("share one");
556        share.frost_identifier = 2;
557        mismatched.insert(1, share);
558        let error = threshold_sign(
559            &config,
560            &dkg.public_key_package,
561            mismatched,
562            message,
563            &mut rng,
564        )
565        .expect_err("share identifier mismatch");
566        assert!(error.to_string().contains("mismatches key 1"));
567    }
568
569    #[test]
570    fn threshold_sign_rejects_non_declared_signer_set_before_public_deserialization() {
571        let config = test_config();
572        let public_key_package = RootPublicKeyPackage {
573            public_key_package: b"not a public package".to_vec(),
574            root_public_key: b"not a verifying key".to_vec(),
575            verifying_shares: BTreeMap::new(),
576        };
577        let shares = [1, 2, 3, 4, 5, 6, 9]
578            .into_iter()
579            .map(|identifier| {
580                (
581                    identifier,
582                    RootKeyPackage {
583                        frost_identifier: identifier,
584                        key_package: Vec::new(),
585                    },
586                )
587            })
588            .collect();
589        let mut rng = StdRng::seed_from_u64(72);
590        let error = threshold_sign(&config, &public_key_package, shares, b"artifact", &mut rng)
591            .expect_err("non-declared signer set");
592        assert!(
593            error.to_string().contains("predeclared signing_set"),
594            "expected signer-set rejection before public package decoding, got: {error}"
595        );
596    }
597
598    #[test]
599    fn distributed_signing_matches_one_shot_and_verifies() {
600        let config = test_config();
601        let mut rng = StdRng::seed_from_u64(99);
602        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
603        let message = b"distributed root signing artifact";
604
605        // Seven signers, each keeping its own key package.
606        let signers: Vec<(u16, _)> = dkg
607            .key_packages
608            .iter()
609            .take(7)
610            .map(|(id, kp)| (*id, kp.clone()))
611            .collect();
612
613        // Round one: each signer commits locally; only commitments are shared.
614        let mut commitments = BTreeMap::new();
615        let mut nonces = BTreeMap::new();
616        for (id, kp) in &signers {
617            let (commitment, signer_nonces) =
618                sign_commit(&config, kp, message, &mut rng).expect("commit");
619            nonces.insert(*id, signer_nonces);
620            commitments.insert(*id, commitment.commitments);
621        }
622
623        // Coordinator builds the signing package from the commitments.
624        let package = build_signing_package(&config, commitments, message).expect("package");
625        assert_eq!(package.signer_ids.len(), 7);
626
627        // Round two: each signer produces its share from its own nonces, signing
628        // the artifact its nonces are bound to.
629        let mut shares = BTreeMap::new();
630        for (id, kp) in &signers {
631            let share = sign_share(&config, kp, &nonces[id], &package, message).expect("share");
632            shares.insert(*id, share.signature_share);
633        }
634
635        // Coordinator aggregates; result verifies against the root key and
636        // matches the threshold the one-shot path would accept.
637        let signature = aggregate_signature(
638            &config,
639            &dkg.public_key_package,
640            &package.signing_package,
641            shares,
642            message,
643        )
644        .expect("aggregate");
645        verify_root_signature(
646            &dkg.public_key_package.root_public_key,
647            message,
648            &signature.signature,
649        )
650        .expect("distributed signature verifies");
651        assert_eq!(signature.signer_ids.len(), 7);
652    }
653
654    #[test]
655    fn build_signing_package_rejects_sub_threshold_commitment_set() {
656        let config = test_config();
657        let mut rng = StdRng::seed_from_u64(100);
658        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
659        let mut commitments = BTreeMap::new();
660        for (id, kp) in dkg.key_packages.iter().take(3) {
661            let (commitment, _nonces) = sign_commit(&config, kp, b"msg", &mut rng).expect("commit");
662            commitments.insert(*id, commitment.commitments);
663        }
664        let error = build_signing_package(&config, commitments, b"msg")
665            .expect_err("sub-threshold commitments");
666        assert!(matches!(
667            error,
668            RootError::ThresholdNotMet {
669                required: 7,
670                supplied: 3
671            }
672        ));
673    }
674
675    #[test]
676    fn distributed_signing_rejects_unrostered_and_sub_threshold() {
677        let config = test_config();
678        let mut rng = StdRng::seed_from_u64(123);
679        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
680
681        // sign_commit rejects an unrostered key package.
682        let mut stranger = dkg.key_packages[&1].clone();
683        stranger.frost_identifier = 99;
684        assert!(matches!(
685            sign_commit(&config, &stranger, b"msg", &mut rng).expect_err("unrostered commit"),
686            RootError::InvalidConfig { .. }
687        ));
688
689        // build_signing_package rejects a commitment from an unrostered signer.
690        let mut commitments = BTreeMap::new();
691        for (id, kp) in dkg.key_packages.iter().take(7) {
692            let (commitment, _nonces) = sign_commit(&config, kp, b"msg", &mut rng).expect("commit");
693            commitments.insert(*id, commitment.commitments);
694        }
695        let mut unrostered = commitments.clone();
696        let stolen = unrostered.remove(&1).expect("commitment one");
697        unrostered.insert(99, stolen);
698        assert!(matches!(
699            build_signing_package(&config, unrostered, b"msg").expect_err("unrostered commitment"),
700            RootError::InvalidConfig { .. }
701        ));
702
703        // sign_share rejects an unrostered key package.
704        let package = build_signing_package(&config, commitments, b"msg").expect("package");
705        let (_commitment, commit_nonces) =
706            sign_commit(&config, &dkg.key_packages[&1], b"msg", &mut rng).expect("commit");
707        assert!(matches!(
708            sign_share(&config, &stranger, &commit_nonces, &package, b"msg")
709                .expect_err("unrostered share"),
710            RootError::InvalidConfig { .. }
711        ));
712
713        // aggregate_signature enforces the threshold and rosters every share.
714        assert!(matches!(
715            aggregate_signature(
716                &config,
717                &dkg.public_key_package,
718                &package.signing_package,
719                BTreeMap::new(),
720                b"msg",
721            )
722            .expect_err("sub-threshold aggregate"),
723            RootError::ThresholdNotMet { required: 7, .. }
724        ));
725    }
726
727    #[test]
728    fn aggregate_signature_rejects_non_declared_signer_set_before_deserialization() {
729        let config = test_config();
730        let public_key_package = RootPublicKeyPackage {
731            public_key_package: b"not a public package".to_vec(),
732            root_public_key: b"not a verifying key".to_vec(),
733            verifying_shares: BTreeMap::new(),
734        };
735        let shares = [1, 2, 3, 4, 5, 6, 9]
736            .into_iter()
737            .map(|identifier| (identifier, vec![u8::try_from(identifier).expect("id fits")]))
738            .collect();
739        let error = aggregate_signature(
740            &config,
741            &public_key_package,
742            b"not a signing package",
743            shares,
744            b"artifact",
745        )
746        .expect_err("non-declared aggregate signer set");
747        assert!(
748            error.to_string().contains("predeclared signing_set"),
749            "expected signer-set rejection before signature artifacts decode, got: {error}"
750        );
751    }
752
753    #[test]
754    fn sign_share_rejects_nonces_bound_to_a_different_signer() {
755        // Nonces must belong to the same signer as the key package. The identifier
756        // check runs before any deserialization, so empty byte fields suffice and
757        // no DKG is needed.
758        let config = test_config();
759        let key_package = RootKeyPackage {
760            frost_identifier: 1,
761            key_package: Vec::new(),
762        };
763        let foreign_nonces = RootSigningNonces {
764            frost_identifier: 2,
765            ceremony_id: config.ceremony_id.clone(),
766            artifact_hash: Hash256::digest(b"artifact"),
767            commitment_hash: Hash256::digest(b"unrelated commitment"),
768            nonces: Vec::new(),
769        };
770        let empty_package = RootSigningPackage {
771            signing_package: Vec::new(),
772            signer_ids: Vec::new(),
773        };
774        let error = sign_share(
775            &config,
776            &key_package,
777            &foreign_nonces,
778            &empty_package,
779            b"artifact",
780        )
781        .expect_err("nonces bound to a different signer must be rejected");
782        assert!(error.to_string().contains("mismatches key 1"));
783    }
784
785    #[test]
786    fn sign_share_rejects_nonces_from_a_different_ceremony() {
787        // The ceremony-id check runs before any deserialization, so empty byte
788        // fields suffice and no DKG is needed.
789        let config = test_config();
790        let key_package = RootKeyPackage {
791            frost_identifier: 1,
792            key_package: Vec::new(),
793        };
794        let foreign_nonces = RootSigningNonces {
795            frost_identifier: 1,
796            ceremony_id: "some-other-ceremony".to_owned(),
797            artifact_hash: Hash256::digest(b"artifact"),
798            commitment_hash: Hash256::digest(b"unrelated commitment"),
799            nonces: Vec::new(),
800        };
801        let empty_package = RootSigningPackage {
802            signing_package: Vec::new(),
803            signer_ids: Vec::new(),
804        };
805        let error = sign_share(
806            &config,
807            &key_package,
808            &foreign_nonces,
809            &empty_package,
810            b"artifact",
811        )
812        .expect_err("nonces from a different ceremony must be rejected");
813        assert!(error.to_string().contains("different ceremony"));
814    }
815
816    #[test]
817    fn sign_share_rejects_nonces_bound_to_a_different_signing_instance() {
818        // A signer's nonces whose commitment hash does not match the commitment
819        // bound for that signer in the signing package are rejected — this is what
820        // ties a nonce to one artifact + signer set and prevents cross-instance reuse.
821        let config = test_config();
822        let mut rng = StdRng::seed_from_u64(404);
823        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
824        let mut commitments = BTreeMap::new();
825        for (id, kp) in dkg.key_packages.iter().take(7) {
826            let (commitment, _nonces) =
827                sign_commit(&config, kp, b"artifact", &mut rng).expect("commit");
828            commitments.insert(*id, commitment.commitments);
829        }
830        let package = build_signing_package(&config, commitments, b"artifact").expect("package");
831        // Fresh nonces for signer 1 (same artifact) from a *different* commitment
832        // than the one in the package (a second sign_commit produces a new commitment).
833        let (_other_commitment, stale_nonces) =
834            sign_commit(&config, &dkg.key_packages[&1], b"artifact", &mut rng).expect("commit");
835        let error = sign_share(
836            &config,
837            &dkg.key_packages[&1],
838            &stale_nonces,
839            &package,
840            b"artifact",
841        )
842        .expect_err("nonces not bound to the package commitment must be rejected");
843        assert!(
844            error
845                .to_string()
846                .contains("not bound to this signing package")
847        );
848    }
849
850    #[test]
851    fn sign_share_rejects_signer_absent_from_signing_package() {
852        // Signers 1..7 are the canonical set; an alternate (id 8) that is not in
853        // that set cannot sign against it.
854        let config = test_config();
855        let mut rng = StdRng::seed_from_u64(606);
856        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
857        let mut commitments = BTreeMap::new();
858        for (id, kp) in dkg.key_packages.iter().take(7) {
859            let (commitment, _nonces) =
860                sign_commit(&config, kp, b"artifact", &mut rng).expect("commit");
861            commitments.insert(*id, commitment.commitments);
862        }
863        let package = build_signing_package(&config, commitments, b"artifact").expect("package");
864        let (_commitment8, nonces8) =
865            sign_commit(&config, &dkg.key_packages[&8], b"artifact", &mut rng).expect("commit 8");
866        let error = sign_share(
867            &config,
868            &dkg.key_packages[&8],
869            &nonces8,
870            &package,
871            b"artifact",
872        )
873        .expect_err("a signer absent from the set must be rejected");
874        assert!(
875            error
876                .to_string()
877                .contains("signer is not in the signing package's signer set")
878        );
879    }
880
881    #[test]
882    fn sign_share_rejects_non_canonical_signer_set() {
883        // Bob's regression: a coordinator must not bypass the ratified
884        // signer-selection policy by hand-crafting a RootSigningPackage with a
885        // non-declared signer set. Set [1,2,3,4,5,6,9] replaces signer 7 with
886        // signer 9; sign_share must reject it even though build_signing_package
887        // was never used.
888        let config = test_config();
889        let mut rng = StdRng::seed_from_u64(909);
890        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
891        let mut commitments = BTreeMap::new();
892        let mut signer_one_nonces = None;
893        for (id, kp) in dkg.key_packages.iter().take(7) {
894            let (commitment, nonces) =
895                sign_commit(&config, kp, b"artifact", &mut rng).expect("commit");
896            if *id == 1 {
897                signer_one_nonces = Some(nonces);
898            }
899            commitments.insert(*id, commitment.commitments);
900        }
901        let mut package =
902            build_signing_package(&config, commitments, b"artifact").expect("package");
903        // Hand-craft a non-canonical signer set (skips required alternate 8).
904        package.signer_ids = vec![1, 2, 3, 4, 5, 6, 9];
905        let error = sign_share(
906            &config,
907            &dkg.key_packages[&1],
908            &signer_one_nonces.expect("signer one nonces"),
909            &package,
910            b"artifact",
911        )
912        .expect_err("a non-canonical signer set must be rejected");
913        assert!(
914            error.to_string().contains("predeclared signing_set"),
915            "expected a signing-selection-policy rejection, got: {error}"
916        );
917    }
918
919    #[test]
920    fn sign_share_refuses_to_sign_a_second_different_message_with_one_nonce_set() {
921        // Bob's blocker-2 regression: a coordinator must not be able to get one
922        // RootSigningNonces object to sign two different messages (FROST nonce
923        // reuse exposes the signer key share). The first share over the bound
924        // artifact succeeds; a second share over a *different* message is rejected.
925        let config = test_config();
926        let mut rng = StdRng::seed_from_u64(707);
927        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
928        let artifact = b"the one true root artifact";
929        let mut commitments = BTreeMap::new();
930        let mut nonces = BTreeMap::new();
931        for (id, kp) in dkg.key_packages.iter().take(7) {
932            let (commitment, signer_nonces) =
933                sign_commit(&config, kp, artifact, &mut rng).expect("commit");
934            commitments.insert(*id, commitment.commitments);
935            nonces.insert(*id, signer_nonces);
936        }
937        let package = build_signing_package(&config, commitments, artifact).expect("package");
938
939        // First use over the bound artifact succeeds.
940        sign_share(
941            &config,
942            &dkg.key_packages[&1],
943            &nonces[&1],
944            &package,
945            artifact,
946        )
947        .expect("first share over the bound artifact");
948
949        // Second use of the SAME nonces over a different message is refused.
950        let error = sign_share(
951            &config,
952            &dkg.key_packages[&1],
953            &nonces[&1],
954            &package,
955            b"a different message the coordinator wants signed",
956        )
957        .expect_err("a second, different message under the same nonces must be rejected");
958        assert!(error.to_string().contains("bound to a different artifact"));
959    }
960
961    #[test]
962    fn sign_share_rejects_a_signing_package_missing_a_declared_signer() {
963        // A RootSigningPackage whose signer_ids claim a signer whose commitment is
964        // absent from the embedded package is malformed; the rebuild fails closed.
965        let config = test_config();
966        let mut rng = StdRng::seed_from_u64(808);
967        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
968        let mut commitments = BTreeMap::new();
969        let mut signer_one_nonces = None;
970        for (id, kp) in dkg.key_packages.iter().take(7) {
971            let (commitment, nonces) =
972                sign_commit(&config, kp, b"artifact", &mut rng).expect("commit");
973            if *id == 1 {
974                signer_one_nonces = Some(nonces);
975            }
976            commitments.insert(*id, commitment.commitments);
977        }
978        let mut package =
979            build_signing_package(&config, commitments, b"artifact").expect("package");
980        let parsed_package: frost::SigningPackage =
981            deserialize_frost(package.signing_package.as_slice()).expect("parsed package");
982        let mut embedded_commitments = BTreeMap::new();
983        for id in 1..=6u16 {
984            let signer_frost_id = crate::dkg::frost_identifier(id).expect("frost id");
985            let commitment = parsed_package
986                .signing_commitment(&signer_frost_id)
987                .expect("commitment");
988            embedded_commitments.insert(signer_frost_id, commitment);
989        }
990        let (commitment8, _nonces8) =
991            sign_commit(&config, &dkg.key_packages[&8], b"artifact", &mut rng).expect("commit 8");
992        embedded_commitments.insert(
993            crate::dkg::frost_identifier(8).expect("frost id 8"),
994            deserialize_frost(commitment8.commitments.as_slice()).expect("commitment 8"),
995        );
996        let malformed_package = frost::SigningPackage::new(embedded_commitments, b"artifact");
997        package.signing_package = serialize_frost(&malformed_package).expect("serialize package");
998        package.signer_ids = vec![1, 2, 3, 4, 5, 6, 7];
999        let error = sign_share(
1000            &config,
1001            &dkg.key_packages[&1],
1002            &signer_one_nonces.expect("signer one nonces"),
1003            &package,
1004            b"artifact",
1005        )
1006        .expect_err("a signing package missing a declared signer must be rejected");
1007        assert!(
1008            error
1009                .to_string()
1010                .contains("missing commitment for signer 7")
1011        );
1012    }
1013}