Skip to main content

exo_root/
ceremony.rs

1//! Ceremony policy and certifier roster validation.
2
3use std::collections::BTreeSet;
4
5use exo_core::{Did, Hash256, PublicKey, Timestamp};
6use serde::{Deserialize, Serialize};
7
8use crate::{Result, RootError};
9
10/// Institutional root threshold.
11pub const ROOT_GENESIS_THRESHOLD: u16 = 7;
12
13/// Institutional root roster size.
14pub const ROOT_GENESIS_SIGNERS: u16 = 13;
15
16/// Public contact and verification material for a root certifier.
17#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
18pub struct CertifierContact {
19    /// Certifier DID.
20    pub did: Did,
21    /// FROST signer identifier in the inclusive range 1..=13.
22    pub frost_identifier: u16,
23    /// Ed25519 public key used for signed portal envelopes.
24    pub signing_public_key: PublicKey,
25    /// X25519 public key used for recipient-bound round-two payloads.
26    pub transport_public_key: [u8; 32],
27}
28
29/// Root genesis ceremony configuration bound into every transcript and bundle.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31pub struct GenesisCeremonyConfig {
32    /// Ceremony identifier chosen before DKG starts.
33    pub ceremony_id: String,
34    /// EXOCHAIN network identifier.
35    pub network_id: String,
36    /// Repository commit reviewed for the ceremony.
37    pub repo_commit: String,
38    /// Canonical hash of the governing constitution.
39    pub constitution_hash: Hash256,
40    /// Threshold required to sign root artifacts after genesis.
41    pub threshold: u16,
42    /// Total roster size.
43    pub max_signers: u16,
44    /// HLC timestamp supplied by the operator.
45    pub created_at: Timestamp,
46    /// Full certifier roster.
47    pub certifiers: Vec<CertifierContact>,
48    /// Predeclared deterministic signing set: exactly `threshold` rostered FROST
49    /// identifiers chosen before commitments are emitted. Root artifacts are
50    /// signed only by this exact set. If any signer is unavailable, the ceremony
51    /// aborts and restarts with a new signed config and ceremony id.
52    pub signing_set: Vec<u16>,
53}
54
55impl GenesisCeremonyConfig {
56    /// Validate the constitutional root policy and roster uniqueness.
57    pub fn validate(&self) -> Result<()> {
58        if self.threshold != ROOT_GENESIS_THRESHOLD {
59            return Err(RootError::InvalidConfig {
60                reason: format!("threshold must be {ROOT_GENESIS_THRESHOLD}"),
61            });
62        }
63        if self.max_signers != ROOT_GENESIS_SIGNERS {
64            return Err(RootError::InvalidConfig {
65                reason: format!("max_signers must be {ROOT_GENESIS_SIGNERS}"),
66            });
67        }
68        if self.certifiers.len() != usize::from(ROOT_GENESIS_SIGNERS) {
69            return Err(RootError::InvalidConfig {
70                reason: format!("roster must contain {ROOT_GENESIS_SIGNERS} certifiers"),
71            });
72        }
73        if self.ceremony_id.trim().is_empty() {
74            return Err(RootError::InvalidConfig {
75                reason: "ceremony_id must not be empty".to_owned(),
76            });
77        }
78        if self.network_id.trim().is_empty() {
79            return Err(RootError::InvalidConfig {
80                reason: "network_id must not be empty".to_owned(),
81            });
82        }
83        if self.repo_commit.len() != 40 || !self.repo_commit.bytes().all(|b| b.is_ascii_hexdigit())
84        {
85            return Err(RootError::InvalidConfig {
86                reason: "repo_commit must be a 40-character hex commit".to_owned(),
87            });
88        }
89
90        let mut dids = BTreeSet::new();
91        let mut frost_ids = BTreeSet::new();
92        let mut signing_keys = BTreeSet::new();
93        let mut transport_keys = BTreeSet::new();
94        for certifier in &self.certifiers {
95            if certifier.frost_identifier == 0 || certifier.frost_identifier > ROOT_GENESIS_SIGNERS
96            {
97                return Err(RootError::InvalidConfig {
98                    reason: format!(
99                        "frost_identifier {} is outside 1..={ROOT_GENESIS_SIGNERS}",
100                        certifier.frost_identifier
101                    ),
102                });
103            }
104            if !dids.insert(certifier.did.clone()) {
105                return Err(RootError::InvalidConfig {
106                    reason: format!("duplicate certifier DID {}", certifier.did),
107                });
108            }
109            if !frost_ids.insert(certifier.frost_identifier) {
110                return Err(RootError::InvalidConfig {
111                    reason: format!("duplicate FROST identifier {}", certifier.frost_identifier),
112                });
113            }
114            if !signing_keys.insert(certifier.signing_public_key) {
115                return Err(RootError::InvalidConfig {
116                    reason: "duplicate signing public key".to_owned(),
117                });
118            }
119            if !transport_keys.insert(certifier.transport_public_key) {
120                return Err(RootError::InvalidConfig {
121                    reason: "duplicate transport public key".to_owned(),
122                });
123            }
124        }
125
126        self.validate_signing_set()?;
127
128        Ok(())
129    }
130
131    /// Validate the predeclared signing set.
132    fn validate_signing_set(&self) -> Result<()> {
133        if self.signing_set.len() != usize::from(self.threshold) {
134            return Err(RootError::InvalidConfig {
135                reason: format!(
136                    "signing_set must contain exactly {} signers",
137                    self.threshold
138                ),
139            });
140        }
141        let mut declared = BTreeSet::new();
142        for identifier in &self.signing_set {
143            if self.certifier_by_identifier(*identifier).is_none() {
144                return Err(RootError::InvalidConfig {
145                    reason: format!("signing_set member {identifier} is not rostered"),
146                });
147            }
148            if !declared.insert(*identifier) {
149                return Err(RootError::InvalidConfig {
150                    reason: format!("duplicate signing_set member {identifier}"),
151                });
152            }
153        }
154        Ok(())
155    }
156
157    /// Validate that `submitted` is the canonical signing selection for this
158    /// ceremony: exactly the predeclared `signing_set`. There is no in-ceremony
159    /// alternate substitution; an unavailable signer aborts the ceremony and forces
160    /// a new config/ceremony id.
161    pub fn validate_signing_selection(&self, submitted: &BTreeSet<u16>) -> Result<()> {
162        if submitted.len() != usize::from(self.threshold) {
163            return Err(RootError::InvalidConfig {
164                reason: format!(
165                    "signing selection must contain exactly {} signers",
166                    self.threshold
167                ),
168            });
169        }
170        let expected: BTreeSet<u16> = self.signing_set.iter().copied().collect();
171        if &expected != submitted {
172            return Err(RootError::InvalidConfig {
173                reason: "signing selection must exactly match the predeclared signing_set"
174                    .to_owned(),
175            });
176        }
177        Ok(())
178    }
179
180    /// Return the certifier with the supplied DID, if rostered.
181    #[must_use]
182    pub fn certifier_by_did(&self, did: &Did) -> Option<&CertifierContact> {
183        self.certifiers
184            .iter()
185            .find(|certifier| &certifier.did == did)
186    }
187
188    /// Return the certifier with the supplied FROST identifier, if rostered.
189    #[must_use]
190    pub fn certifier_by_identifier(&self, identifier: u16) -> Option<&CertifierContact> {
191        self.certifiers
192            .iter()
193            .find(|certifier| certifier.frost_identifier == identifier)
194    }
195}
196
197#[cfg(test)]
198mod tests {
199    use std::collections::BTreeSet;
200
201    use exo_core::{Did, Hash256, PublicKey, Timestamp};
202
203    use super::*;
204
205    fn config() -> GenesisCeremonyConfig {
206        let certifiers = (1..=ROOT_GENESIS_SIGNERS)
207            .map(|index| {
208                let byte = u8::try_from(index).expect("index fits");
209                CertifierContact {
210                    did: Did::new(&format!("did:exo:ceremony-unit-{index:02}")).expect("did"),
211                    frost_identifier: index,
212                    signing_public_key: PublicKey::from_bytes([byte; 32]),
213                    transport_public_key: [byte; 32],
214                }
215            })
216            .collect();
217        GenesisCeremonyConfig {
218            ceremony_id: "ceremony-unit".into(),
219            network_id: "exochain-test".into(),
220            repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".into(),
221            constitution_hash: Hash256::digest(b"constitution"),
222            threshold: ROOT_GENESIS_THRESHOLD,
223            max_signers: ROOT_GENESIS_SIGNERS,
224            created_at: Timestamp::new(1, 0),
225            certifiers,
226            signing_set: (1..=7).collect(),
227        }
228    }
229
230    fn selection(ids: &[u16]) -> BTreeSet<u16> {
231        ids.iter().copied().collect()
232    }
233
234    #[test]
235    fn valid_config_with_signing_set_validates() {
236        config().validate().expect("config validates");
237    }
238
239    #[test]
240    fn validate_rejects_malformed_signing_set() {
241        let mut wrong_len = config();
242        wrong_len.signing_set = (1..=6).collect();
243        assert!(wrong_len.validate().is_err());
244
245        let mut unrostered_primary = config();
246        unrostered_primary.signing_set = vec![1, 2, 3, 4, 5, 6, 99];
247        assert!(unrostered_primary.validate().is_err());
248
249        let mut duplicate_primary = config();
250        duplicate_primary.signing_set = vec![1, 2, 3, 4, 5, 6, 6];
251        assert!(duplicate_primary.validate().is_err());
252    }
253
254    #[test]
255    fn validate_signing_selection_accepts_only_declared_set() {
256        let config = config();
257        config
258            .validate_signing_selection(&selection(&[1, 2, 3, 4, 5, 6, 7]))
259            .expect("declared set accepted");
260    }
261
262    #[test]
263    fn validate_signing_selection_rejects_wrong_size_unknown_and_substitution() {
264        let config = config();
265        // Wrong size.
266        assert!(
267            config
268                .validate_signing_selection(&selection(&[1, 2, 3, 4, 5, 6, 7, 8]))
269                .is_err()
270        );
271        // Signer outside the declared pool.
272        assert!(
273            config
274                .validate_signing_selection(&selection(&[1, 2, 3, 4, 5, 6, 99]))
275                .is_err()
276        );
277        // No alternate substitution: primary 7 absent and alternate 8 present.
278        assert!(
279            config
280                .validate_signing_selection(&selection(&[1, 2, 3, 4, 5, 6, 8]))
281                .is_err()
282        );
283    }
284}