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}