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