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}