1use std::{collections::BTreeMap, fmt};
18
19use exo_core::{Did, Hash256, PublicKey, SecretKey, Signature, Timestamp, hash::hash_structured};
20use serde::Serialize;
21use thiserror::Error;
22
23use crate::{
24 did::{DidDocument, did_from_public_key},
25 registry::DidRegistry,
26 risk::{RiskAttestation, RiskContext, RiskLevel, assess_risk},
27};
28
29#[derive(Debug, Error)]
30pub enum VerificationCeremonyError {
31 #[error("Ceremony has expired")]
32 Expired,
33 #[error("Ceremony is already finalized")]
34 AlreadyFinalized,
35 #[error("Invalid signature proof")]
36 InvalidSignature,
37 #[error("signature proof message is not the canonical ceremony challenge")]
38 SignatureChallengeMismatch,
39 #[error("signature proof key resolves to {derived_did}, not target ceremony DID {target_did}")]
40 SignatureKeyNotBoundToTargetDid { target_did: Did, derived_did: Did },
41 #[error("signature proof DID derivation failed: {reason}")]
42 SignatureDidDerivation { reason: String },
43 #[error("unverified proof kind cannot be submitted directly: {kind}")]
44 UnverifiedProofKind { kind: &'static str },
45 #[error("target DID is not active in the supplied registry: {target_did}")]
46 TargetDidNotActive { target_did: Did },
47 #[error("registry returned DID document {document_did} for target DID {target_did}")]
48 TargetDidDocumentMismatch { target_did: Did, document_did: Did },
49 #[error("signature proof key is not declared by active target DID document: {target_did}")]
50 SignatureKeyNotDeclaredByTarget { target_did: Did },
51 #[error("Duplicate proof kind: {kind}")]
52 DuplicateProofKind { kind: &'static str },
53 #[error("Insufficient risk score to finalize: {score}")]
54 InsufficientScore { score: u32 },
55 #[error("Invalid attester DID: {0}")]
56 InvalidAttesterDid(String),
57 #[error("signature ceremony challenge encoding failed: {reason}")]
58 SignatureChallengeEncoding { reason: String },
59 #[error("verification ceremony evidence encoding failed: {reason}")]
60 EvidenceEncoding { reason: String },
61 #[error("risk attestation failed: {0}")]
62 RiskAttestation(#[from] crate::error::IdentityError),
63}
64
65pub const VERIFICATION_CEREMONY_EVIDENCE_DOMAIN: &str =
67 "exo.identity.verification_ceremony.evidence.v1";
68
69const VERIFICATION_CEREMONY_EVIDENCE_SCHEMA_VERSION: u16 = 1;
70const VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_DOMAIN: &str =
71 "exo.identity.verification_ceremony.proof_material.v1";
72const VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_SCHEMA_VERSION: u16 = 1;
73pub const VERIFICATION_CEREMONY_SIGNATURE_CHALLENGE_DOMAIN: &str =
74 "exo.identity.verification_ceremony.signature_challenge.v1";
75const VERIFICATION_CEREMONY_SIGNATURE_CHALLENGE_SCHEMA_VERSION: u16 = 1;
76pub const VERIFICATION_CEREMONY_EXPIRY_WINDOW_MS: u64 = 3_600_000;
77
78#[derive(Clone, PartialEq, Eq)]
79pub enum IdentityProof {
80 Signature(Signature, PublicKey, Vec<u8>), Otp(String),
82 WebAuthnAssertion(Vec<u8>),
83 KycToken(String),
84}
85
86impl fmt::Debug for IdentityProof {
87 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
88 match self {
89 Self::Signature(signature, public_key, message) => f
90 .debug_struct("Signature")
91 .field("signature", signature)
92 .field("public_key", public_key)
93 .field("message", &"<redacted>")
94 .field("message_len", &message.len())
95 .finish(),
96 Self::Otp(_) => f.debug_struct("Otp").field("token", &"<redacted>").finish(),
97 Self::WebAuthnAssertion(assertion) => f
98 .debug_struct("WebAuthnAssertion")
99 .field("assertion", &"<redacted>")
100 .field("assertion_len", &assertion.len())
101 .finish(),
102 Self::KycToken(_) => f
103 .debug_struct("KycToken")
104 .field("token", &"<redacted>")
105 .finish(),
106 }
107 }
108}
109
110#[derive(Debug, Clone)]
111pub struct VerificationCeremony {
112 target_did: Did,
113 session_id: String,
114 initiated_at: Timestamp,
115 proofs: Vec<IdentityProof>,
116 finalized: bool,
117}
118
119#[derive(Debug, Serialize)]
120struct VerificationCeremonySignatureChallengePayload<'a> {
121 domain: &'static str,
122 schema_version: u16,
123 target_did: &'a Did,
124 session_id: &'a str,
125 initiated_at: Timestamp,
126}
127
128#[derive(Debug, Serialize)]
129struct VerificationCeremonyEvidencePayload {
130 domain: &'static str,
131 schema_version: u16,
132 target_did: Did,
133 session_id: String,
134 initiated_at: Timestamp,
135 proofs: Vec<VerificationProofEvidencePayload>,
136}
137
138#[derive(Debug, Serialize)]
139struct VerificationProofEvidencePayload {
140 index: u64,
141 kind: &'static str,
142 material_hash: Hash256,
143}
144
145#[derive(Serialize)]
146struct SignatureProofMaterialPayload<'a> {
147 domain: &'static str,
148 schema_version: u16,
149 kind: &'static str,
150 signature: &'a Signature,
151 public_key: &'a PublicKey,
152 message: &'a [u8],
153}
154
155#[derive(Serialize)]
156struct OtpProofMaterialPayload<'a> {
157 domain: &'static str,
158 schema_version: u16,
159 kind: &'static str,
160 token: &'a str,
161}
162
163#[derive(Serialize)]
164struct WebAuthnProofMaterialPayload<'a> {
165 domain: &'static str,
166 schema_version: u16,
167 kind: &'static str,
168 assertion: &'a [u8],
169}
170
171#[derive(Serialize)]
172struct KycProofMaterialPayload<'a> {
173 domain: &'static str,
174 schema_version: u16,
175 kind: &'static str,
176 token: &'a str,
177}
178
179impl VerificationCeremony {
180 pub fn new(target_did: Did, session_id: String, initiated_at: Timestamp) -> Self {
181 Self {
182 target_did,
183 session_id,
184 initiated_at,
185 proofs: Vec::new(),
186 finalized: false,
187 }
188 }
189
190 #[must_use]
191 pub fn target_did(&self) -> &Did {
192 &self.target_did
193 }
194
195 #[must_use]
196 pub fn session_id(&self) -> &str {
197 &self.session_id
198 }
199
200 #[must_use]
201 pub const fn initiated_at(&self) -> Timestamp {
202 self.initiated_at
203 }
204
205 #[must_use]
206 pub fn proof_count(&self) -> usize {
207 self.proofs.len()
208 }
209
210 #[must_use]
211 pub const fn is_finalized(&self) -> bool {
212 self.finalized
213 }
214
215 pub fn signature_challenge(&self) -> Result<Vec<u8>, VerificationCeremonyError> {
216 let payload = VerificationCeremonySignatureChallengePayload {
217 domain: VERIFICATION_CEREMONY_SIGNATURE_CHALLENGE_DOMAIN,
218 schema_version: VERIFICATION_CEREMONY_SIGNATURE_CHALLENGE_SCHEMA_VERSION,
219 target_did: &self.target_did,
220 session_id: &self.session_id,
221 initiated_at: self.initiated_at,
222 };
223 let mut out = Vec::new();
224 ciborium::ser::into_writer(&payload, &mut out).map_err(|e| {
225 VerificationCeremonyError::SignatureChallengeEncoding {
226 reason: e.to_string(),
227 }
228 })?;
229 Ok(out)
230 }
231
232 pub fn submit_proof(
233 &mut self,
234 proof: IdentityProof,
235 now: Timestamp,
236 ) -> Result<(), VerificationCeremonyError> {
237 let proof_kind = proof.kind();
238 self.ensure_can_accept_proof(proof_kind, now)?;
239
240 match &proof {
241 IdentityProof::Signature(signature, public_key, message) => {
242 self.validate_signature_proof(signature, public_key, message)?;
243 }
244 IdentityProof::Otp(_)
245 | IdentityProof::WebAuthnAssertion(_)
246 | IdentityProof::KycToken(_) => {
247 return Err(VerificationCeremonyError::UnverifiedProofKind { kind: proof_kind });
248 }
249 }
250
251 self.proofs.push(proof);
252 Ok(())
253 }
254
255 fn ensure_can_accept_proof(
256 &self,
257 proof_kind: &'static str,
258 now: Timestamp,
259 ) -> Result<(), VerificationCeremonyError> {
260 if self.finalized {
261 return Err(VerificationCeremonyError::AlreadyFinalized);
262 }
263 if ceremony_is_expired(self.initiated_at, now) {
264 return Err(VerificationCeremonyError::Expired);
265 }
266
267 if self
268 .proofs
269 .iter()
270 .any(|existing| existing.kind() == proof_kind)
271 {
272 return Err(VerificationCeremonyError::DuplicateProofKind { kind: proof_kind });
273 }
274
275 Ok(())
276 }
277
278 fn validate_signature_proof(
279 &self,
280 signature: &Signature,
281 public_key: &PublicKey,
282 message: &[u8],
283 ) -> Result<(), VerificationCeremonyError> {
284 let expected_challenge = self.signature_challenge()?;
285 if message != expected_challenge.as_slice() {
286 return Err(VerificationCeremonyError::SignatureChallengeMismatch);
287 }
288
289 let derived_did = did_from_public_key(public_key).map_err(|e| {
290 VerificationCeremonyError::SignatureDidDerivation {
291 reason: e.to_string(),
292 }
293 })?;
294 if derived_did != self.target_did {
295 return Err(VerificationCeremonyError::SignatureKeyNotBoundToTargetDid {
296 target_did: self.target_did.clone(),
297 derived_did,
298 });
299 }
300
301 if !exo_core::crypto::verify(message, signature, public_key) {
302 return Err(VerificationCeremonyError::InvalidSignature);
303 }
304
305 Ok(())
306 }
307
308 pub fn calculate_risk_score(&self) -> u32 {
309 let mut score: u32 = 0;
310 let mut proof_weights: BTreeMap<&str, u32> = BTreeMap::new();
312 proof_weights.insert("Signature", 1000);
313 proof_weights.insert("Otp", 2000);
314 proof_weights.insert("WebAuthnAssertion", 4000);
315 proof_weights.insert("KycToken", 5000);
316
317 let mut seen = std::collections::BTreeSet::new();
318 for proof in &self.proofs {
319 let proof_kind = proof.kind();
320 if !seen.insert(proof_kind) {
321 continue;
322 }
323 let s = proof_weights.get(proof_kind).copied().unwrap_or(0);
324 score = score.saturating_add(s);
325 }
326 score
327 }
328
329 pub fn finalize<R: DidRegistry>(
349 &mut self,
350 registry: &R,
351 now: Timestamp,
352 attester_did: &Did,
353 attester_key: &SecretKey,
354 ) -> Result<RiskAttestation, VerificationCeremonyError> {
355 if self.finalized {
356 return Err(VerificationCeremonyError::AlreadyFinalized);
357 }
358 if ceremony_is_expired(self.initiated_at, now) {
359 return Err(VerificationCeremonyError::Expired);
360 }
361
362 let target_document = registry.resolve(&self.target_did).ok_or_else(|| {
363 VerificationCeremonyError::TargetDidNotActive {
364 target_did: self.target_did.clone(),
365 }
366 })?;
367 self.validate_active_target_document(target_document)?;
368
369 let score = self.calculate_risk_score();
370 if score < 1000 {
371 return Err(VerificationCeremonyError::InsufficientScore { score });
372 }
373
374 if attester_did.as_str().is_empty() {
376 return Err(VerificationCeremonyError::InvalidAttesterDid(
377 "attester DID is empty".to_owned(),
378 ));
379 }
380
381 let level = if score >= 5000 {
382 RiskLevel::Critical
383 } else if score >= 4000 {
384 RiskLevel::High
385 } else if score >= 3000 {
386 RiskLevel::Medium
387 } else if score >= 2000 {
388 RiskLevel::Low
389 } else {
390 RiskLevel::Minimal
391 };
392
393 let evidence = self.canonical_evidence()?;
397
398 const ONE_YEAR_MS: u64 = 31_536_000_000;
400
401 let ctx = RiskContext {
402 attester_did: attester_did.clone(),
403 evidence,
404 now,
405 validity_ms: ONE_YEAR_MS,
406 level,
407 };
408
409 let attestation = assess_risk(&self.target_did, &ctx, attester_key)?;
410
411 self.finalized = true;
414
415 debug_assert!(
420 !matches!(attestation.signature, Signature::Ed25519(bytes) if bytes.iter().all(|b| *b == 0)),
421 "assess_risk produced a zero signature"
422 );
423 debug_assert!(
424 attestation.evidence_hash != [0u8; 32],
425 "assess_risk produced a zero evidence_hash"
426 );
427
428 Ok(attestation)
429 }
430
431 fn validate_active_target_document(
432 &self,
433 target_document: &DidDocument,
434 ) -> Result<(), VerificationCeremonyError> {
435 if target_document.id != self.target_did {
436 return Err(VerificationCeremonyError::TargetDidDocumentMismatch {
437 target_did: self.target_did.clone(),
438 document_did: target_document.id.clone(),
439 });
440 }
441 if target_document.revoked {
442 return Err(VerificationCeremonyError::TargetDidNotActive {
443 target_did: self.target_did.clone(),
444 });
445 }
446
447 for proof in &self.proofs {
448 if let IdentityProof::Signature(_, public_key, _) = proof {
449 let declared = target_document
450 .public_keys
451 .iter()
452 .any(|declared_key| declared_key == public_key);
453 if !declared {
454 return Err(VerificationCeremonyError::SignatureKeyNotDeclaredByTarget {
455 target_did: self.target_did.clone(),
456 });
457 }
458 }
459 }
460
461 Ok(())
462 }
463
464 fn canonical_evidence(&self) -> Result<Vec<u8>, VerificationCeremonyError> {
472 let payload = self.evidence_payload()?;
473 let mut out = Vec::new();
474 ciborium::ser::into_writer(&payload, &mut out).map_err(|e| {
475 VerificationCeremonyError::EvidenceEncoding {
476 reason: e.to_string(),
477 }
478 })?;
479 Ok(out)
480 }
481
482 fn evidence_payload(
483 &self,
484 ) -> Result<VerificationCeremonyEvidencePayload, VerificationCeremonyError> {
485 let mut proofs = Vec::with_capacity(self.proofs.len());
486 for (idx, proof) in self.proofs.iter().enumerate() {
487 let index =
488 u64::try_from(idx).map_err(|e| VerificationCeremonyError::EvidenceEncoding {
489 reason: format!("proof index does not fit u64: {e}"),
490 })?;
491 proofs.push(VerificationProofEvidencePayload {
492 index,
493 kind: proof.kind(),
494 material_hash: proof.material_hash()?,
495 });
496 }
497
498 Ok(VerificationCeremonyEvidencePayload {
499 domain: VERIFICATION_CEREMONY_EVIDENCE_DOMAIN,
500 schema_version: VERIFICATION_CEREMONY_EVIDENCE_SCHEMA_VERSION,
501 target_did: self.target_did.clone(),
502 session_id: self.session_id.clone(),
503 initiated_at: self.initiated_at,
504 proofs,
505 })
506 }
507}
508
509fn ceremony_is_expired(initiated_at: Timestamp, now: Timestamp) -> bool {
510 let Some(expires_at) = initiated_at
511 .physical_ms
512 .checked_add(VERIFICATION_CEREMONY_EXPIRY_WINDOW_MS)
513 else {
514 return true;
515 };
516 now.physical_ms > expires_at
517}
518
519impl IdentityProof {
520 fn kind(&self) -> &'static str {
521 match self {
522 Self::Signature(..) => "Signature",
523 Self::Otp(_) => "Otp",
524 Self::WebAuthnAssertion(_) => "WebAuthnAssertion",
525 Self::KycToken(_) => "KycToken",
526 }
527 }
528
529 fn material_hash(&self) -> Result<Hash256, VerificationCeremonyError> {
530 let hash_result = match self {
531 Self::Signature(signature, public_key, message) => {
532 hash_structured(&SignatureProofMaterialPayload {
533 domain: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_DOMAIN,
534 schema_version: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_SCHEMA_VERSION,
535 kind: self.kind(),
536 signature,
537 public_key,
538 message,
539 })
540 }
541 Self::Otp(token) => hash_structured(&OtpProofMaterialPayload {
542 domain: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_DOMAIN,
543 schema_version: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_SCHEMA_VERSION,
544 kind: self.kind(),
545 token,
546 }),
547 Self::WebAuthnAssertion(assertion) => hash_structured(&WebAuthnProofMaterialPayload {
548 domain: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_DOMAIN,
549 schema_version: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_SCHEMA_VERSION,
550 kind: self.kind(),
551 assertion,
552 }),
553 Self::KycToken(token) => hash_structured(&KycProofMaterialPayload {
554 domain: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_DOMAIN,
555 schema_version: VERIFICATION_CEREMONY_PROOF_MATERIAL_HASH_SCHEMA_VERSION,
556 kind: self.kind(),
557 token,
558 }),
559 };
560
561 hash_result.map_err(|e| VerificationCeremonyError::EvidenceEncoding {
562 reason: e.to_string(),
563 })
564 }
565}
566
567#[cfg(test)]
568mod tests {
569 use exo_core::crypto::{generate_keypair, sign};
570
571 use super::*;
572 use crate::did::did_from_public_key;
573
574 fn make_signature_ceremony(
575 session_id: &str,
576 initiated_at: Timestamp,
577 ) -> (VerificationCeremony, PublicKey, SecretKey) {
578 let (public_key, secret_key) = generate_keypair();
579 let did = did_from_public_key(&public_key).unwrap();
580 (
581 VerificationCeremony::new(did, session_id.to_string(), initiated_at),
582 public_key,
583 secret_key,
584 )
585 }
586
587 fn signature_proof_for(
588 ceremony: &VerificationCeremony,
589 public_key: PublicKey,
590 secret_key: &SecretKey,
591 ) -> IdentityProof {
592 let message = ceremony.signature_challenge().unwrap();
593 let signature = sign(&message, secret_key);
594 IdentityProof::Signature(signature, public_key, message)
595 }
596
597 fn submit_valid_signature(
598 ceremony: &mut VerificationCeremony,
599 public_key: PublicKey,
600 secret_key: &SecretKey,
601 now: Timestamp,
602 ) {
603 let proof = signature_proof_for(ceremony, public_key, secret_key);
604 ceremony.submit_proof(proof, now).unwrap();
605 }
606
607 fn active_registry_for_key(
608 ceremony: &VerificationCeremony,
609 public_key: PublicKey,
610 ) -> crate::registry::LocalDidRegistry {
611 let doc = crate::did::DidDocument {
612 id: ceremony.target_did().clone(),
613 public_keys: vec![public_key],
614 authentication: vec![],
615 verification_methods: vec![],
616 hybrid_verification_methods: vec![],
617 service_endpoints: vec![],
618 created: Timestamp::new(1000, 0),
619 updated: Timestamp::new(1000, 0),
620 revoked: false,
621 };
622 let mut registry = crate::registry::LocalDidRegistry::new();
623 crate::registry::DidRegistry::register(&mut registry, doc).unwrap();
624 registry
625 }
626
627 #[test]
628 fn submit_proof_rejects_signature_over_attacker_chosen_message() {
629 let (public_key, secret_key) = generate_keypair();
630 let did = did_from_public_key(&public_key).unwrap();
631 let mut ceremony = VerificationCeremony::new(
632 did,
633 "sess-forged-message".to_string(),
634 Timestamp::new(1000, 0),
635 );
636 let message = b"attacker-selected-message".to_vec();
637 let signature = sign(&message, &secret_key);
638
639 let result = ceremony.submit_proof(
640 IdentityProof::Signature(signature, public_key, message),
641 Timestamp::new(1010, 0),
642 );
643
644 assert!(
645 result.is_err(),
646 "signature proofs must be bound to the ceremony challenge, not caller-chosen bytes"
647 );
648 assert!(ceremony.proofs.is_empty());
649 }
650
651 #[test]
652 fn submit_proof_rejects_signature_key_unbound_to_target_did() {
653 let (target_public_key, _target_secret_key) = generate_keypair();
654 let target_did = did_from_public_key(&target_public_key).unwrap();
655 let mut ceremony = VerificationCeremony::new(
656 target_did,
657 "sess-wrong-key".to_string(),
658 Timestamp::new(1000, 0),
659 );
660 let (attacker_public_key, attacker_secret_key) = generate_keypair();
661 let message = ceremony.signature_challenge().unwrap();
662 let signature = sign(&message, &attacker_secret_key);
663
664 let result = ceremony.submit_proof(
665 IdentityProof::Signature(signature, attacker_public_key, message),
666 Timestamp::new(1010, 0),
667 );
668
669 assert!(
670 result.is_err(),
671 "signature proofs must prove control of a key bound to target_did"
672 );
673 assert!(matches!(
674 result.unwrap_err(),
675 VerificationCeremonyError::SignatureKeyNotBoundToTargetDid { .. }
676 ));
677 assert!(ceremony.proofs.is_empty());
678 }
679
680 #[test]
681 fn submit_proof_rejects_unverified_external_proof_kinds() {
682 for proof in [
683 IdentityProof::Otp("123456".to_string()),
684 IdentityProof::WebAuthnAssertion(vec![1, 2, 3]),
685 IdentityProof::KycToken("kyc-token".to_string()),
686 ] {
687 let did = Did::new("did:exo:unverified-proof-kind").unwrap();
688 let mut ceremony = VerificationCeremony::new(
689 did,
690 format!("sess-{}", proof.kind()),
691 Timestamp::new(1000, 0),
692 );
693
694 let result = ceremony.submit_proof(proof, Timestamp::new(1010, 0));
695
696 assert!(
697 result.is_err(),
698 "unverified external proof material must fail closed"
699 );
700 assert!(ceremony.proofs.is_empty());
701 }
702 }
703
704 #[test]
705 fn verification_ceremony_proof_vector_is_not_publicly_mutable() {
706 let source = include_str!("verification.rs");
707 let production = source
708 .split("#[cfg(test)]")
709 .next()
710 .expect("production section");
711
712 for forbidden_public_field in [
713 "pub target_did:",
714 "pub session_id:",
715 "pub initiated_at:",
716 "pub proofs:",
717 "pub finalized:",
718 ] {
719 assert!(
720 !production.contains(forbidden_public_field),
721 "ceremony state must not be publicly mutable: {forbidden_public_field}"
722 );
723 }
724 }
725
726 #[test]
727 fn signature_challenge_binds_target_session_and_hlc() {
728 let (ceremony, _public_key, _secret_key) =
729 make_signature_ceremony("sess-bound", Timestamp::new(1000, 7));
730 let same = VerificationCeremony::new(
731 ceremony.target_did().clone(),
732 "sess-bound".to_string(),
733 Timestamp::new(1000, 7),
734 );
735 let different_session = VerificationCeremony::new(
736 ceremony.target_did().clone(),
737 "sess-other".to_string(),
738 Timestamp::new(1000, 7),
739 );
740 let different_hlc = VerificationCeremony::new(
741 ceremony.target_did().clone(),
742 "sess-bound".to_string(),
743 Timestamp::new(1000, 8),
744 );
745 let (other_ceremony, _other_public_key, _other_secret_key) =
746 make_signature_ceremony("sess-bound", Timestamp::new(1000, 7));
747
748 let challenge = ceremony.signature_challenge().unwrap();
749
750 assert_eq!(challenge, same.signature_challenge().unwrap());
751 assert_ne!(challenge, different_session.signature_challenge().unwrap());
752 assert_ne!(challenge, different_hlc.signature_challenge().unwrap());
753 assert_ne!(challenge, other_ceremony.signature_challenge().unwrap());
754 }
755
756 #[test]
757 fn finalize_rejects_signature_key_absent_from_active_registry_document() {
758 let (mut ceremony, public_key, secret_key) =
759 make_signature_ceremony("sess-undeclared-key", Timestamp::new(1000, 0));
760 submit_valid_signature(
761 &mut ceremony,
762 public_key,
763 &secret_key,
764 Timestamp::new(1010, 0),
765 );
766 let (wrong_registry_key, _wrong_secret_key) = generate_keypair();
767 let registry = active_registry_for_key(&ceremony, wrong_registry_key);
768
769 let (_attester_public_key, attester_secret_key) = generate_keypair();
770 let attester_did = Did::new("did:exo:att-undeclared-key").unwrap();
771 let err = ceremony
772 .finalize(
773 ®istry,
774 Timestamp::new(1020, 0),
775 &attester_did,
776 &attester_secret_key,
777 )
778 .unwrap_err();
779
780 assert!(matches!(
781 err,
782 VerificationCeremonyError::SignatureKeyNotDeclaredByTarget { .. }
783 ));
784 assert!(!ceremony.is_finalized());
785 }
786
787 #[test]
788 fn test_ceremony_lifecycle() {
789 let (mut ceremony, public_key, secret_key) =
790 make_signature_ceremony("sess1", Timestamp::new(1000, 0));
791 submit_valid_signature(
792 &mut ceremony,
793 public_key,
794 &secret_key,
795 Timestamp::new(1010, 0),
796 );
797 let (att_pk, att_sk) = generate_keypair();
798 let attester_did = Did::new("did:exo:attester-lifecycle").unwrap();
799 let registry = active_registry_for_key(&ceremony, public_key);
800 let attestation = ceremony
801 .finalize(®istry, Timestamp::new(1020, 0), &attester_did, &att_sk)
802 .unwrap();
803
804 assert_eq!(attestation.level, RiskLevel::Minimal);
805 assert!(ceremony.is_finalized());
806 assert!(crate::risk::verify_attestation(&attestation, &att_pk));
808 assert_ne!(attestation.evidence_hash, [0u8; 32]);
809 assert!(
810 !matches!(attestation.signature, Signature::Ed25519(b) if b.iter().all(|x| *x == 0))
811 );
812 }
813
814 #[test]
815 fn test_finalize_without_sufficient_proofs_fails() {
816 let (public_key, _secret_key) = generate_keypair();
817 let did = did_from_public_key(&public_key).unwrap();
818 let mut ceremony =
819 VerificationCeremony::new(did, "sess2".to_string(), Timestamp::new(1000, 0));
820
821 let (_att_pk, att_sk) = generate_keypair();
822 let attester_did = Did::new("did:exo:attester-bob").unwrap();
823 let registry = active_registry_for_key(&ceremony, public_key);
824 let err = ceremony
825 .finalize(®istry, Timestamp::new(1020, 0), &attester_did, &att_sk)
826 .unwrap_err();
827 assert!(matches!(
828 err,
829 VerificationCeremonyError::InsufficientScore { .. }
830 ));
831 }
832
833 #[test]
834 fn test_risk_score_calculation() {
835 let (mut ceremony, public_key, secret_key) =
836 make_signature_ceremony("sess3", Timestamp::new(1000, 0));
837 submit_valid_signature(
838 &mut ceremony,
839 public_key,
840 &secret_key,
841 Timestamp::new(1010, 0),
842 );
843 assert_eq!(ceremony.calculate_risk_score(), 1000);
844
845 ceremony
846 .proofs
847 .push(IdentityProof::Otp("123456".to_string()));
848 assert_eq!(ceremony.calculate_risk_score(), 3000); }
850
851 #[test]
852 fn identity_proof_debug_redacts_otp_and_kyc_secrets() {
853 let otp = IdentityProof::Otp("super-secret-otp".to_string());
854 let kyc = IdentityProof::KycToken("super-secret-kyc".to_string());
855
856 let otp_debug = format!("{otp:?}");
857 let kyc_debug = format!("{kyc:?}");
858
859 assert!(
860 !otp_debug.contains("super-secret-otp"),
861 "OTP Debug output must redact the token"
862 );
863 assert!(
864 !kyc_debug.contains("super-secret-kyc"),
865 "KYC Debug output must redact the token"
866 );
867 assert!(otp_debug.contains("<redacted>"));
868 assert!(kyc_debug.contains("<redacted>"));
869 }
870
871 #[test]
872 fn identity_proof_debug_redacts_signature_message_and_webauthn_assertion() {
873 let (public_key, secret_key) = generate_keypair();
874 let message = b"super-secret-signature-message".to_vec();
875 let signature = sign(&message, &secret_key);
876 let signature_proof = IdentityProof::Signature(signature, public_key, message);
877 let webauthn = IdentityProof::WebAuthnAssertion(b"super-secret-webauthn".to_vec());
878
879 let signature_debug = format!("{signature_proof:?}");
880 let webauthn_debug = format!("{webauthn:?}");
881
882 assert!(
883 !signature_debug.contains("super-secret-signature-message"),
884 "Signature proof Debug output must redact the signed message"
885 );
886 assert!(
887 !webauthn_debug.contains("super-secret-webauthn"),
888 "WebAuthn proof Debug output must redact the assertion bytes"
889 );
890 assert!(signature_debug.contains("<redacted>"));
891 assert!(signature_debug.contains("message_len"));
892 assert!(webauthn_debug.contains("<redacted>"));
893 assert!(webauthn_debug.contains("assertion_len"));
894 }
895
896 #[test]
897 fn verification_ceremony_debug_uses_redacted_identity_proofs() {
898 let did = match Did::new("did:exo:redacted-proof") {
899 Ok(did) => did,
900 Err(err) => panic!("test DID must be valid: {err}"),
901 };
902 let mut ceremony =
903 VerificationCeremony::new(did, "sess-redacted".to_string(), Timestamp::new(1000, 0));
904 ceremony
905 .proofs
906 .push(IdentityProof::KycToken("ceremony-secret-kyc".to_string()));
907
908 let debug = format!("{ceremony:?}");
909
910 assert!(
911 !debug.contains("ceremony-secret-kyc"),
912 "VerificationCeremony Debug output must not leak nested proof secrets"
913 );
914 assert!(debug.contains("<redacted>"));
915 }
916
917 #[test]
918 fn duplicate_proof_kind_does_not_inflate_risk_score() {
919 let did = Did::new("did:exo:duplicate-score").unwrap();
920 let mut ceremony =
921 VerificationCeremony::new(did, "sess-dup-score".to_string(), Timestamp::new(1000, 0));
922
923 ceremony
924 .proofs
925 .push(IdentityProof::Otp("111111".to_string()));
926 ceremony
927 .proofs
928 .push(IdentityProof::Otp("222222".to_string()));
929
930 assert_eq!(
931 ceremony.calculate_risk_score(),
932 2000,
933 "duplicate proof kinds must count once even if inserted directly"
934 );
935 }
936
937 #[test]
938 fn submit_proof_rejects_duplicate_proof_kind() {
939 let (mut ceremony, public_key, secret_key) =
940 make_signature_ceremony("sess-dup-submit", Timestamp::new(1000, 0));
941 submit_valid_signature(
942 &mut ceremony,
943 public_key,
944 &secret_key,
945 Timestamp::new(1001, 0),
946 );
947 let duplicate = signature_proof_for(&ceremony, public_key, &secret_key);
948 let err = ceremony
949 .submit_proof(duplicate, Timestamp::new(1002, 0))
950 .unwrap_err();
951
952 assert!(matches!(
953 err,
954 VerificationCeremonyError::DuplicateProofKind { kind: "Signature" }
955 ));
956 assert_eq!(ceremony.proof_count(), 1);
957 assert_eq!(ceremony.calculate_risk_score(), 1000);
958 }
959
960 #[test]
961 fn test_expired_ceremony_fails() {
962 let did = Did::new("did:exo:dave").unwrap();
963 let mut ceremony =
964 VerificationCeremony::new(did, "sess4".to_string(), Timestamp::new(1000, 0));
965
966 let err = ceremony
967 .submit_proof(
968 IdentityProof::Otp("123".into()),
969 Timestamp::new(4_000_000, 0),
970 )
971 .unwrap_err();
972 assert!(matches!(err, VerificationCeremonyError::Expired));
973 }
974
975 #[test]
976 fn ceremony_expiry_overflow_fails_closed_without_panic() {
977 let did = Did::new("did:exo:expiry-overflow").unwrap();
978 let mut ceremony = VerificationCeremony::new(
979 did.clone(),
980 "sess-expiry-overflow".to_string(),
981 Timestamp::new(u64::MAX, 0),
982 );
983
984 let submit_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
985 ceremony.submit_proof(
986 IdentityProof::Otp("123456".to_string()),
987 Timestamp::new(u64::MAX, 0),
988 )
989 }));
990 assert!(
991 matches!(submit_result, Ok(Err(VerificationCeremonyError::Expired))),
992 "expiry overflow during submit must fail closed, got {submit_result:?}"
993 );
994 assert!(ceremony.proofs.is_empty());
995
996 let (_att_pk, att_sk) = generate_keypair();
997 let attester_did = Did::new("did:exo:attester-expiry-overflow").unwrap();
998 let registry = LocalDidRegistry::new();
999 let finalize_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1000 ceremony.finalize(
1001 ®istry,
1002 Timestamp::new(u64::MAX, 0),
1003 &attester_did,
1004 &att_sk,
1005 )
1006 }));
1007 assert!(
1008 matches!(finalize_result, Ok(Err(VerificationCeremonyError::Expired))),
1009 "expiry overflow during finalize must fail closed, got {finalize_result:?}"
1010 );
1011 assert!(!ceremony.is_finalized());
1012 }
1013
1014 #[test]
1015 fn test_invalid_signature_proof_rejected() {
1016 let (mut ceremony, public_key, _secret_key) =
1017 make_signature_ceremony("sess5", Timestamp::new(1000, 0));
1018 let (_, sk2) = generate_keypair();
1019 let msg = ceremony.signature_challenge().unwrap();
1020 let bad_sig = sign(&msg, &sk2);
1021
1022 let err = ceremony
1023 .submit_proof(
1024 IdentityProof::Signature(bad_sig, public_key, msg),
1025 Timestamp::new(1010, 0),
1026 )
1027 .unwrap_err();
1028 assert!(matches!(err, VerificationCeremonyError::InvalidSignature));
1029 }
1030
1031 use crate::{
1033 did::DidDocument,
1034 registry::{DidRegistry, LocalDidRegistry, revocation_proof_payload},
1035 };
1036
1037 fn make_did(label: &str) -> Did {
1038 Did::new(&format!("did:exo:{label}")).expect("valid did")
1039 }
1040
1041 fn make_doc(did: Did, pk: exo_core::PublicKey) -> DidDocument {
1042 DidDocument {
1043 id: did,
1044 public_keys: vec![pk],
1045 authentication: vec![],
1046 verification_methods: vec![],
1047 hybrid_verification_methods: vec![],
1048 service_endpoints: vec![],
1049 created: Timestamp::new(1000, 0),
1050 updated: Timestamp::new(1000, 0),
1051 revoked: false,
1052 }
1053 }
1054
1055 #[test]
1056 fn test_integration_full_verification_flow() {
1057 let (pk, sk) = generate_keypair();
1058 let did = did_from_public_key(&pk).unwrap();
1059 let doc = make_doc(did.clone(), pk);
1060
1061 let mut reg = LocalDidRegistry::new();
1062 reg.register(doc).unwrap();
1063
1064 let mut ceremony = VerificationCeremony::new(
1065 did.clone(),
1066 "session_int_1".to_string(),
1067 Timestamp::new(1000, 0),
1068 );
1069
1070 let msg = ceremony.signature_challenge().unwrap();
1072 let sig = sign(&msg, &sk);
1073 ceremony
1074 .submit_proof(
1075 IdentityProof::Signature(sig, pk, msg),
1076 Timestamp::new(1010, 0),
1077 )
1078 .unwrap();
1079
1080 let (att_pk, att_sk) = generate_keypair();
1082 let attester_did = Did::new("did:exo:attester-int1").unwrap();
1083 let attestation = ceremony
1084 .finalize(®, Timestamp::new(1030, 0), &attester_did, &att_sk)
1085 .unwrap();
1086 assert_eq!(attestation.level, RiskLevel::Minimal);
1087 assert!(crate::risk::verify_attestation(&attestation, &att_pk));
1088
1089 let resolved = reg.resolve(&attestation.subject_did).unwrap();
1091 assert_eq!(resolved.id, did);
1092 }
1093
1094 #[test]
1095 fn test_integration_revoked_did_verification_fails() {
1096 let (pk, sk) = generate_keypair();
1098 let did = did_from_public_key(&pk).unwrap();
1099 let doc = make_doc(did.clone(), pk);
1100
1101 let mut reg = LocalDidRegistry::new();
1102 reg.register(doc).unwrap();
1103
1104 let payload = revocation_proof_payload(&did).unwrap();
1106 let proof = crate::did::RevocationProof {
1107 did: did.clone(),
1108 signature: sign(&payload, &sk),
1109 };
1110 reg.revoke(&did, &proof).unwrap();
1111
1112 let resolved = reg.resolve(&did);
1113 assert!(resolved.is_none());
1114
1115 let mut ceremony =
1117 VerificationCeremony::new(did, "session_int_2".to_string(), Timestamp::new(1000, 0));
1118 let msg = ceremony.signature_challenge().unwrap();
1119 let sig = sign(&msg, &sk);
1120 ceremony
1121 .submit_proof(
1122 IdentityProof::Signature(sig, pk, msg),
1123 Timestamp::new(1010, 0),
1124 )
1125 .unwrap();
1126
1127 let (_att_pk, att_sk) = generate_keypair();
1128 let attester_did = Did::new("did:exo:attester-int2").unwrap();
1129 let err = ceremony
1130 .finalize(®, Timestamp::new(1020, 0), &attester_did, &att_sk)
1131 .unwrap_err();
1132 assert!(matches!(
1133 err,
1134 VerificationCeremonyError::TargetDidNotActive { .. }
1135 ));
1136 }
1137
1138 #[test]
1139 fn test_integration_insufficient_proofs() {
1140 let (pk, _) = generate_keypair();
1141 let did = make_did("integration3");
1142 let doc = make_doc(did.clone(), pk);
1143
1144 let mut reg = LocalDidRegistry::new();
1145 reg.register(doc).unwrap();
1146
1147 let mut ceremony =
1148 VerificationCeremony::new(did, "session_int_3".to_string(), Timestamp::new(1000, 0));
1149 let (_att_pk, att_sk) = generate_keypair();
1151 let attester_did = Did::new("did:exo:attester-int3").unwrap();
1152 let err = ceremony
1153 .finalize(®, Timestamp::new(1020, 0), &attester_did, &att_sk)
1154 .unwrap_err();
1155 assert!(matches!(
1156 err,
1157 VerificationCeremonyError::InsufficientScore { score: 0 }
1158 ));
1159 }
1160
1161 #[test]
1163 fn test_submit_proof_after_finalize_returns_already_finalized() {
1164 let (mut ceremony, public_key, secret_key) =
1165 make_signature_ceremony("sess-finalized-submit", Timestamp::new(1000, 0));
1166 submit_valid_signature(
1167 &mut ceremony,
1168 public_key,
1169 &secret_key,
1170 Timestamp::new(1010, 0),
1171 );
1172
1173 let (_pk, att_sk) = generate_keypair();
1174 let attester_did = Did::new("did:exo:att-finalized-submit").unwrap();
1175 let registry = active_registry_for_key(&ceremony, public_key);
1176 let _ = ceremony
1177 .finalize(®istry, Timestamp::new(1020, 0), &attester_did, &att_sk)
1178 .unwrap();
1179 assert!(ceremony.is_finalized());
1180
1181 let err = ceremony
1182 .submit_proof(
1183 IdentityProof::Otp("111".to_string()),
1184 Timestamp::new(1030, 0),
1185 )
1186 .unwrap_err();
1187 assert!(matches!(err, VerificationCeremonyError::AlreadyFinalized));
1188 assert_eq!(ceremony.proof_count(), 1);
1190 }
1191
1192 #[test]
1194 fn test_double_finalize_returns_already_finalized() {
1195 let (mut ceremony, public_key, secret_key) =
1196 make_signature_ceremony("sess-double-final", Timestamp::new(1000, 0));
1197 submit_valid_signature(
1198 &mut ceremony,
1199 public_key,
1200 &secret_key,
1201 Timestamp::new(1010, 0),
1202 );
1203
1204 let (_pk, att_sk) = generate_keypair();
1205 let attester_did = Did::new("did:exo:att-double-final").unwrap();
1206 let registry = active_registry_for_key(&ceremony, public_key);
1207 let _first = ceremony
1208 .finalize(®istry, Timestamp::new(1020, 0), &attester_did, &att_sk)
1209 .unwrap();
1210 let err = ceremony
1211 .finalize(®istry, Timestamp::new(1030, 0), &attester_did, &att_sk)
1212 .unwrap_err();
1213 assert!(matches!(err, VerificationCeremonyError::AlreadyFinalized));
1214 }
1215
1216 #[test]
1218 fn test_finalize_expired_returns_expired() {
1219 let (mut ceremony, public_key, secret_key) =
1220 make_signature_ceremony("sess-final-expired", Timestamp::new(1000, 0));
1221 submit_valid_signature(
1222 &mut ceremony,
1223 public_key,
1224 &secret_key,
1225 Timestamp::new(1010, 0),
1226 );
1227
1228 let (_pk, att_sk) = generate_keypair();
1229 let attester_did = Did::new("did:exo:att-fin-exp").unwrap();
1230 let registry = active_registry_for_key(&ceremony, public_key);
1231 let err = ceremony
1233 .finalize(
1234 ®istry,
1235 Timestamp::new(5_000_000, 0),
1236 &attester_did,
1237 &att_sk,
1238 )
1239 .unwrap_err();
1240 assert!(matches!(err, VerificationCeremonyError::Expired));
1241 assert!(!ceremony.is_finalized());
1243 }
1244
1245 #[test]
1247 fn test_finalize_empty_attester_did_rejected() {
1248 let (mut ceremony, public_key, secret_key) =
1249 make_signature_ceremony("sess-empty-att", Timestamp::new(1000, 0));
1250 submit_valid_signature(
1251 &mut ceremony,
1252 public_key,
1253 &secret_key,
1254 Timestamp::new(1010, 0),
1255 );
1256
1257 let (_pk, att_sk) = generate_keypair();
1258 let registry = active_registry_for_key(&ceremony, public_key);
1259 let empty_did: Did =
1264 serde_json::from_str("\"\"").expect("serde_json accepts empty string for Did wrapper");
1265
1266 let err = ceremony
1267 .finalize(®istry, Timestamp::new(1020, 0), &empty_did, &att_sk)
1268 .unwrap_err();
1269 match err {
1270 VerificationCeremonyError::InvalidAttesterDid(msg) => {
1271 assert!(msg.contains("empty"));
1272 }
1273 other => panic!("expected InvalidAttesterDid, got {other:?}"),
1274 }
1275 assert!(!ceremony.is_finalized());
1277 }
1278
1279 #[test]
1281 fn test_risk_score_kyc_token_weight() {
1282 let did = Did::new("did:exo:score-kyc").unwrap();
1283 let mut ceremony =
1284 VerificationCeremony::new(did, "sess-kyc".to_string(), Timestamp::new(1000, 0));
1285 ceremony.proofs.push(IdentityProof::KycToken("kyc".into()));
1286 assert_eq!(ceremony.calculate_risk_score(), 5000);
1288 }
1289
1290 #[test]
1292 fn test_finalize_risk_level_low() {
1293 let did = Did::new("did:exo:level-low").unwrap();
1294 let mut ceremony =
1295 VerificationCeremony::new(did, "sess-low".to_string(), Timestamp::new(1000, 0));
1296 ceremony.proofs.push(IdentityProof::Otp("otp".into()));
1297 assert_eq!(ceremony.calculate_risk_score(), 2000);
1298
1299 let (att_pk, att_sk) = generate_keypair();
1300 let attester_did = Did::new("did:exo:att-low").unwrap();
1301 let (registry_key, _registry_secret) = generate_keypair();
1302 let registry = active_registry_for_key(&ceremony, registry_key);
1303 let attestation = ceremony
1304 .finalize(®istry, Timestamp::new(1010, 0), &attester_did, &att_sk)
1305 .unwrap();
1306 assert_eq!(attestation.level, RiskLevel::Low);
1307 assert!(crate::risk::verify_attestation(&attestation, &att_pk));
1308 }
1309
1310 #[test]
1312 fn test_finalize_risk_level_medium() {
1313 let (mut ceremony, public_key, secret_key) =
1314 make_signature_ceremony("sess-med", Timestamp::new(1000, 0));
1315 submit_valid_signature(
1316 &mut ceremony,
1317 public_key,
1318 &secret_key,
1319 Timestamp::new(1001, 0),
1320 );
1321 ceremony.proofs.push(IdentityProof::Otp("otp".into()));
1322 assert_eq!(ceremony.calculate_risk_score(), 3000);
1324
1325 let (_att_pk, att_sk) = generate_keypair();
1326 let attester_did = Did::new("did:exo:att-med").unwrap();
1327 let registry = active_registry_for_key(&ceremony, public_key);
1328 let attestation = ceremony
1329 .finalize(®istry, Timestamp::new(1010, 0), &attester_did, &att_sk)
1330 .unwrap();
1331 assert_eq!(attestation.level, RiskLevel::Medium);
1332 }
1333
1334 #[test]
1336 fn test_finalize_risk_level_high() {
1337 let did = Did::new("did:exo:level-high").unwrap();
1338 let mut ceremony =
1339 VerificationCeremony::new(did, "sess-high".to_string(), Timestamp::new(1000, 0));
1340 ceremony
1341 .proofs
1342 .push(IdentityProof::WebAuthnAssertion(vec![9, 9, 9]));
1343 assert_eq!(ceremony.calculate_risk_score(), 4000);
1345
1346 let (_att_pk, att_sk) = generate_keypair();
1347 let attester_did = Did::new("did:exo:att-high").unwrap();
1348 let (registry_key, _registry_secret) = generate_keypair();
1349 let registry = active_registry_for_key(&ceremony, registry_key);
1350 let attestation = ceremony
1351 .finalize(®istry, Timestamp::new(1010, 0), &attester_did, &att_sk)
1352 .unwrap();
1353 assert_eq!(attestation.level, RiskLevel::High);
1354 }
1355
1356 #[test]
1359 fn test_canonical_evidence_otp_and_kyc_branches_are_deterministic() {
1360 fn run(otp: &str, kyc: &str) -> [u8; 32] {
1361 let did = Did::new("did:exo:canon-otp-kyc").unwrap();
1362 let mut ceremony =
1363 VerificationCeremony::new(did, "sess-canon".to_string(), Timestamp::new(1000, 0));
1364 ceremony.proofs.push(IdentityProof::Otp(otp.to_string()));
1365 ceremony
1366 .proofs
1367 .push(IdentityProof::KycToken(kyc.to_string()));
1368 let (_pk, sk) = generate_keypair();
1369 let attester = Did::new("did:exo:att-canon").unwrap();
1370 let (registry_key, _registry_secret) = generate_keypair();
1371 let registry = active_registry_for_key(&ceremony, registry_key);
1372 let att = ceremony
1373 .finalize(®istry, Timestamp::new(1010, 0), &attester, &sk)
1374 .unwrap();
1375 att.evidence_hash
1376 }
1377
1378 let a = run("111111", "kyc-A");
1379 let b = run("111111", "kyc-A");
1380 let c_diff_otp = run("222222", "kyc-A");
1381 let d_diff_kyc = run("111111", "kyc-B");
1382
1383 assert_eq!(a, b);
1385 assert_ne!(a, c_diff_otp);
1387 assert_ne!(a, d_diff_kyc);
1389 assert_ne!(a, [0u8; 32]);
1391 }
1392
1393 #[test]
1394 fn canonical_evidence_binds_initiated_at_hlc_logical_counter() {
1395 fn evidence_hash_for(initiated_at: Timestamp) -> [u8; 32] {
1396 let did = Did::new("did:exo:canon-hlc-logical").unwrap();
1397 let mut ceremony =
1398 VerificationCeremony::new(did, "sess-canon-hlc".to_string(), initiated_at);
1399 ceremony
1400 .proofs
1401 .push(IdentityProof::KycToken("kyc-logical".to_string()));
1402
1403 let (_pk, sk) = generate_keypair();
1404 let attester = Did::new("did:exo:att-canon-hlc").unwrap();
1405 let (registry_key, _registry_secret) = generate_keypair();
1406 let registry = active_registry_for_key(&ceremony, registry_key);
1407 ceremony
1408 .finalize(®istry, Timestamp::new(2010, 0), &attester, &sk)
1409 .unwrap()
1410 .evidence_hash
1411 }
1412
1413 let logical_zero = evidence_hash_for(Timestamp::new(1000, 0));
1414 let logical_one = evidence_hash_for(Timestamp::new(1000, 1));
1415
1416 assert_ne!(
1417 logical_zero, logical_one,
1418 "verification ceremony evidence must bind the full HLC, not only physical_ms"
1419 );
1420 }
1421
1422 #[test]
1423 fn canonical_evidence_payload_is_domain_separated_cbor() {
1424 let did = Did::new("did:exo:canon-cbor").unwrap();
1425 let mut ceremony =
1426 VerificationCeremony::new(did, "sess-cbor".to_string(), Timestamp::new(1000, 2));
1427 ceremony
1428 .proofs
1429 .push(IdentityProof::Otp("otp-cbor".to_string()));
1430
1431 let evidence = ceremony.canonical_evidence().unwrap();
1432
1433 assert!(
1434 !evidence.starts_with(b"exo.verification.ceremony.v1\n"),
1435 "verification ceremony evidence must not use the legacy line format"
1436 );
1437 assert!(
1438 evidence
1439 .windows(b"exo.identity.verification_ceremony.evidence.v1".len())
1440 .any(|window| window == b"exo.identity.verification_ceremony.evidence.v1"),
1441 "canonical evidence must include a domain tag in the CBOR payload"
1442 );
1443
1444 let decoded: ciborium::value::Value =
1445 ciborium::de::from_reader(evidence.as_slice()).expect("canonical evidence CBOR");
1446 assert!(
1447 matches!(decoded, ciborium::value::Value::Map(_)),
1448 "canonical evidence should decode as a structured CBOR map"
1449 );
1450 }
1451
1452 #[test]
1453 fn verification_evidence_source_has_no_raw_proof_hash_streaming() {
1454 let source = include_str!("verification.rs");
1455 let production = source
1456 .split("#[cfg(test)]")
1457 .next()
1458 .expect("production section");
1459
1460 assert!(
1461 !production.contains("blake3::Hasher"),
1462 "verification ceremony evidence must use domain-separated canonical CBOR, not raw BLAKE3 streaming"
1463 );
1464 }
1465}