1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
23pub enum CeremonyPhase {
24 Round1,
26 Round1SetAttestation,
28 Round2,
30 Finalize,
32 RootSigning,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
38pub enum CeremonyPayloadKind {
39 Round1Package,
41 Round1SetAttestation,
43 Round2EncryptedPackage,
45 Round2PlaintextPackage,
47 FinalKeyConfirmation,
49 RootSigningCommitment,
51 RootSignatureShare,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct CeremonyEnvelope {
58 pub ceremony_id: String,
60 pub phase: CeremonyPhase,
62 pub payload_kind: CeremonyPayloadKind,
64 pub sender_did: Did,
66 pub recipient_did: Option<Did>,
68 pub sequence: u64,
70 pub payload_bytes: Vec<u8>,
72 pub payload_hash: Hash256,
74 pub signature: Signature,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct CeremonyEnvelopeDraft {
81 pub ceremony_id: String,
83 pub phase: CeremonyPhase,
85 pub payload_kind: CeremonyPayloadKind,
87 pub sender_did: Did,
89 pub recipient_did: Option<Did>,
91 pub sequence: u64,
93 pub payload_bytes: Vec<u8>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct FinalKeyConfirmation {
100 pub domain: String,
102 pub schema_version: u16,
104 pub ceremony_id: String,
106 pub certifier_did: Did,
108 pub frost_identifier: u16,
110 pub config_hash: Hash256,
112 pub dkg_transcript_hash: Hash256,
114 pub public_key_package: RootPublicKeyPackage,
116 pub root_public_key_package_hash: Hash256,
118 pub root_public_key_hash: Hash256,
120 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#[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 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
199pub fn ceremony_config_hash(config: &GenesisCeremonyConfig) -> Result<Hash256> {
201 hash_structured(config).map_err(canonical_encoding_error)
202}
203
204pub 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
231pub 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 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 #[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 #[must_use]
341 pub fn envelope_count(&self) -> usize {
342 self.envelopes.len()
343 }
344
345 #[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 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 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 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 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 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
879fn 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
889fn 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 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 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 assert_eq!(store.query(None, None, None).len(), 2);
1260 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 assert_eq!(
1271 store
1272 .query(
1273 None,
1274 Some(CeremonyPayloadKind::Round2EncryptedPackage),
1275 None
1276 )
1277 .len(),
1278 1
1279 );
1280 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}