Skip to main content

exo_identity/
verification.rs

1// Copyright 2026 Exochain Foundation
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at:
6//
7//     https://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14//
15// SPDX-License-Identifier: Apache-2.0
16
17use 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
65/// Domain tag for canonical verification ceremony evidence.
66pub 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>), // sig, pubkey, message
81    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        // BTreeMap used as required
311        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    /// Finalize the ceremony and produce a **signed** [`RiskAttestation`].
330    ///
331    /// Closes GAP-004. The previous implementation returned a
332    /// `RiskAttestation` with a zero-byte signature and a zero-byte
333    /// evidence hash — structurally well-formed but cryptographically
334    /// meaningless. Any downstream code that trusted the attestation
335    /// would have accepted a forgery.
336    ///
337    /// The caller now MUST supply:
338    /// - `attester_did`: the DID of the identity system issuing the
339    ///   attestation (e.g. `did:exo:system` or a specific adjudicator).
340    /// - `attester_key`: the secret key corresponding to the attester's
341    ///   published public key. The produced attestation's signature is
342    ///   verifiable with `risk::verify_attestation` against that pubkey.
343    ///
344    /// The evidence bytes over which the attestation's `evidence_hash`
345    /// is computed are a domain-separated canonical CBOR summary of the
346    /// submitted proofs in submission order. This binds the attestation to the
347    /// exact proof set that produced the risk score.
348    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        // Reject an empty attester DID up front.
375        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        // Build canonical CBOR evidence bytes summarizing the proof set.
394        // The attestation's evidence_hash will be blake3 over these bytes
395        // (computed inside risk::assess_risk).
396        let evidence = self.canonical_evidence()?;
397
398        // Validity: 1 year from `now`, matching the previous behavior.
399        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        // Only flip the finalized flag once the real attestation has been
412        // produced successfully.
413        self.finalized = true;
414
415        // Sanity: the returned attestation must verify under the public
416        // key corresponding to the supplied secret key. We don't have the
417        // pubkey here, so we instead assert that the signature is not the
418        // zero-byte sentinel — closing the exact shape of the old bug.
419        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    /// Canonical CBOR serialization of this ceremony's proof set, used
465    /// as input to the attestation's `evidence_hash`.
466    ///
467    /// The evidence payload binds the exact target DID, session ID, full
468    /// initiation HLC timestamp, proof ordering, proof kind, and per-proof
469    /// canonical material hash. Raw OTP, KYC, and WebAuthn material does not
470    /// appear directly in the evidence bytes.
471    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                &registry,
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(&registry, Timestamp::new(1020, 0), &attester_did, &att_sk)
802            .unwrap();
803
804        assert_eq!(attestation.level, RiskLevel::Minimal);
805        assert!(ceremony.is_finalized());
806        // GAP-004: signature must verify, evidence_hash must be non-zero.
807        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(&registry, 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); // 1000 + 2000
849    }
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                &registry,
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    // Integration-style tests combining the registry and verification flow
1032    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        // 1. Submit signature
1071        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        // 2. Finalize
1081        let (att_pk, att_sk) = generate_keypair();
1082        let attester_did = Did::new("did:exo:attester-int1").unwrap();
1083        let attestation = ceremony
1084            .finalize(&reg, 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        // Check that the target DID matches what's in the registry
1090        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        // Finalization must resolve the target DID from an active registry.
1097        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        // Revoke the DID
1105        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        // Since DID is revoked, even a key-bound ceremony proof must not finalize.
1116        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(&reg, 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        // No proofs submitted
1150        let (_att_pk, att_sk) = generate_keypair();
1151        let attester_did = Did::new("did:exo:attester-int3").unwrap();
1152        let err = ceremony
1153            .finalize(&reg, 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    // Covers submit_proof() returning AlreadyFinalized (line 56) when ceremony has been finalized.
1162    #[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(&registry, 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        // Proof must NOT have been appended.
1189        assert_eq!(ceremony.proof_count(), 1);
1190    }
1191
1192    // Covers finalize() returning AlreadyFinalized (line 124) on a second finalize call.
1193    #[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(&registry, Timestamp::new(1020, 0), &attester_did, &att_sk)
1209            .unwrap();
1210        let err = ceremony
1211            .finalize(&registry, Timestamp::new(1030, 0), &attester_did, &att_sk)
1212            .unwrap_err();
1213        assert!(matches!(err, VerificationCeremonyError::AlreadyFinalized));
1214    }
1215
1216    // Covers finalize() returning Expired (line 127) when `now` is past the one-hour window.
1217    #[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        // now > initiated_at + 3_600_000 ms
1232        let err = ceremony
1233            .finalize(
1234                &registry,
1235                Timestamp::new(5_000_000, 0),
1236                &attester_did,
1237                &att_sk,
1238            )
1239            .unwrap_err();
1240        assert!(matches!(err, VerificationCeremonyError::Expired));
1241        // Still not finalized.
1242        assert!(!ceremony.is_finalized());
1243    }
1244
1245    // Covers the empty-attester-DID rejection path (lines 137-139) in finalize().
1246    #[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        // `Did::new("")` legitimately rejects — so the only way to reach the
1260        // empty-attester branch in `finalize()` is to deserialize a malformed
1261        // Did from serde. That mirrors the real attack surface (malformed
1262        // network input / tampered storage) that the branch is meant to catch.
1263        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(&registry, 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        // Ceremony must not have been flipped to finalized when validation failed.
1276        assert!(!ceremony.is_finalized());
1277    }
1278
1279    // Covers calculate_risk_score() KycToken arm (line 90) in isolation with a known weight.
1280    #[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        // KYC weight alone is 5000.
1287        assert_eq!(ceremony.calculate_risk_score(), 5000);
1288    }
1289
1290    // Covers RiskLevel::Low branch (line 149): 2000 <= score < 3000.
1291    #[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(&registry, 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    // Covers RiskLevel::Medium branch (line 147): 3000 <= score < 4000.
1311    #[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        // 1000 (sig) + 2000 (otp) = 3000 -> Medium
1323        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(&registry, Timestamp::new(1010, 0), &attester_did, &att_sk)
1330            .unwrap();
1331        assert_eq!(attestation.level, RiskLevel::Medium);
1332    }
1333
1334    // Covers RiskLevel::High branch (line 145): 4000 <= score < 5000.
1335    #[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        // WebAuthn alone = 4000 -> High
1344        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(&registry, Timestamp::new(1010, 0), &attester_did, &att_sk)
1352            .unwrap();
1353        assert_eq!(attestation.level, RiskLevel::High);
1354    }
1355
1356    // Covers canonical_evidence() OTP arm (lines 230-234) and KycToken arm (lines 242-246)
1357    // by finalizing ceremonies whose evidence hashes must change when OTP/KYC material changes.
1358    #[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(&registry, 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        // Deterministic for identical inputs.
1384        assert_eq!(a, b);
1385        // Different OTP must change the evidence hash -> proves OTP arm is hashed.
1386        assert_ne!(a, c_diff_otp);
1387        // Different KYC token must change the evidence hash -> proves KycToken arm is hashed.
1388        assert_ne!(a, d_diff_kyc);
1389        // Non-zero sanity (also asserted by debug_assert in finalize).
1390        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(&registry, 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}