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