Skip to main content

exo_root/
bundle.rs

1//! Root trust bundle assembly and verification.
2
3use std::fmt::Display;
4
5use exo_authority::permission::Permission;
6use exo_core::{Did, Hash256, PublicKey, Timestamp, hash::hash_structured};
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    GenesisCeremonyConfig, Result, RootError, RootPublicKeyPackage,
11    dkg::validate_public_key_package,
12    signing::{RootSignature, validate_root_signer_ids},
13    verify_root_signature,
14};
15
16/// Operational AVC issuer authority delegated by the root.
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18pub struct RootIssuerDelegation {
19    /// Operational issuer DID.
20    pub issuer_did: Did,
21    /// Operational issuer public key.
22    pub issuer_public_key: PublicKey,
23    /// Permissions granted by the root authority.
24    pub granted_permissions: Vec<Permission>,
25    /// HLC activation timestamp.
26    pub effective_at: Timestamp,
27    /// Optional HLC expiry timestamp.
28    pub expires_at: Option<Timestamp>,
29    /// Human-readable bounded purpose.
30    pub purpose: String,
31}
32
33/// Root trust bundle produced by genesis.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
35pub struct RootTrustBundle {
36    /// Ceremony configuration.
37    pub config: GenesisCeremonyConfig,
38    /// Public FROST package and root key.
39    pub public_key_package: RootPublicKeyPackage,
40    /// Root-signed AVC issuer delegation.
41    pub issuer_delegation: RootIssuerDelegation,
42    /// Canonical transcript hash.
43    pub transcript_hash: Hash256,
44    /// Root threshold signature over the trust artifact payload.
45    pub root_signature: RootSignature,
46    /// Canonical bundle content identifier.
47    pub bundle_id: Hash256,
48}
49
50#[derive(Serialize)]
51struct RootArtifactPayload<'a> {
52    domain: &'static str,
53    config_hash: Hash256,
54    public_key_package_hash: Hash256,
55    transcript_hash: Hash256,
56    issuer_delegation_hash: Hash256,
57    issuer_did: &'a Did,
58    signer_ids: &'a [u16],
59}
60
61#[derive(Serialize)]
62struct RootBundleIdPayload<'a> {
63    domain: &'static str,
64    artifact_payload_hash: Hash256,
65    root_signature: &'a RootSignature,
66}
67
68fn canonical_bytes<T: Serialize>(value: &T) -> Result<Vec<u8>> {
69    let mut bytes = Vec::new();
70    ciborium::into_writer(value, &mut bytes).map_err(canonical_encoding_error)?;
71    Ok(bytes)
72}
73
74fn structured_hash<T: Serialize>(value: &T) -> Result<Hash256> {
75    hash_structured(value).map_err(canonical_encoding_error)
76}
77
78fn canonical_encoding_error(error: impl Display) -> RootError {
79    RootError::CanonicalEncoding {
80        detail: error.to_string(),
81    }
82}
83
84impl RootIssuerDelegation {
85    /// Canonical payload signed by the root threshold authority for the
86    /// predeclared deterministic signing set.
87    pub fn root_artifact_payload(
88        &self,
89        config: &GenesisCeremonyConfig,
90        public_key_package: &RootPublicKeyPackage,
91        transcript_hash: Hash256,
92    ) -> Result<Vec<u8>> {
93        self.root_artifact_payload_for_signers(
94            config,
95            public_key_package,
96            transcript_hash,
97            config.signing_set.as_slice(),
98        )
99    }
100
101    /// Canonical payload signed by the root threshold authority for the exact
102    /// signer metadata carried by a root signature.
103    pub fn root_artifact_payload_for_signers(
104        &self,
105        config: &GenesisCeremonyConfig,
106        public_key_package: &RootPublicKeyPackage,
107        transcript_hash: Hash256,
108        signer_ids: &[u16],
109    ) -> Result<Vec<u8>> {
110        config.validate()?;
111        validate_root_signer_ids(config, signer_ids)?;
112        if self.purpose.trim().is_empty() {
113            return Err(RootError::BundleRejected {
114                reason: "issuer delegation purpose must not be empty".to_owned(),
115            });
116        }
117        if self.granted_permissions.is_empty() {
118            return Err(RootError::BundleRejected {
119                reason: "issuer delegation must grant at least one permission".to_owned(),
120            });
121        }
122        let payload = RootArtifactPayload {
123            domain: "EXOCHAIN_ROOT_ARTIFACT_V1",
124            config_hash: structured_hash(config)?,
125            public_key_package_hash: structured_hash(public_key_package)?,
126            transcript_hash,
127            issuer_delegation_hash: structured_hash(self)?,
128            issuer_did: &self.issuer_did,
129            signer_ids,
130        };
131        canonical_bytes(&payload)
132    }
133}
134
135fn bundle_id(
136    delegation: &RootIssuerDelegation,
137    config: &GenesisCeremonyConfig,
138    public_key_package: &RootPublicKeyPackage,
139    transcript_hash: Hash256,
140    root_signature: &RootSignature,
141) -> Result<Hash256> {
142    let artifact_payload = delegation.root_artifact_payload_for_signers(
143        config,
144        public_key_package,
145        transcript_hash,
146        root_signature.signer_ids.as_slice(),
147    )?;
148    let id_payload = RootBundleIdPayload {
149        domain: "EXOCHAIN_ROOT_BUNDLE_V1",
150        artifact_payload_hash: Hash256::digest(&artifact_payload),
151        root_signature,
152    };
153    structured_hash(&id_payload)
154}
155
156/// Assemble and verify a root trust bundle.
157pub fn assemble_root_bundle(
158    config: GenesisCeremonyConfig,
159    public_key_package: RootPublicKeyPackage,
160    issuer_delegation: RootIssuerDelegation,
161    transcript_hash: Hash256,
162    root_signature: RootSignature,
163) -> Result<RootTrustBundle> {
164    validate_public_key_package(&config, &public_key_package)?;
165    validate_root_signer_ids(&config, root_signature.signer_ids.as_slice())?;
166    let payload = issuer_delegation.root_artifact_payload_for_signers(
167        &config,
168        &public_key_package,
169        transcript_hash,
170        root_signature.signer_ids.as_slice(),
171    )?;
172    verify_root_signature(
173        &public_key_package.root_public_key,
174        &payload,
175        root_signature.signature.as_slice(),
176    )?;
177    let bundle_id = bundle_id(
178        &issuer_delegation,
179        &config,
180        &public_key_package,
181        transcript_hash,
182        &root_signature,
183    )?;
184    Ok(RootTrustBundle {
185        config,
186        public_key_package,
187        issuer_delegation,
188        transcript_hash,
189        root_signature,
190        bundle_id,
191    })
192}
193
194/// Verify that a root trust bundle is self-consistent and root-signed.
195pub fn verify_root_bundle(bundle: &RootTrustBundle) -> Result<()> {
196    bundle.config.validate()?;
197    validate_public_key_package(&bundle.config, &bundle.public_key_package)?;
198    validate_root_signer_ids(&bundle.config, bundle.root_signature.signer_ids.as_slice())?;
199    let payload = bundle.issuer_delegation.root_artifact_payload_for_signers(
200        &bundle.config,
201        &bundle.public_key_package,
202        bundle.transcript_hash,
203        bundle.root_signature.signer_ids.as_slice(),
204    )?;
205    verify_root_signature(
206        &bundle.public_key_package.root_public_key,
207        &payload,
208        bundle.root_signature.signature.as_slice(),
209    )?;
210    let expected_id = bundle_id(
211        &bundle.issuer_delegation,
212        &bundle.config,
213        &bundle.public_key_package,
214        bundle.transcript_hash,
215        &bundle.root_signature,
216    )?;
217    if expected_id != bundle.bundle_id {
218        return Err(RootError::BundleRejected {
219            reason: "bundle identifier does not match contents".to_owned(),
220        });
221    }
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use std::collections::BTreeMap;
228
229    use exo_core::{Did, PublicKey};
230    use frost_ristretto255 as frost;
231    use rand::{SeedableRng, rngs::StdRng};
232
233    use super::*;
234    use crate::{
235        CertifierContact, RootKeyPackage,
236        dkg::{deserialize_frost, serialize_frost},
237        run_complete_dkg,
238    };
239
240    #[derive(Serialize)]
241    struct LegacyRootArtifactPayload<'a> {
242        domain: &'static str,
243        config_hash: Hash256,
244        public_key_package_hash: Hash256,
245        transcript_hash: Hash256,
246        issuer_delegation_hash: Hash256,
247        issuer_did: &'a Did,
248    }
249
250    fn test_config() -> GenesisCeremonyConfig {
251        let certifiers = (1..=13)
252            .map(|identifier| {
253                let byte = u8::try_from(identifier).expect("identifier fits");
254                CertifierContact {
255                    did: Did::new(&format!("did:exo:bundle-unit-{identifier:02}"))
256                        .expect("valid did"),
257                    frost_identifier: identifier,
258                    signing_public_key: PublicKey::from_bytes([byte; 32]),
259                    transport_public_key: [byte; 32],
260                }
261            })
262            .collect();
263        GenesisCeremonyConfig {
264            ceremony_id: "bundle-root".into(),
265            network_id: "unit-net".into(),
266            repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".into(),
267            constitution_hash: Hash256::digest(b"constitution"),
268            threshold: 7,
269            max_signers: 13,
270            created_at: Timestamp::new(1, 0),
271            certifiers,
272            signing_set: (1..=7).collect(),
273        }
274    }
275
276    fn issuer_delegation() -> RootIssuerDelegation {
277        RootIssuerDelegation {
278            issuer_did: Did::new("did:exo:bundle-avc-issuer").expect("valid did"),
279            issuer_public_key: PublicKey::from_bytes([0x44; 32]),
280            granted_permissions: vec![Permission::Govern, Permission::Delegate],
281            effective_at: Timestamp::new(1_785_000_010_000, 0),
282            expires_at: None,
283            purpose: "Delegate operational AVC issuing authority".into(),
284        }
285    }
286
287    fn legacy_unbound_root_artifact_payload(
288        delegation: &RootIssuerDelegation,
289        config: &GenesisCeremonyConfig,
290        public_key_package: &RootPublicKeyPackage,
291        transcript_hash: Hash256,
292    ) -> Result<Vec<u8>> {
293        let payload = LegacyRootArtifactPayload {
294            domain: "EXOCHAIN_ROOT_ARTIFACT_V1",
295            config_hash: structured_hash(config)?,
296            public_key_package_hash: structured_hash(public_key_package)?,
297            transcript_hash,
298            issuer_delegation_hash: structured_hash(delegation)?,
299            issuer_did: &delegation.issuer_did,
300        };
301        canonical_bytes(&payload)
302    }
303
304    fn raw_threshold_signature_without_signer_policy<R>(
305        public_key_package: &RootPublicKeyPackage,
306        shares: BTreeMap<u16, RootKeyPackage>,
307        message: &[u8],
308        rng: &mut R,
309    ) -> RootSignature
310    where
311        R: frost::rand_core::RngCore + frost::rand_core::CryptoRng,
312    {
313        let public: frost::keys::PublicKeyPackage =
314            deserialize_frost(public_key_package.public_key_package.as_slice())
315                .expect("public key package");
316        let mut key_packages = BTreeMap::new();
317        let mut signing_nonces = BTreeMap::new();
318        let mut signing_commitments = BTreeMap::new();
319
320        for (identifier, share) in &shares {
321            let frost_identifier = frost::Identifier::try_from(*identifier).expect("frost id");
322            let key_package: frost::keys::KeyPackage =
323                deserialize_frost(share.key_package.as_slice()).expect("key package");
324            let (nonces, commitments) = frost::round1::commit(key_package.signing_share(), rng);
325            signing_nonces.insert(frost_identifier, nonces);
326            signing_commitments.insert(frost_identifier, commitments);
327            key_packages.insert(frost_identifier, key_package);
328        }
329
330        let signing_package = frost::SigningPackage::new(signing_commitments, message);
331        let mut signature_shares = BTreeMap::new();
332        for (identifier, key_package) in &key_packages {
333            let share =
334                frost::round2::sign(&signing_package, &signing_nonces[identifier], key_package)
335                    .expect("signature share");
336            signature_shares.insert(*identifier, share);
337        }
338        let aggregate =
339            frost::aggregate(&signing_package, &signature_shares, &public).expect("aggregate");
340        let signer_ids = shares.keys().copied().collect();
341        RootSignature {
342            signature: serialize_frost(&aggregate).expect("signature encoding"),
343            signer_ids,
344        }
345    }
346
347    #[test]
348    fn canonical_error_conversion_is_diagnostic() {
349        let error = canonical_encoding_error("encoder failed");
350        assert!(error.to_string().contains("encoder failed"));
351    }
352
353    #[test]
354    fn root_bundle_rejects_relabelled_signature_when_signer_metadata_was_unsigned() {
355        let config = test_config();
356        let mut rng = StdRng::seed_from_u64(700);
357        let dkg = run_complete_dkg(&config, &mut rng).expect("dkg");
358        let delegation = issuer_delegation();
359        let transcript_hash = Hash256::digest(b"transcript");
360        let legacy_payload = legacy_unbound_root_artifact_payload(
361            &delegation,
362            &config,
363            &dkg.public_key_package,
364            transcript_hash,
365        )
366        .expect("legacy payload");
367        let actual_signers = [1, 2, 3, 4, 5, 6, 8]
368            .into_iter()
369            .map(|identifier| {
370                (
371                    identifier,
372                    dkg.key_packages
373                        .get(&identifier)
374                        .expect("key package")
375                        .clone(),
376                )
377            })
378            .collect();
379        let mut root_signature = raw_threshold_signature_without_signer_policy(
380            &dkg.public_key_package,
381            actual_signers,
382            &legacy_payload,
383            &mut rng,
384        );
385        assert_eq!(root_signature.signer_ids, vec![1, 2, 3, 4, 5, 6, 8]);
386
387        root_signature.signer_ids = config.signing_set.clone();
388
389        assert!(
390            assemble_root_bundle(
391                config,
392                dkg.public_key_package,
393                delegation,
394                transcript_hash,
395                root_signature,
396            )
397            .is_err(),
398            "bundle assembly must reject a threshold signature whose claimed signer metadata was not covered by the signed artifact"
399        );
400    }
401}