Skip to main content

exo_root/
portal.rs

1//! Server-side root genesis portal relay policy.
2
3use std::collections::{BTreeMap, BTreeSet};
4
5use exo_core::{Did, Hash256, SecretKey, Signature, crypto, hash::hash_structured};
6use frost_ristretto255 as frost;
7use serde::{Deserialize, Serialize};
8
9use crate::{
10    GenesisCeremonyConfig, PairwiseEncryptedPayload, Result, RootError,
11    dkg::{
12        RootParticipantDkgOutput, RootPublicKeyPackage, deserialize_frost, frost_identifier,
13        validate_public_key_package,
14    },
15};
16
17const MAX_PORTAL_PAYLOAD_BYTES: usize = 64 * 1024;
18pub const FINAL_KEY_CONFIRMATION_DOMAIN: &str = "EXOCHAIN_ROOT_FINAL_KEY_CONFIRMATION_V1";
19pub const FINAL_KEY_CONFIRMATION_SCHEMA_VERSION: u16 = 1;
20
21/// Ceremony phase associated with a portal envelope.
22#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23pub enum CeremonyPhase {
24    /// DKG round one broadcast.
25    Round1,
26    /// Roster-wide round one set attestation.
27    Round1SetAttestation,
28    /// DKG round two pairwise exchange.
29    Round2,
30    /// Final DKG confirmation.
31    Finalize,
32    /// Root artifact signing.
33    RootSigning,
34}
35
36/// Bounded payload type carried by a portal envelope.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38pub enum CeremonyPayloadKind {
39    /// DKG round one public package.
40    Round1Package,
41    /// Signed statement binding the full round one set.
42    Round1SetAttestation,
43    /// Recipient-bound encrypted DKG round two package.
44    Round2EncryptedPackage,
45    /// Rejected DKG round two raw package.
46    Round2PlaintextPackage,
47    /// Final key confirmation package.
48    FinalKeyConfirmation,
49    /// Root signing nonce commitment.
50    RootSigningCommitment,
51    /// Root signing share.
52    RootSignatureShare,
53}
54
55/// Signed, bounded, untrusted relay envelope.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CeremonyEnvelope {
58    /// Ceremony identifier.
59    pub ceremony_id: String,
60    /// Ceremony phase.
61    pub phase: CeremonyPhase,
62    /// Payload type.
63    pub payload_kind: CeremonyPayloadKind,
64    /// Rostered sender DID.
65    pub sender_did: Did,
66    /// Optional rostered recipient DID.
67    pub recipient_did: Option<Did>,
68    /// Monotonic sender sequence.
69    pub sequence: u64,
70    /// Bounded opaque payload.
71    pub payload_bytes: Vec<u8>,
72    /// Canonical payload hash.
73    pub payload_hash: Hash256,
74    /// Ed25519 signature by the sender.
75    pub signature: Signature,
76}
77
78/// Inputs that are signed into a portal relay envelope.
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CeremonyEnvelopeDraft {
81    /// Ceremony identifier.
82    pub ceremony_id: String,
83    /// Ceremony phase.
84    pub phase: CeremonyPhase,
85    /// Payload type.
86    pub payload_kind: CeremonyPayloadKind,
87    /// Rostered sender DID.
88    pub sender_did: Did,
89    /// Optional rostered recipient DID.
90    pub recipient_did: Option<Did>,
91    /// Monotonic sender sequence.
92    pub sequence: u64,
93    /// Bounded opaque payload.
94    pub payload_bytes: Vec<u8>,
95}
96
97/// Ratified final DKG key confirmation payload.
98#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct FinalKeyConfirmation {
100    /// Domain separator; must equal [`crate::FINAL_KEY_CONFIRMATION_DOMAIN`].
101    pub domain: String,
102    /// Schema version; must equal [`crate::FINAL_KEY_CONFIRMATION_SCHEMA_VERSION`].
103    pub schema_version: u16,
104    /// Ceremony identifier.
105    pub ceremony_id: String,
106    /// Confirming certifier DID.
107    pub certifier_did: Did,
108    /// Confirming certifier FROST identifier.
109    pub frost_identifier: u16,
110    /// Canonical hash of the ceremony config.
111    pub config_hash: Hash256,
112    /// Canonical hash of the completed DKG relay transcript.
113    pub dkg_transcript_hash: Hash256,
114    /// Public key package independently derived by the certifier.
115    pub public_key_package: RootPublicKeyPackage,
116    /// Canonical hash of the full public key package.
117    pub root_public_key_package_hash: Hash256,
118    /// Hash of `public_key_package.root_public_key`.
119    pub root_public_key_hash: Hash256,
120    /// Hash of this certifier's verifying share in the public key package.
121    pub certifier_verifying_share_hash: Hash256,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
125struct PortalEnvelopeKey {
126    sender_did: Did,
127    phase: CeremonyPhase,
128    payload_kind: CeremonyPayloadKind,
129    sequence: u64,
130    recipient_did: Option<Did>,
131}
132
133/// In-memory portal store used by the server relay and tests.
134#[derive(Debug, Clone)]
135pub struct PortalStore {
136    config: GenesisCeremonyConfig,
137    envelopes: BTreeMap<PortalEnvelopeKey, CeremonyEnvelope>,
138    seen_sequences: BTreeSet<(Did, u64)>,
139    final_key_confirmations: BTreeMap<Did, FinalKeyConfirmation>,
140    /// Signers who have already submitted a root signature share this session.
141    /// Enforces one share per signer (single-use of the signer's nonces).
142    signature_share_senders: BTreeSet<Did>,
143}
144
145#[derive(Serialize)]
146struct PayloadHashEnvelope<'a> {
147    domain: &'static str,
148    payload_kind: CeremonyPayloadKind,
149    payload_bytes: &'a [u8],
150}
151
152#[derive(Serialize)]
153struct EnvelopeSigningPayload<'a> {
154    domain: &'static str,
155    ceremony_id: &'a str,
156    phase: CeremonyPhase,
157    payload_kind: CeremonyPayloadKind,
158    sender_did: &'a Did,
159    recipient_did: &'a Option<Did>,
160    sequence: u64,
161    payload_hash: Hash256,
162}
163
164#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
165struct TranscriptEnvelopeRecord {
166    phase: CeremonyPhase,
167    payload_kind: CeremonyPayloadKind,
168    sender_did: Did,
169    recipient_did: Option<Did>,
170    sequence: u64,
171    envelope_id: Hash256,
172    envelope_hash: Hash256,
173}
174
175#[derive(Serialize)]
176struct DkgTranscriptPayload<'a> {
177    domain: &'static str,
178    config_hash: Hash256,
179    envelopes: &'a [TranscriptEnvelopeRecord],
180}
181
182#[derive(Serialize)]
183struct FinalTranscriptPayload<'a> {
184    domain: &'static str,
185    config_hash: Hash256,
186    dkg_transcript_hash: Hash256,
187    final_key_confirmations: &'a [TranscriptEnvelopeRecord],
188}
189
190fn payload_hash(kind: CeremonyPayloadKind, payload_bytes: &[u8]) -> Result<Hash256> {
191    hash_structured(&PayloadHashEnvelope {
192        domain: "EXOCHAIN_ROOT_PORTAL_PAYLOAD_V1",
193        payload_kind: kind,
194        payload_bytes,
195    })
196    .map_err(canonical_encoding_error)
197}
198
199/// Canonical hash of a root genesis ceremony config.
200pub fn ceremony_config_hash(config: &GenesisCeremonyConfig) -> Result<Hash256> {
201    hash_structured(config).map_err(canonical_encoding_error)
202}
203
204/// Encode a ratified final key confirmation as portal payload bytes.
205pub fn encode_final_key_confirmation_payload(
206    confirmation: &FinalKeyConfirmation,
207) -> Result<Vec<u8>> {
208    let mut bytes = Vec::new();
209    ciborium::into_writer(confirmation, &mut bytes).map_err(canonical_encoding_error)?;
210    Ok(bytes)
211}
212
213fn decode_final_key_confirmation_payload(bytes: &[u8]) -> Result<FinalKeyConfirmation> {
214    ciborium::from_reader(bytes).map_err(|error| RootError::PortalRejected {
215        reason: format!("final key confirmation payload failed schema validation: {error}"),
216    })
217}
218
219fn certifier_verifying_share_hash(
220    public_key_package: &RootPublicKeyPackage,
221    frost_identifier_value: u16,
222    missing_error: RootError,
223) -> Result<Hash256> {
224    let verifying_share = public_key_package
225        .verifying_shares
226        .get(&frost_identifier_value)
227        .ok_or(missing_error)?;
228    Ok(Hash256::digest(verifying_share.as_slice()))
229}
230
231/// Build the ratified final key confirmation payload for one finalized
232/// certifier. This emits only public confirmation material; the secret FROST key
233/// package is parsed locally to bind the certifier identifier but is never copied
234/// into the payload.
235pub fn build_final_key_confirmation(
236    config: &GenesisCeremonyConfig,
237    dkg_output: &RootParticipantDkgOutput,
238    dkg_transcript_hash: Hash256,
239) -> Result<FinalKeyConfirmation> {
240    config.validate()?;
241    validate_public_key_package(config, &dkg_output.public_key_package)?;
242    let frost_identifier_value = dkg_output.key_package.frost_identifier;
243    let certifier = config
244        .certifier_by_identifier(frost_identifier_value)
245        .ok_or_else(|| RootError::InvalidConfig {
246            reason: format!(
247                "final key confirmation certifier {frost_identifier_value} is not rostered"
248            ),
249        })?;
250    let parsed_key_package: frost::keys::KeyPackage =
251        deserialize_frost(dkg_output.key_package.key_package.as_slice())?;
252    if *parsed_key_package.identifier() != frost_identifier(frost_identifier_value)? {
253        return Err(RootError::Frost {
254            detail: "final key confirmation key package identifier mismatch".to_owned(),
255        });
256    }
257    let certifier_verifying_share_hash = certifier_verifying_share_hash(
258        &dkg_output.public_key_package,
259        frost_identifier_value,
260        RootError::BundleRejected {
261            reason: format!(
262                "public key package missing verifying share for certifier {frost_identifier_value}"
263            ),
264        },
265    )?;
266    Ok(FinalKeyConfirmation {
267        domain: FINAL_KEY_CONFIRMATION_DOMAIN.to_owned(),
268        schema_version: FINAL_KEY_CONFIRMATION_SCHEMA_VERSION,
269        ceremony_id: config.ceremony_id.clone(),
270        certifier_did: certifier.did.clone(),
271        frost_identifier: frost_identifier_value,
272        config_hash: ceremony_config_hash(config)?,
273        dkg_transcript_hash,
274        public_key_package: dkg_output.public_key_package.clone(),
275        root_public_key_package_hash: hash_structured(&dkg_output.public_key_package)
276            .map_err(canonical_encoding_error)?,
277        root_public_key_hash: Hash256::digest(
278            dkg_output.public_key_package.root_public_key.as_slice(),
279        ),
280        certifier_verifying_share_hash,
281    })
282}
283
284fn signing_payload(envelope: &CeremonyEnvelope) -> Result<Vec<u8>> {
285    let payload = EnvelopeSigningPayload {
286        domain: "EXOCHAIN_ROOT_PORTAL_ENVELOPE_V1",
287        ceremony_id: &envelope.ceremony_id,
288        phase: envelope.phase,
289        payload_kind: envelope.payload_kind,
290        sender_did: &envelope.sender_did,
291        recipient_did: &envelope.recipient_did,
292        sequence: envelope.sequence,
293        payload_hash: envelope.payload_hash,
294    };
295    let mut bytes = Vec::new();
296    ciborium::into_writer(&payload, &mut bytes).map_err(canonical_encoding_error)?;
297    Ok(bytes)
298}
299
300fn canonical_encoding_error(error: impl core::fmt::Display) -> RootError {
301    RootError::CanonicalEncoding {
302        detail: error.to_string(),
303    }
304}
305
306impl CeremonyEnvelope {
307    /// Create and sign a portal relay envelope.
308    pub fn sign(draft: CeremonyEnvelopeDraft, signing_secret: &SecretKey) -> Result<Self> {
309        let mut envelope = Self {
310            ceremony_id: draft.ceremony_id,
311            phase: draft.phase,
312            payload_kind: draft.payload_kind,
313            sender_did: draft.sender_did,
314            recipient_did: draft.recipient_did,
315            sequence: draft.sequence,
316            payload_hash: payload_hash(draft.payload_kind, draft.payload_bytes.as_slice())?,
317            payload_bytes: draft.payload_bytes,
318            signature: Signature::Empty,
319        };
320        let payload = signing_payload(&envelope)?;
321        envelope.signature = crypto::sign(payload.as_slice(), signing_secret);
322        Ok(envelope)
323    }
324}
325
326impl PortalStore {
327    /// Construct an empty portal store for a ceremony.
328    #[must_use]
329    pub fn new(config: GenesisCeremonyConfig) -> Self {
330        Self {
331            config,
332            envelopes: BTreeMap::new(),
333            seen_sequences: BTreeSet::new(),
334            final_key_confirmations: BTreeMap::new(),
335            signature_share_senders: BTreeSet::new(),
336        }
337    }
338
339    /// Number of accepted relay envelopes.
340    #[must_use]
341    pub fn envelope_count(&self) -> usize {
342        self.envelopes.len()
343    }
344
345    /// Return accepted envelopes matching all of the supplied filters; a `None`
346    /// filter matches any value. Envelopes are relay data — already signed and
347    /// (for round two) encrypted — so returning them to rostered participants is
348    /// the read half of the relay, used to collect round-one packages, pull
349    /// recipient-bound round-two packages, and gather signing commitments/shares.
350    #[must_use]
351    pub fn query(
352        &self,
353        phase: Option<CeremonyPhase>,
354        payload_kind: Option<CeremonyPayloadKind>,
355        recipient_did: Option<&Did>,
356    ) -> Vec<CeremonyEnvelope> {
357        self.envelopes
358            .values()
359            .filter(|envelope| phase.is_none_or(|value| envelope.phase == value))
360            .filter(|envelope| payload_kind.is_none_or(|value| envelope.payload_kind == value))
361            .filter(|envelope| {
362                recipient_did.is_none_or(|value| envelope.recipient_did.as_ref() == Some(value))
363            })
364            .cloned()
365            .collect()
366    }
367
368    /// Submit a signed envelope to the relay.
369    pub fn submit(&mut self, envelope: CeremonyEnvelope) -> Result<Hash256> {
370        self.validate_envelope(&envelope)?;
371        let final_key_confirmation =
372            if envelope.payload_kind == CeremonyPayloadKind::FinalKeyConfirmation {
373                Some(self.validate_final_key_confirmation(&envelope)?)
374            } else {
375                None
376            };
377        let sequence_key = (envelope.sender_did.clone(), envelope.sequence);
378        if self.seen_sequences.contains(&sequence_key) {
379            return Err(RootError::PortalRejected {
380                reason: "sender sequence replay".to_owned(),
381            });
382        }
383        // One root signature share per signer per session: a second share from the
384        // same signer is rejected, enforcing single-use of that signer's nonces.
385        if envelope.payload_kind == CeremonyPayloadKind::RootSignatureShare
386            && self.signature_share_senders.contains(&envelope.sender_did)
387        {
388            return Err(RootError::PortalRejected {
389                reason: "signer has already submitted a signature share this session".to_owned(),
390            });
391        }
392        let key = PortalEnvelopeKey {
393            sender_did: envelope.sender_did.clone(),
394            phase: envelope.phase,
395            payload_kind: envelope.payload_kind,
396            sequence: envelope.sequence,
397            recipient_did: envelope.recipient_did.clone(),
398        };
399        let envelope_id = hash_structured(&key_parts(&key)).map_err(canonical_encoding_error)?;
400        self.seen_sequences.insert(sequence_key);
401        if envelope.payload_kind == CeremonyPayloadKind::RootSignatureShare {
402            self.signature_share_senders
403                .insert(envelope.sender_did.clone());
404        }
405        if let Some(confirmation) = final_key_confirmation {
406            self.final_key_confirmations
407                .insert(envelope.sender_did.clone(), confirmation);
408        }
409        self.envelopes.insert(key, envelope);
410        Ok(envelope_id)
411    }
412
413    /// Canonical hash over the complete accepted DKG relay transcript.
414    pub fn dkg_transcript_hash(&self) -> Result<Hash256> {
415        let records = self.dkg_transcript_records()?;
416        self.ensure_dkg_transcript_complete(records.as_slice())?;
417        let payload = DkgTranscriptPayload {
418            domain: "EXOCHAIN_ROOT_DKG_TRANSCRIPT_V1",
419            config_hash: ceremony_config_hash(&self.config)?,
420            envelopes: records.as_slice(),
421        };
422        hash_structured(&payload).map_err(canonical_encoding_error)
423    }
424
425    /// Canonical final ceremony transcript hash, including all accepted final
426    /// key confirmation envelopes. This is the transcript hash root artifacts
427    /// must bind before root signing begins.
428    pub fn final_transcript_hash(&self) -> Result<Hash256> {
429        self.ensure_final_key_confirmations_complete()?;
430        let records = self.final_key_confirmation_records()?;
431        let payload = FinalTranscriptPayload {
432            domain: "EXOCHAIN_ROOT_FINAL_TRANSCRIPT_V1",
433            config_hash: ceremony_config_hash(&self.config)?,
434            dkg_transcript_hash: self.dkg_transcript_hash()?,
435            final_key_confirmations: records.as_slice(),
436        };
437        hash_structured(&payload).map_err(canonical_encoding_error)
438    }
439
440    fn validate_envelope(&self, envelope: &CeremonyEnvelope) -> Result<()> {
441        self.config.validate()?;
442        if envelope.ceremony_id != self.config.ceremony_id {
443            return Err(RootError::PortalRejected {
444                reason: "ceremony_id mismatch".to_owned(),
445            });
446        }
447        if envelope.payload_bytes.len() > MAX_PORTAL_PAYLOAD_BYTES {
448            return Err(RootError::PortalRejected {
449                reason: "payload exceeds portal limit".to_owned(),
450            });
451        }
452        if envelope.payload_hash
453            != payload_hash(envelope.payload_kind, envelope.payload_bytes.as_slice())?
454        {
455            return Err(RootError::PortalRejected {
456                reason: "payload hash mismatch".to_owned(),
457            });
458        }
459        self.validate_phase_policy(envelope)?;
460
461        let sender = self
462            .config
463            .certifier_by_did(&envelope.sender_did)
464            .ok_or_else(|| RootError::PortalRejected {
465                reason: "sender is not rostered".to_owned(),
466            })?;
467        if let Some(recipient) = &envelope.recipient_did {
468            if self.config.certifier_by_did(recipient).is_none() {
469                return Err(RootError::PortalRejected {
470                    reason: "recipient is not rostered".to_owned(),
471                });
472            }
473            if recipient == &envelope.sender_did {
474                return Err(RootError::PortalRejected {
475                    reason: "sender cannot target itself".to_owned(),
476                });
477            }
478        }
479
480        let payload = signing_payload(envelope)?;
481        if !crypto::verify(
482            payload.as_slice(),
483            &envelope.signature,
484            &sender.signing_public_key,
485        ) {
486            return Err(RootError::SignatureRejected {
487                reason: "certifier envelope signature rejected".to_owned(),
488            });
489        }
490        Ok(())
491    }
492
493    fn validate_phase_policy(&self, envelope: &CeremonyEnvelope) -> Result<()> {
494        // Every accepted payload kind is schema-validated by decoding it to its
495        // concrete type before storage — the portal never stores opaque bytes for
496        // a security-sensitive kind. Kinds without a ratified, decodable schema
497        // (round-one set attestation) are disabled.
498        let bytes = envelope.payload_bytes.as_slice();
499        match (envelope.phase, envelope.payload_kind) {
500            (CeremonyPhase::Round1, CeremonyPayloadKind::Round1Package) => {
501                reject_recipient(envelope)?;
502                self.reject_dkg_mutation_after_final_confirmation()?;
503                self.reject_duplicate_broadcast_sender(
504                    envelope,
505                    CeremonyPhase::Round1,
506                    CeremonyPayloadKind::Round1Package,
507                    "round-one package already submitted by sender",
508                )?;
509                reject_unless_decodable::<frost::keys::dkg::round1::Package>(
510                    bytes,
511                    "round-one package",
512                )
513            }
514            (CeremonyPhase::RootSigning, CeremonyPayloadKind::RootSigningCommitment) => {
515                reject_recipient(envelope)?;
516                self.ensure_final_key_confirmations_complete()?;
517                self.reject_duplicate_broadcast_sender(
518                    envelope,
519                    CeremonyPhase::RootSigning,
520                    CeremonyPayloadKind::RootSigningCommitment,
521                    "root signing commitment already submitted by sender",
522                )?;
523                reject_unless_decodable::<frost::round1::SigningCommitments>(
524                    bytes,
525                    "root signing commitment",
526                )
527            }
528            (CeremonyPhase::RootSigning, CeremonyPayloadKind::RootSignatureShare) => {
529                reject_recipient(envelope)?;
530                self.ensure_final_key_confirmations_complete()?;
531                reject_unless_decodable::<frost::round2::SignatureShare>(
532                    bytes,
533                    "root signature share",
534                )
535            }
536            (CeremonyPhase::Round2, CeremonyPayloadKind::Round2EncryptedPackage) => {
537                if envelope.recipient_did.is_none() {
538                    return Err(RootError::PortalRejected {
539                        reason: "round-two encrypted package requires recipient".to_owned(),
540                    });
541                }
542                self.reject_dkg_mutation_after_final_confirmation()?;
543                self.reject_duplicate_pairwise_sender_recipient(
544                    envelope,
545                    CeremonyPhase::Round2,
546                    CeremonyPayloadKind::Round2EncryptedPackage,
547                    "round-two encrypted package already submitted for sender and recipient",
548                )?;
549                validate_encrypted_round2_payload(bytes)
550            }
551            (CeremonyPhase::Round1SetAttestation, CeremonyPayloadKind::Round1SetAttestation) => {
552                Err(RootError::PortalRejected {
553                    reason: "round-one set attestation is disabled pending a ratified, \
554                             portal-validated payload schema"
555                        .to_owned(),
556                })
557            }
558            (CeremonyPhase::Finalize, CeremonyPayloadKind::FinalKeyConfirmation) => {
559                reject_recipient(envelope)?;
560                self.validate_final_key_confirmation(envelope).map(|_| ())
561            }
562            (_, CeremonyPayloadKind::Round2PlaintextPackage) => Err(RootError::PortalRejected {
563                reason: "round-two raw package is rejected".to_owned(),
564            }),
565            _ => Err(RootError::PortalRejected {
566                reason: "payload kind is not valid for phase".to_owned(),
567            }),
568        }
569    }
570
571    fn reject_dkg_mutation_after_final_confirmation(&self) -> Result<()> {
572        if !self.final_key_confirmations.is_empty() {
573            return Err(RootError::PortalRejected {
574                reason: "dkg transcript is frozen after final key confirmation".to_owned(),
575            });
576        }
577        Ok(())
578    }
579
580    fn reject_duplicate_broadcast_sender(
581        &self,
582        envelope: &CeremonyEnvelope,
583        phase: CeremonyPhase,
584        payload_kind: CeremonyPayloadKind,
585        reason: &str,
586    ) -> Result<()> {
587        if self.envelopes.values().any(|accepted| {
588            accepted.sender_did == envelope.sender_did
589                && accepted.phase == phase
590                && accepted.payload_kind == payload_kind
591                && accepted.recipient_did.is_none()
592                && accepted.sequence != envelope.sequence
593        }) {
594            return Err(RootError::PortalRejected {
595                reason: reason.to_owned(),
596            });
597        }
598        Ok(())
599    }
600
601    fn reject_duplicate_pairwise_sender_recipient(
602        &self,
603        envelope: &CeremonyEnvelope,
604        phase: CeremonyPhase,
605        payload_kind: CeremonyPayloadKind,
606        reason: &str,
607    ) -> Result<()> {
608        if self.envelopes.values().any(|accepted| {
609            accepted.sender_did == envelope.sender_did
610                && accepted.recipient_did == envelope.recipient_did
611                && accepted.phase == phase
612                && accepted.payload_kind == payload_kind
613                && accepted.sequence != envelope.sequence
614        }) {
615            return Err(RootError::PortalRejected {
616                reason: reason.to_owned(),
617            });
618        }
619        Ok(())
620    }
621
622    fn validate_final_key_confirmation(
623        &self,
624        envelope: &CeremonyEnvelope,
625    ) -> Result<FinalKeyConfirmation> {
626        if self
627            .final_key_confirmations
628            .contains_key(&envelope.sender_did)
629        {
630            return Err(RootError::PortalRejected {
631                reason: "final key confirmation already submitted by sender".to_owned(),
632            });
633        }
634        let confirmation =
635            decode_final_key_confirmation_payload(envelope.payload_bytes.as_slice())?;
636        self.validate_final_key_confirmation_semantics(envelope, &confirmation)?;
637        for accepted in self.final_key_confirmations.values() {
638            if accepted.config_hash != confirmation.config_hash {
639                return Err(RootError::PortalRejected {
640                    reason: "final key confirmation config hash disagrees with accepted set"
641                        .to_owned(),
642                });
643            }
644            if accepted.dkg_transcript_hash != confirmation.dkg_transcript_hash {
645                return Err(RootError::PortalRejected {
646                    reason:
647                        "final key confirmation DKG transcript hash disagrees with accepted set"
648                            .to_owned(),
649                });
650            }
651            if accepted.public_key_package != confirmation.public_key_package {
652                return Err(RootError::PortalRejected {
653                    reason: "final key confirmation public key package disagrees with accepted set"
654                        .to_owned(),
655                });
656            }
657            if accepted.root_public_key_package_hash != confirmation.root_public_key_package_hash {
658                return Err(RootError::PortalRejected {
659                    reason:
660                        "final key confirmation public key package hash disagrees with accepted set"
661                            .to_owned(),
662                });
663            }
664            if accepted.root_public_key_hash != confirmation.root_public_key_hash {
665                return Err(RootError::PortalRejected {
666                    reason: "final key confirmation root key hash disagrees with accepted set"
667                        .to_owned(),
668                });
669            }
670        }
671        Ok(confirmation)
672    }
673
674    fn validate_final_key_confirmation_semantics(
675        &self,
676        envelope: &CeremonyEnvelope,
677        confirmation: &FinalKeyConfirmation,
678    ) -> Result<()> {
679        if confirmation.domain != FINAL_KEY_CONFIRMATION_DOMAIN {
680            return Err(RootError::PortalRejected {
681                reason: "final key confirmation domain mismatch".to_owned(),
682            });
683        }
684        if confirmation.schema_version != FINAL_KEY_CONFIRMATION_SCHEMA_VERSION {
685            return Err(RootError::PortalRejected {
686                reason: "final key confirmation schema version mismatch".to_owned(),
687            });
688        }
689        if confirmation.ceremony_id != self.config.ceremony_id {
690            return Err(RootError::PortalRejected {
691                reason: "final key confirmation ceremony_id mismatch".to_owned(),
692            });
693        }
694        if confirmation.certifier_did != envelope.sender_did {
695            return Err(RootError::PortalRejected {
696                reason: "final key confirmation certifier_did must match envelope sender"
697                    .to_owned(),
698            });
699        }
700        let certifier = self
701            .config
702            .certifier_by_did(&confirmation.certifier_did)
703            .ok_or_else(|| RootError::PortalRejected {
704                reason: "final key confirmation certifier is not rostered".to_owned(),
705            })?;
706        if certifier.frost_identifier != confirmation.frost_identifier {
707            return Err(RootError::PortalRejected {
708                reason: "final key confirmation DID and FROST identifier mismatch".to_owned(),
709            });
710        }
711        let expected_config_hash = ceremony_config_hash(&self.config)?;
712        if confirmation.config_hash != expected_config_hash {
713            return Err(RootError::PortalRejected {
714                reason: "final key confirmation config hash mismatch".to_owned(),
715            });
716        }
717        let expected_dkg_transcript_hash = self.dkg_transcript_hash()?;
718        if confirmation.dkg_transcript_hash != expected_dkg_transcript_hash {
719            return Err(RootError::PortalRejected {
720                reason: "final key confirmation DKG transcript hash mismatch".to_owned(),
721            });
722        }
723        validate_public_key_package(&self.config, &confirmation.public_key_package)?;
724        let expected_package_hash =
725            hash_structured(&confirmation.public_key_package).map_err(canonical_encoding_error)?;
726        if confirmation.root_public_key_package_hash != expected_package_hash {
727            return Err(RootError::PortalRejected {
728                reason: "final key confirmation public key package hash mismatch".to_owned(),
729            });
730        }
731        let expected_root_public_key_hash =
732            Hash256::digest(confirmation.public_key_package.root_public_key.as_slice());
733        if confirmation.root_public_key_hash != expected_root_public_key_hash {
734            return Err(RootError::PortalRejected {
735                reason: "final key confirmation root public key hash mismatch".to_owned(),
736            });
737        }
738        let certifier_verifying_share_hash = certifier_verifying_share_hash(
739            &confirmation.public_key_package,
740            confirmation.frost_identifier,
741            RootError::PortalRejected {
742                reason: "final key confirmation verifying share is missing".to_owned(),
743            },
744        )?;
745        if confirmation.certifier_verifying_share_hash != certifier_verifying_share_hash {
746            return Err(RootError::PortalRejected {
747                reason: "final key confirmation verifying share hash mismatch".to_owned(),
748            });
749        }
750        Ok(())
751    }
752
753    fn dkg_transcript_records(&self) -> Result<Vec<TranscriptEnvelopeRecord>> {
754        let mut records = Vec::new();
755        for (key, envelope) in &self.envelopes {
756            if matches!(
757                (envelope.phase, envelope.payload_kind),
758                (CeremonyPhase::Round1, CeremonyPayloadKind::Round1Package)
759                    | (
760                        CeremonyPhase::Round2,
761                        CeremonyPayloadKind::Round2EncryptedPackage
762                    )
763            ) {
764                records.push(transcript_record(key, envelope)?);
765            }
766        }
767        records.sort();
768        Ok(records)
769    }
770
771    fn final_key_confirmation_records(&self) -> Result<Vec<TranscriptEnvelopeRecord>> {
772        let mut records = Vec::new();
773        for (key, envelope) in &self.envelopes {
774            if envelope.phase == CeremonyPhase::Finalize
775                && envelope.payload_kind == CeremonyPayloadKind::FinalKeyConfirmation
776            {
777                records.push(transcript_record(key, envelope)?);
778            }
779        }
780        records.sort();
781        Ok(records)
782    }
783
784    fn ensure_dkg_transcript_complete(&self, records: &[TranscriptEnvelopeRecord]) -> Result<()> {
785        let expected_certifiers: BTreeSet<Did> = self
786            .config
787            .certifiers
788            .iter()
789            .map(|certifier| certifier.did.clone())
790            .collect();
791        let mut round1_senders = BTreeSet::new();
792        let mut round2_pairs = BTreeSet::new();
793        for record in records {
794            match (record.phase, record.payload_kind) {
795                (CeremonyPhase::Round1, CeremonyPayloadKind::Round1Package) => {
796                    if record.recipient_did.is_some() {
797                        return Err(RootError::PortalRejected {
798                            reason: "dkg transcript round-one record has a recipient".to_owned(),
799                        });
800                    }
801                    if !round1_senders.insert(record.sender_did.clone()) {
802                        return Err(RootError::PortalRejected {
803                            reason: "dkg transcript contains duplicate round-one sender".to_owned(),
804                        });
805                    }
806                }
807                (CeremonyPhase::Round2, CeremonyPayloadKind::Round2EncryptedPackage) => {
808                    let recipient =
809                        record
810                            .recipient_did
811                            .clone()
812                            .ok_or_else(|| RootError::PortalRejected {
813                                reason: "dkg transcript round-two record missing recipient"
814                                    .to_owned(),
815                            })?;
816                    if !round2_pairs.insert((record.sender_did.clone(), recipient)) {
817                        return Err(RootError::PortalRejected {
818                            reason:
819                                "dkg transcript contains duplicate round-two sender-recipient pair"
820                                    .to_owned(),
821                        });
822                    }
823                }
824                _ => {
825                    return Err(RootError::PortalRejected {
826                        reason: "dkg transcript contains non-DKG envelope".to_owned(),
827                    });
828                }
829            }
830        }
831        if round1_senders != expected_certifiers {
832            return Err(RootError::PortalRejected {
833                reason: "dkg transcript requires one round-one package from every certifier"
834                    .to_owned(),
835            });
836        }
837        let expected_round2 = usize::from(self.config.max_signers)
838            * usize::from(self.config.max_signers.saturating_sub(1));
839        if round2_pairs.len() != expected_round2 {
840            return Err(RootError::PortalRejected {
841                reason: "dkg transcript requires every ordered round-two sender-recipient package"
842                    .to_owned(),
843            });
844        }
845        for sender in &expected_certifiers {
846            for recipient in &expected_certifiers {
847                if sender == recipient {
848                    continue;
849                }
850                if !round2_pairs.contains(&(sender.clone(), recipient.clone())) {
851                    return Err(RootError::PortalRejected {
852                        reason: "dkg transcript missing round-two sender-recipient package"
853                            .to_owned(),
854                    });
855                }
856            }
857        }
858        Ok(())
859    }
860
861    fn ensure_final_key_confirmations_complete(&self) -> Result<()> {
862        if self.final_key_confirmations.len() != usize::from(self.config.max_signers) {
863            return Err(RootError::PortalRejected {
864                reason: "root signing requires final key confirmations from all certifiers"
865                    .to_owned(),
866            });
867        }
868        for certifier in &self.config.certifiers {
869            if !self.final_key_confirmations.contains_key(&certifier.did) {
870                return Err(RootError::PortalRejected {
871                    reason: "root signing missing a certifier final key confirmation".to_owned(),
872                });
873            }
874        }
875        Ok(())
876    }
877}
878
879/// Reject a broadcast payload that carries a recipient.
880fn reject_recipient(envelope: &CeremonyEnvelope) -> Result<()> {
881    if envelope.recipient_did.is_some() {
882        return Err(RootError::PortalRejected {
883            reason: "broadcast payload must not set recipient".to_owned(),
884        });
885    }
886    Ok(())
887}
888
889/// Schema-validate a payload by decoding it to its concrete type `T`; a decode
890/// failure is a portal rejection (bad request), never silent storage.
891fn reject_unless_decodable<T: serde::de::DeserializeOwned>(bytes: &[u8], kind: &str) -> Result<()> {
892    ciborium::from_reader::<T, _>(bytes)
893        .map(|_decoded| ())
894        .map_err(|error| RootError::PortalRejected {
895            reason: format!("{kind} payload failed schema validation: {error}"),
896        })
897}
898
899fn validate_encrypted_round2_payload(payload_bytes: &[u8]) -> Result<()> {
900    let encrypted: PairwiseEncryptedPayload =
901        ciborium::from_reader(payload_bytes).map_err(|error| RootError::PortalRejected {
902            reason: format!("round-two encrypted package is malformed: {error}"),
903        })?;
904    if encrypted.ciphertext.is_empty() {
905        return Err(RootError::PortalRejected {
906            reason: "round-two encrypted package ciphertext must not be empty".to_owned(),
907        });
908    }
909    Ok(())
910}
911
912#[derive(Serialize)]
913struct PortalEnvelopeKeyParts<'a> {
914    sender_did: &'a Did,
915    phase: CeremonyPhase,
916    payload_kind: CeremonyPayloadKind,
917    sequence: u64,
918    recipient_did: &'a Option<Did>,
919}
920
921fn key_parts(key: &PortalEnvelopeKey) -> PortalEnvelopeKeyParts<'_> {
922    PortalEnvelopeKeyParts {
923        sender_did: &key.sender_did,
924        phase: key.phase,
925        payload_kind: key.payload_kind,
926        sequence: key.sequence,
927        recipient_did: &key.recipient_did,
928    }
929}
930
931fn transcript_record(
932    key: &PortalEnvelopeKey,
933    envelope: &CeremonyEnvelope,
934) -> Result<TranscriptEnvelopeRecord> {
935    Ok(TranscriptEnvelopeRecord {
936        phase: envelope.phase,
937        payload_kind: envelope.payload_kind,
938        sender_did: envelope.sender_did.clone(),
939        recipient_did: envelope.recipient_did.clone(),
940        sequence: envelope.sequence,
941        envelope_id: hash_structured(&key_parts(key)).map_err(canonical_encoding_error)?,
942        envelope_hash: hash_structured(envelope).map_err(canonical_encoding_error)?,
943    })
944}
945
946#[cfg(test)]
947#[allow(clippy::expect_used, clippy::unwrap_used)]
948mod tests {
949    use exo_core::{Timestamp, crypto::KeyPair};
950    use rand::{SeedableRng, rngs::StdRng};
951
952    use super::*;
953    use crate::{CertifierContact, PairwiseEncryptedPayload};
954
955    fn round1_package_bytes(config: &GenesisCeremonyConfig, frost_identifier: u16) -> Vec<u8> {
956        let mut rng = StdRng::seed_from_u64(u64::from(frost_identifier));
957        crate::dkg_round1(config, frost_identifier, &mut rng)
958            .expect("round one")
959            .round1_package
960    }
961
962    fn certifier(index: u16) -> (CertifierContact, exo_core::SecretKey) {
963        let seed = [u8::try_from(index).expect("index fits"); 32];
964        let keypair = KeyPair::from_secret_bytes(seed).expect("keypair");
965        let transport_public =
966            x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(seed));
967        (
968            CertifierContact {
969                did: Did::new(&format!("did:exo:portal-query-{index:02}")).expect("did"),
970                frost_identifier: index,
971                signing_public_key: *keypair.public_key(),
972                transport_public_key: *transport_public.as_bytes(),
973            },
974            keypair.secret_key().clone(),
975        )
976    }
977
978    fn config_with_secrets() -> (GenesisCeremonyConfig, Vec<SecretKey>) {
979        let mut certifiers = Vec::new();
980        let mut secrets = Vec::new();
981        for index in 1..=crate::ROOT_GENESIS_SIGNERS {
982            let (contact, secret) = certifier(index);
983            certifiers.push(contact);
984            secrets.push(secret);
985        }
986        (
987            GenesisCeremonyConfig {
988                ceremony_id: "portal-query".into(),
989                network_id: "exochain-test".into(),
990                repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".into(),
991                constitution_hash: Hash256::digest(b"constitution"),
992                threshold: crate::ROOT_GENESIS_THRESHOLD,
993                max_signers: crate::ROOT_GENESIS_SIGNERS,
994                created_at: Timestamp::new(1, 0),
995                certifiers,
996                signing_set: (1..=7).collect(),
997            },
998            secrets,
999        )
1000    }
1001
1002    fn encrypted_payload_bytes() -> Vec<u8> {
1003        let payload = PairwiseEncryptedPayload {
1004            nonce: [9u8; 24],
1005            ciphertext: b"ciphertext".to_vec(),
1006        };
1007        let mut bytes = Vec::new();
1008        ciborium::into_writer(&payload, &mut bytes).expect("encode");
1009        bytes
1010    }
1011
1012    fn encrypted_payload_with(ciphertext: impl Into<Vec<u8>>) -> Vec<u8> {
1013        let payload = PairwiseEncryptedPayload {
1014            nonce: [9u8; 24],
1015            ciphertext: ciphertext.into(),
1016        };
1017        let mut bytes = Vec::new();
1018        ciborium::into_writer(&payload, &mut bytes).expect("encode");
1019        bytes
1020    }
1021
1022    #[allow(clippy::too_many_arguments)]
1023    fn sign_envelope(
1024        config: &GenesisCeremonyConfig,
1025        secrets: &[SecretKey],
1026        sender_identifier: u16,
1027        phase: CeremonyPhase,
1028        payload_kind: CeremonyPayloadKind,
1029        recipient_identifier: Option<u16>,
1030        sequence: u64,
1031        payload_bytes: Vec<u8>,
1032    ) -> CeremonyEnvelope {
1033        let sender_index = usize::from(sender_identifier - 1);
1034        let recipient_did = recipient_identifier
1035            .map(|identifier| config.certifiers[usize::from(identifier - 1)].did.clone());
1036        CeremonyEnvelope::sign(
1037            CeremonyEnvelopeDraft {
1038                ceremony_id: config.ceremony_id.clone(),
1039                phase,
1040                payload_kind,
1041                sender_did: config.certifiers[sender_index].did.clone(),
1042                recipient_did,
1043                sequence,
1044                payload_bytes,
1045            },
1046            &secrets[sender_index],
1047        )
1048        .expect("signed envelope")
1049    }
1050
1051    fn participant_output(
1052        dkg: &crate::RootDkgOutput,
1053        identifier: u16,
1054    ) -> crate::RootParticipantDkgOutput {
1055        crate::RootParticipantDkgOutput {
1056            key_package: dkg.key_packages[&identifier].clone(),
1057            public_key_package: dkg.public_key_package.clone(),
1058        }
1059    }
1060
1061    fn submit_complete_dkg_transcript(
1062        store: &mut PortalStore,
1063        config: &GenesisCeremonyConfig,
1064        secrets: &[SecretKey],
1065    ) -> Hash256 {
1066        for certifier in &config.certifiers {
1067            store
1068                .submit(sign_envelope(
1069                    config,
1070                    secrets,
1071                    certifier.frost_identifier,
1072                    CeremonyPhase::Round1,
1073                    CeremonyPayloadKind::Round1Package,
1074                    None,
1075                    10,
1076                    round1_package_bytes(config, certifier.frost_identifier),
1077                ))
1078                .expect("round one submit");
1079        }
1080        for sender in &config.certifiers {
1081            for recipient in &config.certifiers {
1082                if sender.frost_identifier == recipient.frost_identifier {
1083                    continue;
1084                }
1085                store
1086                    .submit(sign_envelope(
1087                        config,
1088                        secrets,
1089                        sender.frost_identifier,
1090                        CeremonyPhase::Round2,
1091                        CeremonyPayloadKind::Round2EncryptedPackage,
1092                        Some(recipient.frost_identifier),
1093                        1_000
1094                            + u64::from(sender.frost_identifier) * 100
1095                            + u64::from(recipient.frost_identifier),
1096                        encrypted_payload_with(format!(
1097                            "round2-{}-{}",
1098                            sender.frost_identifier, recipient.frost_identifier
1099                        )),
1100                    ))
1101                    .expect("round two submit");
1102            }
1103        }
1104        store.dkg_transcript_hash().expect("dkg transcript hash")
1105    }
1106
1107    fn final_key_confirmation(
1108        config: &GenesisCeremonyConfig,
1109        dkg: &crate::RootDkgOutput,
1110        identifier: u16,
1111        dkg_transcript_hash: Hash256,
1112    ) -> FinalKeyConfirmation {
1113        build_final_key_confirmation(
1114            config,
1115            &participant_output(dkg, identifier),
1116            dkg_transcript_hash,
1117        )
1118        .expect("final key confirmation")
1119    }
1120
1121    fn final_key_confirmation_envelope(
1122        config: &GenesisCeremonyConfig,
1123        secrets: &[SecretKey],
1124        identifier: u16,
1125        confirmation: &FinalKeyConfirmation,
1126    ) -> CeremonyEnvelope {
1127        sign_envelope(
1128            config,
1129            secrets,
1130            identifier,
1131            CeremonyPhase::Finalize,
1132            CeremonyPayloadKind::FinalKeyConfirmation,
1133            None,
1134            5_000 + u64::from(identifier),
1135            encode_final_key_confirmation_payload(confirmation).expect("confirmation payload"),
1136        )
1137    }
1138
1139    fn transcript_record_for(
1140        config: &GenesisCeremonyConfig,
1141        phase: CeremonyPhase,
1142        payload_kind: CeremonyPayloadKind,
1143        sender_identifier: u16,
1144        recipient_identifier: Option<u16>,
1145        sequence: u64,
1146    ) -> TranscriptEnvelopeRecord {
1147        let sender_did = if sender_identifier == 0 {
1148            Did::new("did:exo:transcript-outside").expect("outside did")
1149        } else {
1150            config.certifiers[usize::from(sender_identifier - 1)]
1151                .did
1152                .clone()
1153        };
1154        let recipient_did = recipient_identifier.map(|identifier| {
1155            if identifier == 0 {
1156                Did::new("did:exo:transcript-outside-recipient").expect("outside recipient")
1157            } else {
1158                config.certifiers[usize::from(identifier - 1)].did.clone()
1159            }
1160        });
1161        let material = format!("{phase:?}:{payload_kind:?}:{sender_identifier}:{sequence}");
1162        TranscriptEnvelopeRecord {
1163            phase,
1164            payload_kind,
1165            sender_did,
1166            recipient_did,
1167            sequence,
1168            envelope_id: Hash256::digest(material.as_bytes()),
1169            envelope_hash: Hash256::digest(format!("hash:{material}").as_bytes()),
1170        }
1171    }
1172
1173    fn complete_transcript_records(
1174        config: &GenesisCeremonyConfig,
1175    ) -> Vec<TranscriptEnvelopeRecord> {
1176        let mut records = Vec::new();
1177        for certifier in &config.certifiers {
1178            records.push(transcript_record_for(
1179                config,
1180                CeremonyPhase::Round1,
1181                CeremonyPayloadKind::Round1Package,
1182                certifier.frost_identifier,
1183                None,
1184                10,
1185            ));
1186        }
1187        for sender in &config.certifiers {
1188            for recipient in &config.certifiers {
1189                if sender.frost_identifier == recipient.frost_identifier {
1190                    continue;
1191                }
1192                records.push(transcript_record_for(
1193                    config,
1194                    CeremonyPhase::Round2,
1195                    CeremonyPayloadKind::Round2EncryptedPackage,
1196                    sender.frost_identifier,
1197                    Some(recipient.frost_identifier),
1198                    1_000
1199                        + u64::from(sender.frost_identifier) * 100
1200                        + u64::from(recipient.frost_identifier),
1201                ));
1202            }
1203        }
1204        records
1205    }
1206
1207    #[test]
1208    fn canonical_error_conversion_is_diagnostic() {
1209        let error = canonical_encoding_error("portal encoding failed");
1210        assert!(error.to_string().contains("portal encoding failed"));
1211    }
1212
1213    #[test]
1214    fn query_filters_by_phase_kind_and_recipient() {
1215        let (config, secrets) = config_with_secrets();
1216        let mut store = PortalStore::new(config.clone());
1217
1218        // A round-one broadcast from certifier 1.
1219        store
1220            .submit(
1221                CeremonyEnvelope::sign(
1222                    CeremonyEnvelopeDraft {
1223                        ceremony_id: config.ceremony_id.clone(),
1224                        phase: CeremonyPhase::Round1,
1225                        payload_kind: CeremonyPayloadKind::Round1Package,
1226                        sender_did: config.certifiers[0].did.clone(),
1227                        recipient_did: None,
1228                        sequence: 0,
1229                        payload_bytes: round1_package_bytes(&config, 1),
1230                    },
1231                    &secrets[0],
1232                )
1233                .expect("round1 envelope"),
1234            )
1235            .expect("submit round1");
1236
1237        // A round-two package from certifier 1 addressed to certifier 2.
1238        store
1239            .submit(
1240                CeremonyEnvelope::sign(
1241                    CeremonyEnvelopeDraft {
1242                        ceremony_id: config.ceremony_id.clone(),
1243                        phase: CeremonyPhase::Round2,
1244                        payload_kind: CeremonyPayloadKind::Round2EncryptedPackage,
1245                        sender_did: config.certifiers[0].did.clone(),
1246                        recipient_did: Some(config.certifiers[1].did.clone()),
1247                        sequence: 1,
1248                        payload_bytes: encrypted_payload_bytes(),
1249                    },
1250                    &secrets[0],
1251                )
1252                .expect("round2 envelope"),
1253            )
1254            .expect("submit round2");
1255
1256        // No filters → both envelopes.
1257        assert_eq!(store.query(None, None, None).len(), 2);
1258        // Phase filter (match + non-match).
1259        assert_eq!(
1260            store.query(Some(CeremonyPhase::Round1), None, None).len(),
1261            1
1262        );
1263        assert_eq!(
1264            store.query(Some(CeremonyPhase::Finalize), None, None).len(),
1265            0
1266        );
1267        // Payload-kind filter.
1268        assert_eq!(
1269            store
1270                .query(
1271                    None,
1272                    Some(CeremonyPayloadKind::Round2EncryptedPackage),
1273                    None
1274                )
1275                .len(),
1276            1
1277        );
1278        // Recipient filter (match + non-match).
1279        assert_eq!(
1280            store
1281                .query(
1282                    Some(CeremonyPhase::Round2),
1283                    None,
1284                    Some(&config.certifiers[1].did)
1285                )
1286                .len(),
1287            1
1288        );
1289        assert_eq!(
1290            store
1291                .query(None, None, Some(&config.certifiers[2].did))
1292                .len(),
1293            0
1294        );
1295    }
1296
1297    #[test]
1298    fn final_key_confirmation_builder_rejects_misbound_key_material() {
1299        let (config, _) = config_with_secrets();
1300        let mut rng = StdRng::seed_from_u64(7_001);
1301        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1302        let dkg_transcript_hash = Hash256::digest(b"dkg transcript");
1303
1304        let mut unrostered = participant_output(&dkg, 1);
1305        unrostered.key_package.frost_identifier = 99;
1306        assert!(
1307            build_final_key_confirmation(&config, &unrostered, dkg_transcript_hash).is_err(),
1308            "builder must reject a certifier id outside the ratified roster"
1309        );
1310
1311        let mut mismatched = participant_output(&dkg, 1);
1312        mismatched.key_package.key_package = dkg.key_packages[&2].key_package.clone();
1313        assert!(
1314            build_final_key_confirmation(&config, &mismatched, dkg_transcript_hash).is_err(),
1315            "builder must bind the public confirmation to the certifier key package"
1316        );
1317
1318        let mut missing_share = participant_output(&dkg, 1);
1319        missing_share.public_key_package.verifying_shares.remove(&1);
1320        assert!(
1321            build_final_key_confirmation(&config, &missing_share, dkg_transcript_hash).is_err(),
1322            "builder must reject public key package metadata that omits a rostered share"
1323        );
1324        let missing_share_error = certifier_verifying_share_hash(
1325            &missing_share.public_key_package,
1326            1,
1327            RootError::PortalRejected {
1328                reason: "unit missing share".to_owned(),
1329            },
1330        )
1331        .expect_err("missing share helper must fail closed");
1332        assert!(
1333            missing_share_error
1334                .to_string()
1335                .contains("unit missing share")
1336        );
1337    }
1338
1339    #[test]
1340    fn duplicate_dkg_replacements_are_rejected() {
1341        let (config, secrets) = config_with_secrets();
1342        let mut store = PortalStore::new(config.clone());
1343        store
1344            .submit(sign_envelope(
1345                &config,
1346                &secrets,
1347                1,
1348                CeremonyPhase::Round1,
1349                CeremonyPayloadKind::Round1Package,
1350                None,
1351                1,
1352                round1_package_bytes(&config, 1),
1353            ))
1354            .expect("first round-one");
1355        assert!(
1356            store
1357                .submit(sign_envelope(
1358                    &config,
1359                    &secrets,
1360                    1,
1361                    CeremonyPhase::Round1,
1362                    CeremonyPayloadKind::Round1Package,
1363                    None,
1364                    2,
1365                    round1_package_bytes(&config, 1),
1366                ))
1367                .is_err(),
1368            "a sender cannot replace a broadcast DKG package after acceptance"
1369        );
1370
1371        store
1372            .submit(sign_envelope(
1373                &config,
1374                &secrets,
1375                1,
1376                CeremonyPhase::Round2,
1377                CeremonyPayloadKind::Round2EncryptedPackage,
1378                Some(2),
1379                101,
1380                encrypted_payload_with(b"one"),
1381            ))
1382            .expect("first round-two");
1383        assert!(
1384            store
1385                .submit(sign_envelope(
1386                    &config,
1387                    &secrets,
1388                    1,
1389                    CeremonyPhase::Round2,
1390                    CeremonyPayloadKind::Round2EncryptedPackage,
1391                    Some(2),
1392                    102,
1393                    encrypted_payload_with(b"two"),
1394                ))
1395                .is_err(),
1396            "a sender cannot replace a pairwise DKG package after acceptance"
1397        );
1398    }
1399
1400    #[test]
1401    fn final_key_confirmation_semantics_reject_every_bound_field() {
1402        let (config, secrets) = config_with_secrets();
1403        let mut rng = StdRng::seed_from_u64(7_002);
1404        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1405        let mut store = PortalStore::new(config.clone());
1406        let dkg_transcript_hash = submit_complete_dkg_transcript(&mut store, &config, &secrets);
1407        let valid = final_key_confirmation(&config, &dkg, 1, dkg_transcript_hash);
1408        let envelope = final_key_confirmation_envelope(&config, &secrets, 1, &valid);
1409
1410        let mut bad = valid.clone();
1411        bad.domain = "wrong-domain".to_owned();
1412        assert!(
1413            store
1414                .validate_final_key_confirmation_semantics(&envelope, &bad)
1415                .is_err()
1416        );
1417
1418        let mut bad = valid.clone();
1419        bad.schema_version = FINAL_KEY_CONFIRMATION_SCHEMA_VERSION + 1;
1420        assert!(
1421            store
1422                .validate_final_key_confirmation_semantics(&envelope, &bad)
1423                .is_err()
1424        );
1425
1426        let mut bad = valid.clone();
1427        bad.ceremony_id = "wrong-ceremony".to_owned();
1428        assert!(
1429            store
1430                .validate_final_key_confirmation_semantics(&envelope, &bad)
1431                .is_err()
1432        );
1433
1434        let mut bad_envelope = envelope.clone();
1435        bad_envelope.sender_did = Did::new("did:exo:not-rostered").expect("outside did");
1436        let mut bad = valid.clone();
1437        bad.certifier_did = bad_envelope.sender_did.clone();
1438        assert!(
1439            store
1440                .validate_final_key_confirmation_semantics(&bad_envelope, &bad)
1441                .is_err()
1442        );
1443
1444        let mut bad = valid.clone();
1445        bad.frost_identifier = 2;
1446        assert!(
1447            store
1448                .validate_final_key_confirmation_semantics(&envelope, &bad)
1449                .is_err()
1450        );
1451
1452        let mut bad = valid.clone();
1453        bad.config_hash = Hash256::digest(b"wrong config hash");
1454        assert!(
1455            store
1456                .validate_final_key_confirmation_semantics(&envelope, &bad)
1457                .is_err()
1458        );
1459
1460        let mut bad = valid.clone();
1461        bad.dkg_transcript_hash = Hash256::digest(b"wrong dkg transcript");
1462        assert!(
1463            store
1464                .validate_final_key_confirmation_semantics(&envelope, &bad)
1465                .is_err()
1466        );
1467
1468        let mut bad = valid.clone();
1469        bad.root_public_key_package_hash = Hash256::digest(b"wrong public package");
1470        assert!(
1471            store
1472                .validate_final_key_confirmation_semantics(&envelope, &bad)
1473                .is_err()
1474        );
1475
1476        let mut bad = valid.clone();
1477        bad.root_public_key_hash = Hash256::digest(b"wrong root key");
1478        assert!(
1479            store
1480                .validate_final_key_confirmation_semantics(&envelope, &bad)
1481                .is_err()
1482        );
1483
1484        let mut bad = valid;
1485        bad.certifier_verifying_share_hash = Hash256::digest(b"wrong verifying share");
1486        assert!(
1487            store
1488                .validate_final_key_confirmation_semantics(&envelope, &bad)
1489                .is_err()
1490        );
1491    }
1492
1493    #[test]
1494    fn final_key_confirmation_rejects_accepted_set_drift() {
1495        let (config, secrets) = config_with_secrets();
1496        let mut rng = StdRng::seed_from_u64(7_003);
1497        let dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1498        let mut store = PortalStore::new(config.clone());
1499        let dkg_transcript_hash = submit_complete_dkg_transcript(&mut store, &config, &secrets);
1500        let valid_one = final_key_confirmation(&config, &dkg, 1, dkg_transcript_hash);
1501        let valid_two = final_key_confirmation(&config, &dkg, 2, dkg_transcript_hash);
1502        let envelope_two = final_key_confirmation_envelope(&config, &secrets, 2, &valid_two);
1503        let transcript_store = store.clone();
1504
1505        for mutation in 0..5 {
1506            let mut store = transcript_store.clone();
1507            let mut accepted = valid_one.clone();
1508            if mutation == 0 {
1509                accepted.config_hash = Hash256::digest(b"accepted config drift");
1510            } else if mutation == 1 {
1511                accepted.dkg_transcript_hash = Hash256::digest(b"accepted transcript drift");
1512            } else if mutation == 2 {
1513                accepted.public_key_package.root_public_key = b"accepted package drift".to_vec();
1514            } else if mutation == 3 {
1515                accepted.root_public_key_package_hash =
1516                    Hash256::digest(b"accepted package hash drift");
1517            } else {
1518                accepted.root_public_key_hash = Hash256::digest(b"accepted root drift");
1519            }
1520            store
1521                .final_key_confirmations
1522                .insert(valid_one.certifier_did.clone(), accepted);
1523            assert!(
1524                store
1525                    .validate_final_key_confirmation(&envelope_two)
1526                    .is_err(),
1527                "accepted-set drift case {mutation} must be rejected"
1528            );
1529        }
1530    }
1531
1532    #[test]
1533    fn dkg_transcript_completion_reports_malformed_shapes() {
1534        let (config, _) = config_with_secrets();
1535        let store = PortalStore::new(config.clone());
1536        let complete = complete_transcript_records(&config);
1537
1538        let mut round1_with_recipient = complete.clone();
1539        round1_with_recipient[0].recipient_did = Some(config.certifiers[1].did.clone());
1540        assert!(
1541            store
1542                .ensure_dkg_transcript_complete(&round1_with_recipient)
1543                .is_err()
1544        );
1545
1546        let mut duplicate_round1 = complete.clone();
1547        duplicate_round1[1].sender_did = duplicate_round1[0].sender_did.clone();
1548        assert!(
1549            store
1550                .ensure_dkg_transcript_complete(&duplicate_round1)
1551                .is_err()
1552        );
1553
1554        let round2_start = usize::from(config.max_signers);
1555        let mut round2_missing_recipient = complete.clone();
1556        round2_missing_recipient[round2_start].recipient_did = None;
1557        assert!(
1558            store
1559                .ensure_dkg_transcript_complete(&round2_missing_recipient)
1560                .is_err()
1561        );
1562
1563        let mut duplicate_round2 = complete.clone();
1564        duplicate_round2[round2_start + 1].sender_did =
1565            duplicate_round2[round2_start].sender_did.clone();
1566        duplicate_round2[round2_start + 1].recipient_did =
1567            duplicate_round2[round2_start].recipient_did.clone();
1568        assert!(
1569            store
1570                .ensure_dkg_transcript_complete(&duplicate_round2)
1571                .is_err()
1572        );
1573
1574        let mut non_dkg = complete.clone();
1575        non_dkg[0].phase = CeremonyPhase::Finalize;
1576        assert!(store.ensure_dkg_transcript_complete(&non_dkg).is_err());
1577
1578        let mut missing_round1 = complete.clone();
1579        missing_round1.remove(0);
1580        assert!(
1581            store
1582                .ensure_dkg_transcript_complete(&missing_round1)
1583                .is_err()
1584        );
1585
1586        let round1_only = complete[..round2_start].to_vec();
1587        assert!(store.ensure_dkg_transcript_complete(&round1_only).is_err());
1588
1589        let mut missing_specific_pair = complete;
1590        let removed = missing_specific_pair.remove(round2_start);
1591        assert_eq!(removed.sender_did, config.certifiers[0].did);
1592        missing_specific_pair.push(transcript_record_for(
1593            &config,
1594            CeremonyPhase::Round2,
1595            CeremonyPayloadKind::Round2EncryptedPackage,
1596            0,
1597            Some(2),
1598            9_999,
1599        ));
1600        assert!(
1601            store
1602                .ensure_dkg_transcript_complete(&missing_specific_pair)
1603                .is_err()
1604        );
1605    }
1606
1607    #[test]
1608    fn root_signing_completion_rejects_missing_rostered_confirmation() {
1609        let (config, _) = config_with_secrets();
1610        let mut store = PortalStore::new(config.clone());
1611        for certifier in &config.certifiers[1..] {
1612            let confirmation = FinalKeyConfirmation {
1613                domain: FINAL_KEY_CONFIRMATION_DOMAIN.to_owned(),
1614                schema_version: FINAL_KEY_CONFIRMATION_SCHEMA_VERSION,
1615                ceremony_id: config.ceremony_id.clone(),
1616                certifier_did: certifier.did.clone(),
1617                frost_identifier: certifier.frost_identifier,
1618                config_hash: Hash256::digest(b"config"),
1619                dkg_transcript_hash: Hash256::digest(b"dkg"),
1620                public_key_package: RootPublicKeyPackage {
1621                    public_key_package: Vec::new(),
1622                    root_public_key: Vec::new(),
1623                    verifying_shares: BTreeMap::new(),
1624                },
1625                root_public_key_package_hash: Hash256::digest(b"package"),
1626                root_public_key_hash: Hash256::digest(b"root"),
1627                certifier_verifying_share_hash: Hash256::digest(b"share"),
1628            };
1629            store
1630                .final_key_confirmations
1631                .insert(certifier.did.clone(), confirmation);
1632        }
1633        let outside = Did::new("did:exo:outside-confirmation").expect("outside did");
1634        let mut outside_confirmation = store
1635            .final_key_confirmations
1636            .values()
1637            .next()
1638            .expect("seed confirmation")
1639            .clone();
1640        outside_confirmation.certifier_did = outside.clone();
1641        store
1642            .final_key_confirmations
1643            .insert(outside, outside_confirmation);
1644        assert!(store.ensure_final_key_confirmations_complete().is_err());
1645    }
1646
1647    #[test]
1648    fn encrypted_round2_payload_validation_rejects_bad_shapes() {
1649        assert!(validate_encrypted_round2_payload(b"not cbor").is_err());
1650        assert!(validate_encrypted_round2_payload(&encrypted_payload_with(Vec::new())).is_err());
1651    }
1652}