Skip to main content

exo_root/
signing.rs

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