Skip to main content

auths_verifier/
credential.rs

1//! Pure ACDC credential verification — report facts, never resolve (Epic F.5).
2//!
3//! [`verify_credential`] decides whether an issued capability credential is
4//! authentic **purely by replaying its inputs**: it recomputes the ACDC SAID,
5//! validates the attributes against the compiled-in F.1 capability schema,
6//! replays the issuer KEL to confirm the issuance is anchored and signed by the
7//! signing-time key, runs the lifecycle witness-quorum math (KAWA) over the
8//! receipts it is handed, and reads TEL status by KEL anchor position.
9//!
10//! It is WASM-safe: no git, no network, no clock of its own. It never resolves
11//! KEL tips or judges freshness — that is the SDK resolution layer's job (F.4),
12//! which is why [`CredentialVerdict`] has no `StaleOrUnresolvable` variant. F.5
13//! reports the `as_of` position of exactly the KEL it was given.
14//!
15//! ## Composed claim
16//!
17//! Under [`VerifierWitnessPolicy::RequireWitnesses`] a credential is `Valid` only
18//! if its `vcp` *and* `iss` anchoring `ixn`s reached witness quorum and no
19//! quorum-reaching `rev` is anchored at/before the presentation position. Under
20//! [`VerifierWitnessPolicy::Warn`] (the default) under-quorum is a non-fatal
21//! trust-on-first-sight acceptance and any seen `rev` revokes (conservative).
22//! `detect_duplicity` flags issuer-KEL forks in both modes.
23
24use auths_crypto::CryptoProvider;
25use auths_keri::witness::StoredReceipt;
26use auths_keri::witness::agreement::WitnessAgreement;
27
28use crate::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
29use crate::software_verify::verify_with_key_sync;
30use crate::{CanonicalDid, Capability, IdentityDID};
31use auths_keri::{
32    Acdc, CesrKey, Event, KeriPublicKey, KeyState, Prefix, Said, TelEvent, Threshold, TrustedKel,
33    compute_capability_schema_said,
34};
35use chrono::{DateTime, Utc};
36
37use crate::commit_kel::VerifierWitnessPolicy;
38use crate::duplicity::{KelEventRef, detect_duplicity};
39
40/// The capability claim field required by the F.1 capability schema (`a.capability`).
41const CAPABILITY_FIELD: &str = "capability";
42
43/// Optional ISO-8601 expiry claim in the attributes block (`a.expiry`). Absent
44/// means the credential never expires; the verifier compares it against the
45/// injected `now` (it never consults a wall clock of its own).
46const EXPIRY_FIELD: &str = "expiry";
47
48/// Names which lifecycle anchor missed witness quorum, for [`CredentialVerdict::WitnessQuorumNotMet`].
49#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum LifecycleEvent {
51    /// The registry inception (`vcp`) anchoring `ixn`.
52    Vcp,
53    /// The credential issuance (`iss`) anchoring `ixn`.
54    Iss,
55    /// A credential revocation (`rev`) anchoring `ixn`.
56    Rev,
57}
58
59impl LifecycleEvent {
60    /// The lowercase TEL event tag (`vcp`/`iss`/`rev`).
61    fn tag(self) -> &'static str {
62        match self {
63            LifecycleEvent::Vcp => "vcp",
64            LifecycleEvent::Iss => "iss",
65            LifecycleEvent::Rev => "rev",
66        }
67    }
68}
69
70impl std::fmt::Display for LifecycleEvent {
71    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
72        f.write_str(self.tag())
73    }
74}
75
76/// The distinguishable outcome of [`verify_credential`].
77///
78/// Every failure is a named variant so a consumer can explain *why* a credential
79/// did not verify, never a generic "invalid".
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub enum CredentialVerdict {
82    /// The credential is authentic, anchored, witnessed (per policy), unexpired,
83    /// and not revoked at/before the presentation position.
84    Valid {
85        /// Issuer AID (`did:keri:`), parsed once at construction.
86        issuer: IdentityDID,
87        /// Subject (holder) AID (`did:keri:`), parsed once at construction.
88        subject: CanonicalDid,
89        /// The capabilities the credential grants (`a.capability`), parsed once — a
90        /// capability that does not parse fails the verdict closed (never silently dropped).
91        caps: Vec<Capability>,
92        /// The KEL position the verdict is as-of: the tip `(seq)` of the given issuer KEL.
93        as_of: u128,
94        /// How fresh this verdict is under the verifier's freshness policy (ADR 009). The
95        /// bare verifier is positional (no clock), so it sets [`Freshness::Unknown`]; a
96        /// caller with a freshness signal upgrades it via [`CredentialVerdict::with_freshness`].
97        freshness: Freshness,
98    },
99    /// The recomputed ACDC `d` (or nested `a.d`) did not match the embedded SAID.
100    SaidMismatch,
101    /// The attributes failed validation against the embedded capability schema, or
102    /// the schema SAID `s` is not the pinned one.
103    SchemaInvalid,
104    /// The issuance was not anchored, or its issuer signature did not verify against
105    /// the signing-time key.
106    IssuerSignatureInvalid,
107    /// The registry (`vcp`) was never anchored in the issuer KEL, so status is unknown.
108    RegistryNotEstablished,
109    /// A revocation reached the policy bar and is anchored at/before the presentation.
110    CredentialRevoked {
111        /// The KEL position at which the revocation was anchored.
112        revoked_at: u128,
113    },
114    /// The credential expired at `expired_at`, checked against the injected `now`.
115    Expired {
116        /// The expiry instant declared in `a.expiry`.
117        expired_at: DateTime<Utc>,
118        /// The injected verification time it was checked against.
119        now: DateTime<Utc>,
120    },
121    /// Under [`VerifierWitnessPolicy::RequireWitnesses`] a lifecycle anchor did not
122    /// reach witness quorum (fail-closed). Names which anchor missed.
123    WitnessQuorumNotMet {
124        /// Which lifecycle anchor (vcp/iss/rev) missed quorum.
125        event: LifecycleEvent,
126        /// Distinct valid designated-witness receipts collected for that anchor.
127        collected: usize,
128        /// Receipts required by the in-force backer threshold at that anchor.
129        required: usize,
130    },
131    /// The issuer KEL forks (two events at one seq with different SAIDs) — fail-closed
132    /// in both witness policies.
133    IssuerKelDuplicitous,
134}
135
136impl CredentialVerdict {
137    /// Whether the credential verified (`Valid`).
138    pub fn is_valid(&self) -> bool {
139        matches!(self, CredentialVerdict::Valid { .. })
140    }
141
142    /// This verdict's freshness (ADR 009). Only meaningful for `Valid`; a non-`Valid`
143    /// verdict reports [`Freshness::Stale`] since it is never trusted regardless.
144    pub fn freshness(&self) -> Freshness {
145        match self {
146            CredentialVerdict::Valid { freshness, .. } => *freshness,
147            _ => Freshness::Stale,
148        }
149    }
150
151    /// The issuer-KEL position a `Valid` verdict was verified as-of (the `{as_of, freshness}`
152    /// pair, ADR 009), or `None` when the verdict is not `Valid`.
153    pub fn as_of(&self) -> Option<u128> {
154        match self {
155            CredentialVerdict::Valid { as_of, .. } => Some(*as_of),
156            _ => None,
157        }
158    }
159
160    /// Re-classify a `Valid` verdict's freshness against a policy and the evidence of a fresher
161    /// source ([`FreshnessEvidence`] — a bundle age, or a fresher KEL tip). The bare verifier
162    /// emits `Unknown`; the relying party upgrades it here once it has a freshness oracle.
163    /// A no-op on non-`Valid` verdicts.
164    ///
165    /// Args:
166    /// * `policy`: the relying party's freshness policy.
167    /// * `evidence`: what the relying party knows about a source fresher than the slice.
168    pub fn with_freshness(self, policy: &FreshnessPolicy, evidence: FreshnessEvidence) -> Self {
169        match self {
170            CredentialVerdict::Valid {
171                issuer,
172                subject,
173                caps,
174                as_of,
175                ..
176            } => CredentialVerdict::Valid {
177                issuer,
178                subject,
179                caps,
180                as_of,
181                freshness: policy.classify(evidence),
182            },
183            other => other,
184        }
185    }
186
187    /// Whether this verdict is trusted under a freshness policy: it is `Valid` AND its
188    /// freshness clears the policy. A bare `Valid` is never trusted without freshness — the
189    /// relying party's policy (not the signer) decides the tolerance (ADR 009).
190    ///
191    /// Args:
192    /// * `policy`: the relying party's freshness policy.
193    pub fn is_trusted(&self, policy: &FreshnessPolicy) -> bool {
194        self.is_valid() && policy.trusts(self.freshness())
195    }
196}
197
198/// An ACDC paired with the issuer's detached signature over its canonical wire bytes.
199///
200/// The ACDC body itself carries no signature; the issuer signs
201/// [`Acdc::to_wire_bytes`] with the KEL signing key in force when the issuance was
202/// anchored. The verifier recovers that signing-time key by KEL replay and checks
203/// this signature against it.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct SignedAcdc {
206    /// The credential body.
207    pub acdc: Acdc,
208    /// The issuer's signature over `acdc.to_wire_bytes()`.
209    pub signature: Vec<u8>,
210}
211
212/// Verify an issued capability credential — the thin `async` wrapper over
213/// [`verify_credential_sync`].
214///
215/// Kept so native Rust async callers (`auths-sdk`, `auths-rp`, `auths-mcp-server`)
216/// compile unchanged. Signature verification is deterministic and backend-independent,
217/// so this returns exactly the same verdict as the executor-free
218/// [`verify_credential_sync`]; the `_provider` argument is retained only for
219/// source-compatibility of this signature (verification runs through the in-crate
220/// pure-Rust `software_verify`, not the injected provider).
221///
222/// Args: identical to [`verify_credential_sync`], plus a trailing `_provider` that is
223/// accepted but unused.
224///
225/// Usage:
226/// ```ignore
227/// let verdict = verify_credential(&signed, &issuer_kel, &tel, &receipts, policy, now, &provider).await;
228/// assert!(verdict.is_valid());
229/// ```
230pub async fn verify_credential(
231    signed: &SignedAcdc,
232    issuer_kel: &[Event],
233    tel_events: &[TelEvent],
234    receipts: &[StoredReceipt],
235    witness_policy: VerifierWitnessPolicy,
236    now: DateTime<Utc>,
237    _provider: &dyn CryptoProvider,
238) -> CredentialVerdict {
239    verify_credential_sync(
240        signed,
241        issuer_kel,
242        tel_events,
243        receipts,
244        witness_policy,
245        now,
246    )
247}
248
249/// Verify an issued capability credential purely by replaying its inputs — synchronously,
250/// with no executor.
251///
252/// This is the executor-free core every non-Rust binding target (C-ABI, WASM, Node,
253/// Python, Go) calls directly: `block_on` is impossible in browser WASM, so signature
254/// checks run through the synchronous pure-Rust `software_verify` rather than the async
255/// [`CryptoProvider`]. It reports facts and does the lifecycle witness-quorum math; it
256/// never resolves KEL tips, fetches, or judges freshness (that is the SDK resolution
257/// layer's job, F.4).
258///
259/// Args:
260/// * `signed`: The credential plus the issuer's detached signature over its wire bytes.
261/// * `issuer_kel`: The issuer identity's KEL events, in sequence order.
262/// * `tel_events`: The credential registry's TEL (`vcp`, `iss`, optional `rev…`).
263/// * `receipts`: Witness receipts (witness-attributed) handed in for the quorum math.
264/// * `witness_policy`: `Warn` (default, TOFS) or `RequireWitnesses` (fail-closed).
265/// * `now`: The verification time, injected at the boundary (no wall clock here).
266///
267/// Usage:
268/// ```ignore
269/// let verdict = verify_credential_sync(&signed, &issuer_kel, &tel, &receipts, policy, now);
270/// assert!(verdict.is_valid());
271/// ```
272pub fn verify_credential_sync(
273    signed: &SignedAcdc,
274    issuer_kel: &[Event],
275    tel_events: &[TelEvent],
276    receipts: &[StoredReceipt],
277    witness_policy: VerifierWitnessPolicy,
278    now: DateTime<Utc>,
279) -> CredentialVerdict {
280    let acdc = &signed.acdc;
281
282    if acdc.verify_said().is_err() {
283        return CredentialVerdict::SaidMismatch;
284    }
285
286    if validate_against_schema(acdc).is_err() {
287        return CredentialVerdict::SchemaInvalid;
288    }
289
290    if let Some(verdict) = check_expiry(acdc, now) {
291        return verdict;
292    }
293
294    // Duplicity is diagnosed on the raw event stream first: a fork makes the KEL
295    // un-replayable, so this must precede `validate_kel` to surface the specific
296    // `IssuerKelDuplicitous` rather than a generic replay failure.
297    if let Some(prefix) = issuer_kel.first().map(|e| e.prefix())
298        && detect_duplicity(&kel_refs(issuer_kel, prefix)).is_diverging()
299    {
300        return CredentialVerdict::IssuerKelDuplicitous;
301    }
302
303    // rt-002-allow: issuer_kel is authenticated before it reaches here — local trusted registry, authenticated bundle, or the JSON contract's authenticate_kel boundary (validate_signed_kel over the wire attachments). The ACDC + TEL lifecycle anchors below are bound to this issuer key-state.
304    let issuer_state = match TrustedKel::from_trusted_source(issuer_kel).replay() {
305        Ok(state) => state,
306        Err(_) => return CredentialVerdict::RegistryNotEstablished,
307    };
308
309    let lifecycle = match locate_lifecycle(tel_events, issuer_kel) {
310        Some(lifecycle) => lifecycle,
311        None => return CredentialVerdict::RegistryNotEstablished,
312    };
313
314    // Step 4: resolve the witness-quorum of every lifecycle anchor once, against
315    // the backer set in force at each anchor's KEL position. Under
316    // RequireWitnesses an under-quorum vcp/iss is fatal; the per-rev outcomes feed
317    // the revocation decision below.
318    let quorum = resolve_quorum(&lifecycle, issuer_kel, &issuer_state.prefix, receipts);
319    if let VerifierWitnessPolicy::RequireWitnesses = witness_policy
320        && let Some(verdict) = quorum.fatal_under_quorum()
321    {
322        return verdict;
323    }
324
325    if !verify_issuer_signature(signed, issuer_kel, lifecycle.iss_anchor_seq) {
326        return CredentialVerdict::IssuerSignatureInvalid;
327    }
328
329    if let Some(revoked_at) =
330        effective_revocation(&lifecycle, &quorum, witness_policy, issuer_state.sequence)
331    {
332        return CredentialVerdict::CredentialRevoked { revoked_at };
333    }
334
335    // Parse the validated identity/capability claims into their typed forms once, here.
336    // These are derived from already-replayed prefixes and schema-checked attributes, so a
337    // parse failure is a data-integrity violation — fail closed rather than carry a bad
338    // value or silently drop it.
339    let (Ok(issuer), Ok(subject)) = (
340        IdentityDID::parse(&format!("did:keri:{}", acdc.i)),
341        CanonicalDid::parse(&format!("did:keri:{}", acdc.a.i)),
342    ) else {
343        return CredentialVerdict::SchemaInvalid;
344    };
345    let Ok(caps) = capability_claims(acdc) else {
346        return CredentialVerdict::SchemaInvalid;
347    };
348
349    CredentialVerdict::Valid {
350        issuer,
351        subject,
352        caps,
353        as_of: issuer_state.sequence,
354        // Positional verification carries no clock; the relying party upgrades this via
355        // `with_freshness` once it has a freshness signal. Offline default → Unknown.
356        freshness: Freshness::Unknown,
357    }
358}
359
360/// Validate the ACDC attributes against the compiled-in F.1 capability schema.
361///
362/// Offline/WASM: pins `s` to the embedded schema SAID and structurally checks the
363/// schema's required fields (JSON-Schema-2020-12-lite). An unknown `s` is rejected.
364fn validate_against_schema(acdc: &Acdc) -> Result<(), ()> {
365    let pinned = compute_capability_schema_said().map_err(|_| ())?;
366    if acdc.s != pinned {
367        return Err(());
368    }
369    if !acdc.a.data.contains_key(CAPABILITY_FIELD) {
370        return Err(());
371    }
372    let capability = acdc.a.data.get(CAPABILITY_FIELD).and_then(|v| v.as_str());
373    match capability {
374        Some(c) if !c.is_empty() => Ok(()),
375        _ => Err(()),
376    }
377}
378
379/// The capability claims granted by the credential, decoded from `a.capability`.
380///
381/// Decodes the single claim string through [`Capability::parse_claim`] — the same
382/// grammar the issuer encodes with — so a multi-capability credential yields each
383/// capability. A malformed claim is an `Err` (the caller fails closed as
384/// `SchemaInvalid`) rather than a silently-wrong single capability.
385fn capability_claims(acdc: &Acdc) -> Result<Vec<Capability>, ()> {
386    let claim = acdc
387        .a
388        .data
389        .get(CAPABILITY_FIELD)
390        .and_then(|v| v.as_str())
391        .unwrap_or_default();
392    Capability::parse_claim(claim).map_err(|_| ())
393}
394
395/// Reject an expired credential by comparing the optional `a.expiry` to `now`.
396fn check_expiry(acdc: &Acdc, now: DateTime<Utc>) -> Option<CredentialVerdict> {
397    let raw = acdc.a.data.get(EXPIRY_FIELD).and_then(|v| v.as_str())?;
398    let expired_at = DateTime::parse_from_rfc3339(raw).ok()?.with_timezone(&Utc);
399    (now >= expired_at).then_some(CredentialVerdict::Expired { expired_at, now })
400}
401
402/// The TEL lifecycle events resolved to their issuer-KEL anchor positions.
403struct Lifecycle {
404    /// KEL position of the `iss`-anchoring `ixn`.
405    iss_anchor_seq: u128,
406    /// The `vcp`-anchoring `ixn` (KEL position + TEL SAID).
407    vcp_anchor: AnchoredTelEvent,
408    /// The `iss`-anchoring `ixn` (KEL position + TEL SAID).
409    iss_anchor: AnchoredTelEvent,
410    /// Each `rev`-anchoring `ixn` (KEL position + TEL SAID), in TEL order.
411    rev_anchors: Vec<AnchoredTelEvent>,
412}
413
414/// One TEL event located by its issuer-KEL anchor (`ixn`) position and SAID.
415struct AnchoredTelEvent {
416    /// The TEL event SAID the `ixn` anchored.
417    tel_said: Said,
418    /// The KEL position (`ixn` sequence) the seal was found at.
419    kel_seq: u128,
420}
421
422/// Locate the `vcp`, `iss`, and any `rev` TEL events by their issuer-KEL anchors.
423///
424/// Returns `None` (⇒ `RegistryNotEstablished`) when the TEL has no `vcp`/`iss` or
425/// either is not anchored by an `ixn` seal in the issuer KEL.
426fn locate_lifecycle(tel_events: &[TelEvent], issuer_kel: &[Event]) -> Option<Lifecycle> {
427    let vcp_said = tel_events.iter().find_map(|e| match e {
428        TelEvent::Vcp(vcp) => Some(vcp.d.clone()),
429        _ => None,
430    })?;
431    let iss_said = tel_events.iter().find_map(|e| match e {
432        TelEvent::Iss(iss) => Some(iss.d.clone()),
433        _ => None,
434    })?;
435
436    let vcp_anchor = anchor_position(issuer_kel, &vcp_said)?;
437    let iss_anchor = anchor_position(issuer_kel, &iss_said)?;
438
439    let rev_anchors = tel_events
440        .iter()
441        .filter_map(|e| match e {
442            TelEvent::Rev(rev) => anchor_position(issuer_kel, &rev.d),
443            _ => None,
444        })
445        .collect();
446
447    Some(Lifecycle {
448        iss_anchor_seq: iss_anchor.kel_seq,
449        vcp_anchor,
450        iss_anchor,
451        rev_anchors,
452    })
453}
454
455/// Find the issuer-KEL `ixn` whose anchor seal carries `tel_said`, returning its position.
456fn anchor_position(issuer_kel: &[Event], tel_said: &Said) -> Option<AnchoredTelEvent> {
457    for event in issuer_kel {
458        if event.is_interaction()
459            && event
460                .anchors()
461                .iter()
462                .any(|seal| seal.digest_value().is_some_and(|d| d == tel_said))
463        {
464            return Some(AnchoredTelEvent {
465                tel_said: tel_said.clone(),
466                kel_seq: event.sequence().value(),
467            });
468        }
469    }
470    None
471}
472
473/// The per-anchor witness-quorum outcomes for one credential's lifecycle (step 4).
474///
475/// Computed once over the given receipts; consumed both by the fatal `vcp`/`iss`
476/// check (`RequireWitnesses`) and by the revocation decision (a `rev` revokes only
477/// if it reached quorum under `RequireWitnesses`, or was simply seen under `Warn`).
478struct QuorumResolution {
479    /// Quorum outcome of the `vcp` anchor.
480    vcp: QuorumOutcome,
481    /// Quorum outcome of the `iss` anchor.
482    iss: QuorumOutcome,
483    /// `(kel_seq, outcome)` for each `rev` anchor, in TEL order.
484    revs: Vec<(u128, QuorumOutcome)>,
485}
486
487impl QuorumResolution {
488    /// The fatal `WitnessQuorumNotMet` verdict if `vcp` or `iss` is under-quorum.
489    fn fatal_under_quorum(&self) -> Option<CredentialVerdict> {
490        for (event, outcome) in [
491            (LifecycleEvent::Vcp, &self.vcp),
492            (LifecycleEvent::Iss, &self.iss),
493        ] {
494            if let QuorumOutcome::UnderQuorum {
495                collected,
496                required,
497            } = outcome
498            {
499                return Some(CredentialVerdict::WitnessQuorumNotMet {
500                    event,
501                    collected: *collected,
502                    required: *required,
503                });
504            }
505        }
506        None
507    }
508}
509
510/// Resolve the witness-quorum of every lifecycle anchor over the given receipts.
511///
512/// For the `vcp`, `iss`, and each `rev` anchoring `ixn`, run KAWA over the backer
513/// set in force at that `ixn`'s KEL position.
514fn resolve_quorum(
515    lifecycle: &Lifecycle,
516    issuer_kel: &[Event],
517    issuer_prefix: &Prefix,
518    receipts: &[StoredReceipt],
519) -> QuorumResolution {
520    let vcp = anchor_quorum(&lifecycle.vcp_anchor, issuer_kel, issuer_prefix, receipts);
521    let iss = anchor_quorum(&lifecycle.iss_anchor, issuer_kel, issuer_prefix, receipts);
522    let mut revs = Vec::with_capacity(lifecycle.rev_anchors.len());
523    for anchor in &lifecycle.rev_anchors {
524        let outcome = anchor_quorum(anchor, issuer_kel, issuer_prefix, receipts);
525        revs.push((anchor.kel_seq, outcome));
526    }
527    QuorumResolution { vcp, iss, revs }
528}
529
530/// The composed revocation decision for the presentation position.
531///
532/// A `rev` counts iff anchored at/before the presentation position AND (under
533/// `RequireWitnesses`) reached quorum, or (under `Warn`) was simply seen
534/// (conservative TOFS). Returns the earliest qualifying revocation position.
535fn effective_revocation(
536    lifecycle: &Lifecycle,
537    quorum: &QuorumResolution,
538    policy: VerifierWitnessPolicy,
539    presentation_seq: u128,
540) -> Option<u128> {
541    lifecycle
542        .rev_anchors
543        .iter()
544        .filter(|rev| rev.kel_seq <= presentation_seq)
545        .filter(|rev| rev_counts(rev.kel_seq, quorum, policy))
546        .map(|rev| rev.kel_seq)
547        .min()
548}
549
550/// Whether a `rev` at `kel_seq` counts as a revocation under the policy.
551fn rev_counts(kel_seq: u128, quorum: &QuorumResolution, policy: VerifierWitnessPolicy) -> bool {
552    match policy {
553        VerifierWitnessPolicy::Warn => true,
554        VerifierWitnessPolicy::RequireWitnesses => quorum
555            .revs
556            .iter()
557            .find(|(seq, _)| *seq == kel_seq)
558            .is_some_and(|(_, outcome)| matches!(outcome, QuorumOutcome::Met)),
559    }
560}
561
562/// The witness-quorum outcome of one lifecycle anchor under the given receipts.
563#[derive(Debug, Clone, PartialEq, Eq)]
564enum QuorumOutcome {
565    /// Quorum reached (including the `bt=0` backerless path).
566    Met,
567    /// Quorum not reached: `collected` distinct valid receipts vs `required`.
568    UnderQuorum {
569        /// Distinct valid designated-witness receipts collected.
570        collected: usize,
571        /// Receipts required by the in-force backer threshold.
572        required: usize,
573    },
574}
575
576/// Compute the KAWA witness-quorum outcome of one TEL anchor.
577///
578/// The backer set in force is the issuer key-state replayed up to and including
579/// the anchoring `ixn`'s position (`take_while ≤ anchor_seq`). KAWA does the
580/// M-of-N math over the receipts whose signature verifies against their declared
581/// witness key and whose witness AID is in that backer set.
582fn anchor_quorum(
583    anchor: &AnchoredTelEvent,
584    issuer_kel: &[Event],
585    issuer_prefix: &Prefix,
586    receipts: &[StoredReceipt],
587) -> QuorumOutcome {
588    let backer_state = match replay_to_seq(issuer_kel, anchor.kel_seq) {
589        Some(state) => state,
590        None => {
591            return QuorumOutcome::UnderQuorum {
592                collected: 0,
593                required: 0,
594            };
595        }
596    };
597    let backers = &backer_state.backers;
598    let bt = &backer_state.backer_threshold;
599    if backers.is_empty() {
600        return QuorumOutcome::Met;
601    }
602
603    let agreement = WitnessAgreement::new(1);
604    let sn = anchor.kel_seq as u64;
605    agreement.submit_event(issuer_prefix, sn, &anchor.tel_said, bt, backers);
606    if agreement.is_accepted(issuer_prefix, sn, &anchor.tel_said) {
607        return QuorumOutcome::Met;
608    }
609
610    let mut collected = 0usize;
611    for receipt in receipts {
612        if receipt.signed.receipt.d != anchor.tel_said {
613            continue;
614        }
615        if !backers.contains(&receipt.witness) {
616            continue;
617        }
618        if !verify_receipt(receipt) {
619            continue;
620        }
621        collected += 1;
622        agreement.add_receipt(
623            issuer_prefix,
624            sn,
625            &anchor.tel_said,
626            receipt.witness.as_str(),
627        );
628    }
629
630    if agreement.is_accepted(issuer_prefix, sn, &anchor.tel_said) {
631        QuorumOutcome::Met
632    } else {
633        QuorumOutcome::UnderQuorum {
634            collected,
635            required: required_count(bt, backers.len()),
636        }
637    }
638}
639
640/// The required-receipt count for display, from the typed backer threshold.
641fn required_count(bt: &Threshold, backer_count: usize) -> usize {
642    bt.simple_value()
643        .map(|v| v as usize)
644        .unwrap_or(backer_count)
645}
646
647/// Verify a witness receipt's detached signature against its declared witness key.
648///
649/// The witness AID is a CESR-qualified verkey, so the key travels in-band; the
650/// curve is dispatched on the parsed key, never on byte length.
651fn verify_receipt(receipt: &StoredReceipt) -> bool {
652    let Ok(payload) = serde_json::to_vec(&receipt.signed.receipt) else {
653        return false;
654    };
655    let Ok(key) = KeriPublicKey::parse(receipt.witness.as_str()) else {
656        return false;
657    };
658    verify_with_key_sync(&key, &payload, &receipt.signed.signature)
659}
660
661/// Verify the issuer's signature over the ACDC wire bytes against the signing-time key.
662///
663/// The signing-time key is recovered by replaying the issuer KEL up to and
664/// including the `iss`-anchoring position (`take_while ≤ iss_anchor_seq`) — a
665/// rotation *after* issuance does not invalidate the credential.
666fn verify_issuer_signature(
667    signed: &SignedAcdc,
668    issuer_kel: &[Event],
669    iss_anchor_seq: u128,
670) -> bool {
671    let Some(state) = replay_to_seq(issuer_kel, iss_anchor_seq) else {
672        return false;
673    };
674    let Ok(wire) = signed.acdc.to_wire_bytes() else {
675        return false;
676    };
677    for cesr in &state.current_keys {
678        if let Some(key) = parse_cesr_key(cesr)
679            && verify_with_key_sync(&key, &wire, &signed.signature)
680        {
681            return true;
682        }
683    }
684    false
685}
686
687/// Replay the issuer KEL up to and including `seq`, returning the key-state at that
688/// position (the take-while-≤-anchor-seq key recovery shared with the commit path).
689fn replay_to_seq(issuer_kel: &[Event], seq: u128) -> Option<KeyState> {
690    let subset: Vec<Event> = issuer_kel
691        .iter()
692        .take_while(|e| e.sequence().value() <= seq)
693        .cloned()
694        .collect();
695    // rt-002-allow: re-replays a prefix of issuer_kel that the verify_credential entrypoint already authenticated (same trust basis), to recover a historical key-state at `seq`; introduces no new untrusted input.
696    TrustedKel::from_trusted_source(&subset).replay().ok()
697}
698
699/// Decode a CESR verkey into a curve-tagged key, or `None` if it is undecodable.
700fn parse_cesr_key(cesr: &CesrKey) -> Option<KeriPublicKey> {
701    KeriPublicKey::parse(cesr.as_str()).ok()
702}
703
704/// Project an issuer KEL onto duplicity-detection refs (prefix, seq, SAID).
705fn kel_refs<'a>(issuer_kel: &'a [Event], prefix: &'a Prefix) -> Vec<KelEventRef<'a>> {
706    issuer_kel
707        .iter()
708        .map(|e| KelEventRef {
709            prefix: prefix.as_str(),
710            seq: e.sequence().value() as u64,
711            said: e.said().as_str(),
712        })
713        .collect()
714}
715
716#[cfg(test)]
717#[allow(clippy::unwrap_used, clippy::expect_used)]
718mod tests {
719    use super::*;
720    use auths_keri::{KeriSequence, Vcp};
721
722    fn lifecycle_with_revs(revs: Vec<u128>) -> Lifecycle {
723        Lifecycle {
724            iss_anchor_seq: 1,
725            vcp_anchor: AnchoredTelEvent {
726                tel_said: Said::new_unchecked("Evcp".into()),
727                kel_seq: 0,
728            },
729            iss_anchor: AnchoredTelEvent {
730                tel_said: Said::new_unchecked("Eiss".into()),
731                kel_seq: 1,
732            },
733            rev_anchors: revs
734                .into_iter()
735                .map(|s| AnchoredTelEvent {
736                    tel_said: Said::new_unchecked(format!("Erev{s}")),
737                    kel_seq: s,
738                })
739                .collect(),
740        }
741    }
742
743    fn warn_quorum(rev_seqs: &[u128]) -> QuorumResolution {
744        QuorumResolution {
745            vcp: QuorumOutcome::Met,
746            iss: QuorumOutcome::Met,
747            revs: rev_seqs.iter().map(|s| (*s, QuorumOutcome::Met)).collect(),
748        }
749    }
750
751    #[test]
752    fn revocation_ordered_by_kel_position() {
753        let lc = lifecycle_with_revs(vec![3]);
754        let q = warn_quorum(&[3]);
755        // Presented before the rev → not revoked.
756        assert_eq!(
757            effective_revocation(&lc, &q, VerifierWitnessPolicy::Warn, 2),
758            None
759        );
760        // Presented at/after the rev → revoked.
761        assert_eq!(
762            effective_revocation(&lc, &q, VerifierWitnessPolicy::Warn, 3),
763            Some(3)
764        );
765    }
766
767    #[test]
768    fn under_quorum_rev_skipped_under_require_witnesses() {
769        let lc = lifecycle_with_revs(vec![3]);
770        let q = QuorumResolution {
771            vcp: QuorumOutcome::Met,
772            iss: QuorumOutcome::Met,
773            revs: vec![(
774                3,
775                QuorumOutcome::UnderQuorum {
776                    collected: 0,
777                    required: 1,
778                },
779            )],
780        };
781        // Under RequireWitnesses a sub-quorum rev does NOT revoke.
782        assert_eq!(
783            effective_revocation(&lc, &q, VerifierWitnessPolicy::RequireWitnesses, 9),
784            None
785        );
786        // But under Warn the same seen rev revokes (conservative).
787        assert_eq!(
788            effective_revocation(&lc, &q, VerifierWitnessPolicy::Warn, 9),
789            Some(3)
790        );
791    }
792
793    #[test]
794    fn lifecycle_event_display_names_anchor() {
795        assert_eq!(LifecycleEvent::Vcp.to_string(), "vcp");
796        assert_eq!(LifecycleEvent::Iss.to_string(), "iss");
797        assert_eq!(LifecycleEvent::Rev.to_string(), "rev");
798    }
799
800    #[test]
801    fn required_count_from_simple_threshold() {
802        assert_eq!(required_count(&Threshold::Simple(2), 3), 2);
803    }
804
805    #[test]
806    fn vcp_registry_accessor_is_reused() {
807        // Compile-time proof the Vcp type is wired (registry SAID == d).
808        let vcp = Vcp::new(Prefix::new_unchecked("Eissuer".into()), "0Anonce".into());
809        let _ = vcp.s.value();
810        let _ = KeriSequence::new(0);
811    }
812}