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        let mut output = crate::dkg_round1(config, frost_identifier, &mut rng).expect("round one");
958        std::mem::take(&mut output.round1_package)
959    }
960
961    fn certifier(index: u16) -> (CertifierContact, exo_core::SecretKey) {
962        let seed = [u8::try_from(index).expect("index fits"); 32];
963        let keypair = KeyPair::from_secret_bytes(seed).expect("keypair");
964        let transport_public =
965            x25519_dalek::PublicKey::from(&x25519_dalek::StaticSecret::from(seed));
966        (
967            CertifierContact {
968                did: Did::new(&format!("did:exo:portal-query-{index:02}")).expect("did"),
969                frost_identifier: index,
970                signing_public_key: *keypair.public_key(),
971                transport_public_key: *transport_public.as_bytes(),
972            },
973            keypair.secret_key().clone(),
974        )
975    }
976
977    fn config_with_secrets() -> (GenesisCeremonyConfig, Vec<SecretKey>) {
978        let mut certifiers = Vec::new();
979        let mut secrets = Vec::new();
980        for index in 1..=crate::ROOT_GENESIS_SIGNERS {
981            let (contact, secret) = certifier(index);
982            certifiers.push(contact);
983            secrets.push(secret);
984        }
985        (
986            GenesisCeremonyConfig {
987                ceremony_id: "portal-query".into(),
988                network_id: "exochain-test".into(),
989                repo_commit: "d8927686a34bdc28ba36d53938f665685d2c4c04".into(),
990                constitution_hash: Hash256::digest(b"constitution"),
991                threshold: crate::ROOT_GENESIS_THRESHOLD,
992                max_signers: crate::ROOT_GENESIS_SIGNERS,
993                created_at: Timestamp::new(1, 0),
994                certifiers,
995                signing_set: (1..=7).collect(),
996            },
997            secrets,
998        )
999    }
1000
1001    fn encrypted_payload_bytes() -> Vec<u8> {
1002        let payload = PairwiseEncryptedPayload {
1003            nonce: [9u8; 24],
1004            ciphertext: b"ciphertext".to_vec(),
1005        };
1006        let mut bytes = Vec::new();
1007        ciborium::into_writer(&payload, &mut bytes).expect("encode");
1008        bytes
1009    }
1010
1011    fn encrypted_payload_with(ciphertext: impl Into<Vec<u8>>) -> Vec<u8> {
1012        let payload = PairwiseEncryptedPayload {
1013            nonce: [9u8; 24],
1014            ciphertext: ciphertext.into(),
1015        };
1016        let mut bytes = Vec::new();
1017        ciborium::into_writer(&payload, &mut bytes).expect("encode");
1018        bytes
1019    }
1020
1021    #[allow(clippy::too_many_arguments)]
1022    fn sign_envelope(
1023        config: &GenesisCeremonyConfig,
1024        secrets: &[SecretKey],
1025        sender_identifier: u16,
1026        phase: CeremonyPhase,
1027        payload_kind: CeremonyPayloadKind,
1028        recipient_identifier: Option<u16>,
1029        sequence: u64,
1030        payload_bytes: Vec<u8>,
1031    ) -> CeremonyEnvelope {
1032        let sender_index = usize::from(sender_identifier - 1);
1033        let recipient_did = recipient_identifier
1034            .map(|identifier| config.certifiers[usize::from(identifier - 1)].did.clone());
1035        CeremonyEnvelope::sign(
1036            CeremonyEnvelopeDraft {
1037                ceremony_id: config.ceremony_id.clone(),
1038                phase,
1039                payload_kind,
1040                sender_did: config.certifiers[sender_index].did.clone(),
1041                recipient_did,
1042                sequence,
1043                payload_bytes,
1044            },
1045            &secrets[sender_index],
1046        )
1047        .expect("signed envelope")
1048    }
1049
1050    fn participant_output(
1051        dkg: &mut crate::RootDkgOutput,
1052        identifier: u16,
1053    ) -> crate::RootParticipantDkgOutput {
1054        crate::RootParticipantDkgOutput {
1055            key_package: dkg
1056                .key_packages
1057                .remove(&identifier)
1058                .expect("participant key package"),
1059            public_key_package: dkg.public_key_package.clone(),
1060        }
1061    }
1062
1063    fn submit_complete_dkg_transcript(
1064        store: &mut PortalStore,
1065        config: &GenesisCeremonyConfig,
1066        secrets: &[SecretKey],
1067    ) -> Hash256 {
1068        for certifier in &config.certifiers {
1069            store
1070                .submit(sign_envelope(
1071                    config,
1072                    secrets,
1073                    certifier.frost_identifier,
1074                    CeremonyPhase::Round1,
1075                    CeremonyPayloadKind::Round1Package,
1076                    None,
1077                    10,
1078                    round1_package_bytes(config, certifier.frost_identifier),
1079                ))
1080                .expect("round one submit");
1081        }
1082        for sender in &config.certifiers {
1083            for recipient in &config.certifiers {
1084                if sender.frost_identifier == recipient.frost_identifier {
1085                    continue;
1086                }
1087                store
1088                    .submit(sign_envelope(
1089                        config,
1090                        secrets,
1091                        sender.frost_identifier,
1092                        CeremonyPhase::Round2,
1093                        CeremonyPayloadKind::Round2EncryptedPackage,
1094                        Some(recipient.frost_identifier),
1095                        1_000
1096                            + u64::from(sender.frost_identifier) * 100
1097                            + u64::from(recipient.frost_identifier),
1098                        encrypted_payload_with(format!(
1099                            "round2-{}-{}",
1100                            sender.frost_identifier, recipient.frost_identifier
1101                        )),
1102                    ))
1103                    .expect("round two submit");
1104            }
1105        }
1106        store.dkg_transcript_hash().expect("dkg transcript hash")
1107    }
1108
1109    fn final_key_confirmation(
1110        config: &GenesisCeremonyConfig,
1111        dkg: &mut crate::RootDkgOutput,
1112        identifier: u16,
1113        dkg_transcript_hash: Hash256,
1114    ) -> FinalKeyConfirmation {
1115        build_final_key_confirmation(
1116            config,
1117            &participant_output(dkg, identifier),
1118            dkg_transcript_hash,
1119        )
1120        .expect("final key confirmation")
1121    }
1122
1123    fn final_key_confirmation_envelope(
1124        config: &GenesisCeremonyConfig,
1125        secrets: &[SecretKey],
1126        identifier: u16,
1127        confirmation: &FinalKeyConfirmation,
1128    ) -> CeremonyEnvelope {
1129        sign_envelope(
1130            config,
1131            secrets,
1132            identifier,
1133            CeremonyPhase::Finalize,
1134            CeremonyPayloadKind::FinalKeyConfirmation,
1135            None,
1136            5_000 + u64::from(identifier),
1137            encode_final_key_confirmation_payload(confirmation).expect("confirmation payload"),
1138        )
1139    }
1140
1141    fn transcript_record_for(
1142        config: &GenesisCeremonyConfig,
1143        phase: CeremonyPhase,
1144        payload_kind: CeremonyPayloadKind,
1145        sender_identifier: u16,
1146        recipient_identifier: Option<u16>,
1147        sequence: u64,
1148    ) -> TranscriptEnvelopeRecord {
1149        let sender_did = if sender_identifier == 0 {
1150            Did::new("did:exo:transcript-outside").expect("outside did")
1151        } else {
1152            config.certifiers[usize::from(sender_identifier - 1)]
1153                .did
1154                .clone()
1155        };
1156        let recipient_did = recipient_identifier.map(|identifier| {
1157            if identifier == 0 {
1158                Did::new("did:exo:transcript-outside-recipient").expect("outside recipient")
1159            } else {
1160                config.certifiers[usize::from(identifier - 1)].did.clone()
1161            }
1162        });
1163        let material = format!("{phase:?}:{payload_kind:?}:{sender_identifier}:{sequence}");
1164        TranscriptEnvelopeRecord {
1165            phase,
1166            payload_kind,
1167            sender_did,
1168            recipient_did,
1169            sequence,
1170            envelope_id: Hash256::digest(material.as_bytes()),
1171            envelope_hash: Hash256::digest(format!("hash:{material}").as_bytes()),
1172        }
1173    }
1174
1175    fn complete_transcript_records(
1176        config: &GenesisCeremonyConfig,
1177    ) -> Vec<TranscriptEnvelopeRecord> {
1178        let mut records = Vec::new();
1179        for certifier in &config.certifiers {
1180            records.push(transcript_record_for(
1181                config,
1182                CeremonyPhase::Round1,
1183                CeremonyPayloadKind::Round1Package,
1184                certifier.frost_identifier,
1185                None,
1186                10,
1187            ));
1188        }
1189        for sender in &config.certifiers {
1190            for recipient in &config.certifiers {
1191                if sender.frost_identifier == recipient.frost_identifier {
1192                    continue;
1193                }
1194                records.push(transcript_record_for(
1195                    config,
1196                    CeremonyPhase::Round2,
1197                    CeremonyPayloadKind::Round2EncryptedPackage,
1198                    sender.frost_identifier,
1199                    Some(recipient.frost_identifier),
1200                    1_000
1201                        + u64::from(sender.frost_identifier) * 100
1202                        + u64::from(recipient.frost_identifier),
1203                ));
1204            }
1205        }
1206        records
1207    }
1208
1209    #[test]
1210    fn canonical_error_conversion_is_diagnostic() {
1211        let error = canonical_encoding_error("portal encoding failed");
1212        assert!(error.to_string().contains("portal encoding failed"));
1213    }
1214
1215    #[test]
1216    fn query_filters_by_phase_kind_and_recipient() {
1217        let (config, secrets) = config_with_secrets();
1218        let mut store = PortalStore::new(config.clone());
1219
1220        // A round-one broadcast from certifier 1.
1221        store
1222            .submit(
1223                CeremonyEnvelope::sign(
1224                    CeremonyEnvelopeDraft {
1225                        ceremony_id: config.ceremony_id.clone(),
1226                        phase: CeremonyPhase::Round1,
1227                        payload_kind: CeremonyPayloadKind::Round1Package,
1228                        sender_did: config.certifiers[0].did.clone(),
1229                        recipient_did: None,
1230                        sequence: 0,
1231                        payload_bytes: round1_package_bytes(&config, 1),
1232                    },
1233                    &secrets[0],
1234                )
1235                .expect("round1 envelope"),
1236            )
1237            .expect("submit round1");
1238
1239        // A round-two package from certifier 1 addressed to certifier 2.
1240        store
1241            .submit(
1242                CeremonyEnvelope::sign(
1243                    CeremonyEnvelopeDraft {
1244                        ceremony_id: config.ceremony_id.clone(),
1245                        phase: CeremonyPhase::Round2,
1246                        payload_kind: CeremonyPayloadKind::Round2EncryptedPackage,
1247                        sender_did: config.certifiers[0].did.clone(),
1248                        recipient_did: Some(config.certifiers[1].did.clone()),
1249                        sequence: 1,
1250                        payload_bytes: encrypted_payload_bytes(),
1251                    },
1252                    &secrets[0],
1253                )
1254                .expect("round2 envelope"),
1255            )
1256            .expect("submit round2");
1257
1258        // No filters → both envelopes.
1259        assert_eq!(store.query(None, None, None).len(), 2);
1260        // Phase filter (match + non-match).
1261        assert_eq!(
1262            store.query(Some(CeremonyPhase::Round1), None, None).len(),
1263            1
1264        );
1265        assert_eq!(
1266            store.query(Some(CeremonyPhase::Finalize), None, None).len(),
1267            0
1268        );
1269        // Payload-kind filter.
1270        assert_eq!(
1271            store
1272                .query(
1273                    None,
1274                    Some(CeremonyPayloadKind::Round2EncryptedPackage),
1275                    None
1276                )
1277                .len(),
1278            1
1279        );
1280        // Recipient filter (match + non-match).
1281        assert_eq!(
1282            store
1283                .query(
1284                    Some(CeremonyPhase::Round2),
1285                    None,
1286                    Some(&config.certifiers[1].did)
1287                )
1288                .len(),
1289            1
1290        );
1291        assert_eq!(
1292            store
1293                .query(None, None, Some(&config.certifiers[2].did))
1294                .len(),
1295            0
1296        );
1297    }
1298
1299    #[test]
1300    fn final_key_confirmation_builder_rejects_misbound_key_material() {
1301        let (config, _) = config_with_secrets();
1302        let mut rng = StdRng::seed_from_u64(7_001);
1303        let mut dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1304        let dkg_transcript_hash = Hash256::digest(b"dkg transcript");
1305
1306        let mut unrostered = participant_output(&mut dkg, 1);
1307        unrostered.key_package.frost_identifier = 99;
1308        assert!(
1309            build_final_key_confirmation(&config, &unrostered, dkg_transcript_hash).is_err(),
1310            "builder must reject a certifier id outside the ratified roster"
1311        );
1312
1313        let mut mismatched = participant_output(&mut dkg, 2);
1314        mismatched.key_package.frost_identifier = 1;
1315        assert!(
1316            build_final_key_confirmation(&config, &mismatched, dkg_transcript_hash).is_err(),
1317            "builder must bind the public confirmation to the certifier key package"
1318        );
1319
1320        let mut missing_share = participant_output(&mut dkg, 3);
1321        missing_share.public_key_package.verifying_shares.remove(&3);
1322        assert!(
1323            build_final_key_confirmation(&config, &missing_share, dkg_transcript_hash).is_err(),
1324            "builder must reject public key package metadata that omits a rostered share"
1325        );
1326        let missing_share_error = certifier_verifying_share_hash(
1327            &missing_share.public_key_package,
1328            3,
1329            RootError::PortalRejected {
1330                reason: "unit missing share".to_owned(),
1331            },
1332        )
1333        .expect_err("missing share helper must fail closed");
1334        assert!(
1335            missing_share_error
1336                .to_string()
1337                .contains("unit missing share")
1338        );
1339    }
1340
1341    #[test]
1342    fn duplicate_dkg_replacements_are_rejected() {
1343        let (config, secrets) = config_with_secrets();
1344        let mut store = PortalStore::new(config.clone());
1345        store
1346            .submit(sign_envelope(
1347                &config,
1348                &secrets,
1349                1,
1350                CeremonyPhase::Round1,
1351                CeremonyPayloadKind::Round1Package,
1352                None,
1353                1,
1354                round1_package_bytes(&config, 1),
1355            ))
1356            .expect("first round-one");
1357        assert!(
1358            store
1359                .submit(sign_envelope(
1360                    &config,
1361                    &secrets,
1362                    1,
1363                    CeremonyPhase::Round1,
1364                    CeremonyPayloadKind::Round1Package,
1365                    None,
1366                    2,
1367                    round1_package_bytes(&config, 1),
1368                ))
1369                .is_err(),
1370            "a sender cannot replace a broadcast DKG package after acceptance"
1371        );
1372
1373        store
1374            .submit(sign_envelope(
1375                &config,
1376                &secrets,
1377                1,
1378                CeremonyPhase::Round2,
1379                CeremonyPayloadKind::Round2EncryptedPackage,
1380                Some(2),
1381                101,
1382                encrypted_payload_with(b"one"),
1383            ))
1384            .expect("first round-two");
1385        assert!(
1386            store
1387                .submit(sign_envelope(
1388                    &config,
1389                    &secrets,
1390                    1,
1391                    CeremonyPhase::Round2,
1392                    CeremonyPayloadKind::Round2EncryptedPackage,
1393                    Some(2),
1394                    102,
1395                    encrypted_payload_with(b"two"),
1396                ))
1397                .is_err(),
1398            "a sender cannot replace a pairwise DKG package after acceptance"
1399        );
1400    }
1401
1402    #[test]
1403    fn final_key_confirmation_semantics_reject_every_bound_field() {
1404        let (config, secrets) = config_with_secrets();
1405        let mut rng = StdRng::seed_from_u64(7_002);
1406        let mut dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1407        let mut store = PortalStore::new(config.clone());
1408        let dkg_transcript_hash = submit_complete_dkg_transcript(&mut store, &config, &secrets);
1409        let valid = final_key_confirmation(&config, &mut dkg, 1, dkg_transcript_hash);
1410        let envelope = final_key_confirmation_envelope(&config, &secrets, 1, &valid);
1411
1412        let mut bad = valid.clone();
1413        bad.domain = "wrong-domain".to_owned();
1414        assert!(
1415            store
1416                .validate_final_key_confirmation_semantics(&envelope, &bad)
1417                .is_err()
1418        );
1419
1420        let mut bad = valid.clone();
1421        bad.schema_version = FINAL_KEY_CONFIRMATION_SCHEMA_VERSION + 1;
1422        assert!(
1423            store
1424                .validate_final_key_confirmation_semantics(&envelope, &bad)
1425                .is_err()
1426        );
1427
1428        let mut bad = valid.clone();
1429        bad.ceremony_id = "wrong-ceremony".to_owned();
1430        assert!(
1431            store
1432                .validate_final_key_confirmation_semantics(&envelope, &bad)
1433                .is_err()
1434        );
1435
1436        let mut bad_envelope = envelope.clone();
1437        bad_envelope.sender_did = Did::new("did:exo:not-rostered").expect("outside did");
1438        let mut bad = valid.clone();
1439        bad.certifier_did = bad_envelope.sender_did.clone();
1440        assert!(
1441            store
1442                .validate_final_key_confirmation_semantics(&bad_envelope, &bad)
1443                .is_err()
1444        );
1445
1446        let mut bad = valid.clone();
1447        bad.frost_identifier = 2;
1448        assert!(
1449            store
1450                .validate_final_key_confirmation_semantics(&envelope, &bad)
1451                .is_err()
1452        );
1453
1454        let mut bad = valid.clone();
1455        bad.config_hash = Hash256::digest(b"wrong config hash");
1456        assert!(
1457            store
1458                .validate_final_key_confirmation_semantics(&envelope, &bad)
1459                .is_err()
1460        );
1461
1462        let mut bad = valid.clone();
1463        bad.dkg_transcript_hash = Hash256::digest(b"wrong dkg transcript");
1464        assert!(
1465            store
1466                .validate_final_key_confirmation_semantics(&envelope, &bad)
1467                .is_err()
1468        );
1469
1470        let mut bad = valid.clone();
1471        bad.root_public_key_package_hash = Hash256::digest(b"wrong public package");
1472        assert!(
1473            store
1474                .validate_final_key_confirmation_semantics(&envelope, &bad)
1475                .is_err()
1476        );
1477
1478        let mut bad = valid.clone();
1479        bad.root_public_key_hash = Hash256::digest(b"wrong root key");
1480        assert!(
1481            store
1482                .validate_final_key_confirmation_semantics(&envelope, &bad)
1483                .is_err()
1484        );
1485
1486        let mut bad = valid;
1487        bad.certifier_verifying_share_hash = Hash256::digest(b"wrong verifying share");
1488        assert!(
1489            store
1490                .validate_final_key_confirmation_semantics(&envelope, &bad)
1491                .is_err()
1492        );
1493    }
1494
1495    #[test]
1496    fn final_key_confirmation_rejects_accepted_set_drift() {
1497        let (config, secrets) = config_with_secrets();
1498        let mut rng = StdRng::seed_from_u64(7_003);
1499        let mut dkg = crate::run_complete_dkg(&config, &mut rng).expect("dkg");
1500        let mut store = PortalStore::new(config.clone());
1501        let dkg_transcript_hash = submit_complete_dkg_transcript(&mut store, &config, &secrets);
1502        let valid_one = final_key_confirmation(&config, &mut dkg, 1, dkg_transcript_hash);
1503        let valid_two = final_key_confirmation(&config, &mut dkg, 2, dkg_transcript_hash);
1504        let envelope_two = final_key_confirmation_envelope(&config, &secrets, 2, &valid_two);
1505        let transcript_store = store.clone();
1506
1507        for mutation in 0..5 {
1508            let mut store = transcript_store.clone();
1509            let mut accepted = valid_one.clone();
1510            if mutation == 0 {
1511                accepted.config_hash = Hash256::digest(b"accepted config drift");
1512            } else if mutation == 1 {
1513                accepted.dkg_transcript_hash = Hash256::digest(b"accepted transcript drift");
1514            } else if mutation == 2 {
1515                accepted.public_key_package.root_public_key = b"accepted package drift".to_vec();
1516            } else if mutation == 3 {
1517                accepted.root_public_key_package_hash =
1518                    Hash256::digest(b"accepted package hash drift");
1519            } else {
1520                accepted.root_public_key_hash = Hash256::digest(b"accepted root drift");
1521            }
1522            store
1523                .final_key_confirmations
1524                .insert(valid_one.certifier_did.clone(), accepted);
1525            assert!(
1526                store
1527                    .validate_final_key_confirmation(&envelope_two)
1528                    .is_err(),
1529                "accepted-set drift case {mutation} must be rejected"
1530            );
1531        }
1532    }
1533
1534    #[test]
1535    fn dkg_transcript_completion_reports_malformed_shapes() {
1536        let (config, _) = config_with_secrets();
1537        let store = PortalStore::new(config.clone());
1538        let complete = complete_transcript_records(&config);
1539
1540        let mut round1_with_recipient = complete.clone();
1541        round1_with_recipient[0].recipient_did = Some(config.certifiers[1].did.clone());
1542        assert!(
1543            store
1544                .ensure_dkg_transcript_complete(&round1_with_recipient)
1545                .is_err()
1546        );
1547
1548        let mut duplicate_round1 = complete.clone();
1549        duplicate_round1[1].sender_did = duplicate_round1[0].sender_did.clone();
1550        assert!(
1551            store
1552                .ensure_dkg_transcript_complete(&duplicate_round1)
1553                .is_err()
1554        );
1555
1556        let round2_start = usize::from(config.max_signers);
1557        let mut round2_missing_recipient = complete.clone();
1558        round2_missing_recipient[round2_start].recipient_did = None;
1559        assert!(
1560            store
1561                .ensure_dkg_transcript_complete(&round2_missing_recipient)
1562                .is_err()
1563        );
1564
1565        let mut duplicate_round2 = complete.clone();
1566        duplicate_round2[round2_start + 1].sender_did =
1567            duplicate_round2[round2_start].sender_did.clone();
1568        duplicate_round2[round2_start + 1].recipient_did =
1569            duplicate_round2[round2_start].recipient_did.clone();
1570        assert!(
1571            store
1572                .ensure_dkg_transcript_complete(&duplicate_round2)
1573                .is_err()
1574        );
1575
1576        let mut non_dkg = complete.clone();
1577        non_dkg[0].phase = CeremonyPhase::Finalize;
1578        assert!(store.ensure_dkg_transcript_complete(&non_dkg).is_err());
1579
1580        let mut missing_round1 = complete.clone();
1581        missing_round1.remove(0);
1582        assert!(
1583            store
1584                .ensure_dkg_transcript_complete(&missing_round1)
1585                .is_err()
1586        );
1587
1588        let round1_only = complete[..round2_start].to_vec();
1589        assert!(store.ensure_dkg_transcript_complete(&round1_only).is_err());
1590
1591        let mut missing_specific_pair = complete;
1592        let removed = missing_specific_pair.remove(round2_start);
1593        assert_eq!(removed.sender_did, config.certifiers[0].did);
1594        missing_specific_pair.push(transcript_record_for(
1595            &config,
1596            CeremonyPhase::Round2,
1597            CeremonyPayloadKind::Round2EncryptedPackage,
1598            0,
1599            Some(2),
1600            9_999,
1601        ));
1602        assert!(
1603            store
1604                .ensure_dkg_transcript_complete(&missing_specific_pair)
1605                .is_err()
1606        );
1607    }
1608
1609    #[test]
1610    fn root_signing_completion_rejects_missing_rostered_confirmation() {
1611        let (config, _) = config_with_secrets();
1612        let mut store = PortalStore::new(config.clone());
1613        for certifier in &config.certifiers[1..] {
1614            let confirmation = FinalKeyConfirmation {
1615                domain: FINAL_KEY_CONFIRMATION_DOMAIN.to_owned(),
1616                schema_version: FINAL_KEY_CONFIRMATION_SCHEMA_VERSION,
1617                ceremony_id: config.ceremony_id.clone(),
1618                certifier_did: certifier.did.clone(),
1619                frost_identifier: certifier.frost_identifier,
1620                config_hash: Hash256::digest(b"config"),
1621                dkg_transcript_hash: Hash256::digest(b"dkg"),
1622                public_key_package: RootPublicKeyPackage {
1623                    public_key_package: Vec::new(),
1624                    root_public_key: Vec::new(),
1625                    verifying_shares: BTreeMap::new(),
1626                },
1627                root_public_key_package_hash: Hash256::digest(b"package"),
1628                root_public_key_hash: Hash256::digest(b"root"),
1629                certifier_verifying_share_hash: Hash256::digest(b"share"),
1630            };
1631            store
1632                .final_key_confirmations
1633                .insert(certifier.did.clone(), confirmation);
1634        }
1635        let outside = Did::new("did:exo:outside-confirmation").expect("outside did");
1636        let mut outside_confirmation = store
1637            .final_key_confirmations
1638            .values()
1639            .next()
1640            .expect("seed confirmation")
1641            .clone();
1642        outside_confirmation.certifier_did = outside.clone();
1643        store
1644            .final_key_confirmations
1645            .insert(outside, outside_confirmation);
1646        assert!(store.ensure_final_key_confirmations_complete().is_err());
1647    }
1648
1649    #[test]
1650    fn encrypted_round2_payload_validation_rejects_bad_shapes() {
1651        assert!(validate_encrypted_round2_payload(b"not cbor").is_err());
1652        assert!(validate_encrypted_round2_payload(&encrypted_payload_with(Vec::new())).is_err());
1653    }
1654}