auths_verifier/presentation.rs
1//! Holder-binding + presentation verification — credentials are not bearer tokens (Epic F.8).
2//!
3//! An issuer-signature-only ACDC that anyone who *possesses* it can present as
4//! authority is a **bearer token** — the red flag this project bans. Authority
5//! derived from a credential is honored only on **proof of current control of the
6//! subject AID** (`a.i`), established by replaying the subject's KEL and checking a
7//! fresh **presentation signature** against the signing-time key. A possessed-but-
8//! unbound ACDC grants nothing.
9//!
10//! [`verify_presentation`] is pure and WASM-safe: no git, no network, no clock of its
11//! own (the verification time is injected). It chains F.5's [`verify_credential`] so a
12//! revoked/invalid credential never binds, then enforces the holder proof.
13//!
14//! ## Replay model
15//!
16//! The signed message is always `(credential-SAID || audience || nonce)`. Two binding
17//! modes select how the `nonce` is judged:
18//!
19//! - **Interactive challenge-response (the v1 default):** the verifier issues a fresh
20//! random nonce as its own ephemeral per-session state (see the SDK
21//! `credentials::present` challenge session), the subject signs over it, and the
22//! verifier accepts the **matching** nonce exactly once. One-shot consumption is the
23//! calling session's job — the pure verifier only confirms the presented nonce equals
24//! the challenge it is handed. A replayed/mismatched/consumed nonce is rejected with
25//! [`PresentationVerdict::NonceMismatchOrConsumed`]. This is genuine replay protection
26//! without any global seen-cache, which is what keeps it WASM-compatible.
27//! - **Non-interactive TTL (`expected_challenge == None`):** no fresh challenge is
28//! possible, so the presentation binds to `(audience, purpose, short-TTL)` carried in
29//! the envelope and is judged against the injected `now`. **Residual (documented
30//! honestly):** within the TTL window, the *same* presentation can be replayed to the
31//! *same audience* — there is no per-use nonce to consume. This is acceptable only for
32//! low-stakes / idempotent audiences; it is NOT a replacement for the challenge path
33//! where genuine single-use is required. The verdict surfaces [`PresentationVerdict::Expired`]
34//! once `now` passes `not_after`.
35
36use auths_crypto::CryptoProvider;
37use auths_keri::{
38 CesrKey, DelegatorKelLookup, Event, KeriPublicKey, KeyState, Prefix, Said, Seal, SourceSeal,
39 validate_kel_with_lookup,
40};
41use chrono::{DateTime, Utc};
42
43use crate::credential::{CredentialVerdict, SignedAcdc, verify_credential_sync};
44use crate::software_verify::verify_with_key_sync;
45use crate::{CanonicalDid, Capability, IdentityDID};
46
47/// The optional informational role claim in the ACDC attributes (`a.role`),
48/// written by the F.4 issuance path. Surfaced on a `Valid` verdict for the F.6 bridge.
49const ROLE_FIELD: &str = "role";
50
51/// The optional ISO-8601 expiry claim in the ACDC attributes (`a.expiry`), written by
52/// the F.4 issuance path. Surfaced on a `Valid` verdict for the F.6 bridge.
53const EXPIRY_FIELD: &str = "expiry";
54
55/// `DelegatorKelLookup` over an in-memory delegator KEL slice — answers "did the
56/// delegator anchor a seal for this delegated subject event?" by scanning its seals.
57///
58/// A credential subject (`a.i`) is typically a delegated device/agent whose `dip`/`drt`
59/// events are anchored in its delegator's KEL. Replaying the subject KEL to recover its
60/// *current* signing key therefore needs the delegator's anchoring seals; this provides
61/// them purely (no git/network), keeping the verify path WASM-safe.
62struct DelegatorSeals<'a> {
63 delegator_kel: &'a [Event],
64}
65
66impl DelegatorKelLookup for DelegatorSeals<'_> {
67 fn find_seal(&self, _delegator_aid: &Prefix, seal_said: &Said) -> Option<SourceSeal> {
68 for event in self.delegator_kel {
69 for seal in event.anchors() {
70 if let Seal::KeyEvent { d, .. } = seal
71 && d == seal_said
72 {
73 return Some(SourceSeal {
74 s: event.sequence(),
75 d: event.said().clone(),
76 });
77 }
78 }
79 }
80 None
81 }
82}
83
84/// Replay the subject KEL to its current key-state, supplying delegator seals if needed.
85///
86/// A non-delegated subject KEL (only `icp`/`rot`/`ixn`) replays with no lookup; a
87/// delegated subject (`dip`/`drt`) needs its delegator's anchoring seals, taken from
88/// `subject_delegator_kel`. An empty delegator KEL with a delegated subject yields a
89/// replay error (`SubjectKelInvalid`), which is the correct fail-closed outcome.
90fn replay_subject(subject_kel: &[Event], subject_delegator_kel: &[Event]) -> Option<KeyState> {
91 let lookup = DelegatorSeals {
92 delegator_kel: subject_delegator_kel,
93 };
94 validate_kel_with_lookup(subject_kel, Some(&lookup)).ok()
95}
96
97/// The presentation binding mode carried in a [`PresentationEnvelope`].
98///
99/// Selects how the `nonce` in the signed `(cred-SAID || audience || nonce)` is judged:
100/// a verifier-issued challenge (single-use, interactive) or a self-asserted TTL window
101/// (non-interactive, with the documented within-TTL same-audience replay residual).
102#[derive(Debug, Clone, PartialEq, Eq)]
103pub enum PresentationBinding {
104 /// Interactive challenge-response: the `nonce` is the verifier-issued challenge the
105 /// subject signed over. The verifier accepts it once (the session consumes it).
106 Challenge {
107 /// The verifier-issued nonce the subject signed.
108 nonce: Vec<u8>,
109 },
110 /// Non-interactive: the `nonce` is a subject-chosen value bound to a short TTL.
111 /// Valid while `now < not_after`. Carries the within-TTL replay residual.
112 Ttl {
113 /// The subject-chosen nonce the subject signed (uniqueness, not single-use).
114 nonce: Vec<u8>,
115 /// The presentation's expiry; `now >= not_after` → [`PresentationVerdict::Expired`].
116 not_after: DateTime<Utc>,
117 },
118}
119
120/// A minimal presentation envelope: the subject's proof of current control of `a.i`.
121///
122/// This is the binding + a minimal envelope, **not** the IPEX grant/admit protocol
123/// (deferred, tracked in F.7). The subject signs `(credential-SAID || audience || nonce)`
124/// with its signing-time key; the verifier recovers that key by replaying the subject
125/// KEL and checks this signature.
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub struct PresentationEnvelope {
128 /// The SAID (`acdc.d`) of the credential being presented.
129 pub credential_said: String,
130 /// The audience this presentation is bound to (the relying party / verifier id).
131 pub audience: String,
132 /// The binding mode (interactive challenge or non-interactive TTL).
133 pub binding: PresentationBinding,
134 /// The subject's signature over `(credential-SAID || audience || nonce)`.
135 pub signature: Vec<u8>,
136}
137
138impl PresentationEnvelope {
139 /// The canonical bytes the subject signs: `credential-SAID || audience || nonce`.
140 ///
141 /// Length-prefix-free concatenation is unambiguous here because the SAID and the
142 /// audience are length-fixed by their domains at the call site, and the nonce is the
143 /// trailing field — but to avoid any cross-field ambiguity we separate fields with a
144 /// NUL byte that cannot occur in a SAID or a UTF-8 audience identifier boundary.
145 fn signed_message(credential_said: &str, audience: &str, nonce: &[u8]) -> Vec<u8> {
146 let mut message =
147 Vec::with_capacity(credential_said.len() + audience.len() + nonce.len() + 2);
148 message.extend_from_slice(credential_said.as_bytes());
149 message.push(0);
150 message.extend_from_slice(audience.as_bytes());
151 message.push(0);
152 message.extend_from_slice(nonce);
153 message
154 }
155
156 /// The nonce carried by this envelope's binding (challenge or TTL).
157 fn nonce(&self) -> &[u8] {
158 match &self.binding {
159 PresentationBinding::Challenge { nonce } => nonce,
160 PresentationBinding::Ttl { nonce, .. } => nonce,
161 }
162 }
163}
164
165/// The distinguishable outcome of [`verify_presentation`].
166///
167/// Every failure names *why* the presentation was not honored. A possessed credential
168/// alone never yields [`PresentationVerdict::Valid`]; current-control proof is mandatory.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum PresentationVerdict {
171 /// Holder-binding proven: the credential is valid (F.5) AND the presentation was
172 /// signed by the subject AID's current signing-time key for the expected audience
173 /// and nonce/TTL. Carries the grant facts so the F.6 authority bridge can build a
174 /// policy context from the *verified presentation*, never from a raw ACDC.
175 Valid {
176 /// The issuer AID (`did:keri:`) that granted the now-bound credential.
177 issuer: IdentityDID,
178 /// The subject (holder) AID (`did:keri:`) whose current key signed the presentation.
179 subject: CanonicalDid,
180 /// The capabilities the now-bound credential grants (`a.capability`).
181 caps: Vec<Capability>,
182 /// The optional informational role claim (`a.role`).
183 role: Option<String>,
184 /// The optional credential expiry (`a.expiry`), as carried in the ACDC attributes.
185 expires_at: Option<DateTime<Utc>>,
186 },
187 /// The presentation signature did not verify against the subject KEL's current key —
188 /// the presenter does not currently control `a.i` (bearer / stale-key rejection).
189 HolderNotCurrentKey,
190 /// The presentation was bound to a different audience than expected.
191 WrongAudience,
192 /// Challenge path: the presented nonce did not match the verifier's challenge, or the
193 /// challenge was already consumed (single-use replay protection).
194 NonceMismatchOrConsumed,
195 /// TTL path: the non-interactive presentation's `not_after` has passed (`now >= not_after`).
196 Expired,
197 /// The subject's KEL could not be replayed (missing/forked/invalid) — no current key
198 /// to bind against.
199 SubjectKelInvalid,
200 /// The credential itself is not valid (chains F.5): revoked, expired, unanchored,
201 /// schema/SAID mismatch, etc. A presentation of an invalid credential binds nothing.
202 CredentialNotValid(CredentialVerdict),
203}
204
205impl PresentationVerdict {
206 /// Whether the presentation is honored (`Valid`).
207 pub fn is_honored(&self) -> bool {
208 matches!(self, PresentationVerdict::Valid { .. })
209 }
210}
211
212/// Verify a credential presentation — F.5 credential validity AND holder-binding proof.
213///
214/// This is the pure, WASM-safe authority gate. It refuses to honor a possessed-but-
215/// unbound ACDC: the presentation MUST be signed by the subject AID's current signing-
216/// time key (recovered by replaying `subject_kel`), bound to `expected_audience`, and
217/// (challenge path) carry the verifier's one-shot nonce or (TTL path) be within its TTL.
218///
219/// The credential check is delegated to F.5's [`verify_credential`] unchanged; a
220/// non-`Valid` inner verdict short-circuits to [`PresentationVerdict::CredentialNotValid`]
221/// so a revoked/expired credential never binds.
222///
223/// Args:
224/// * `envelope`: The subject's presentation (audience, binding, signature).
225/// * `signed`: The credential body + the issuer's detached signature (the F.5 input).
226/// * `issuer_kel`: The issuer identity's KEL (for the F.5 credential check), in sequence order.
227/// * `tel_events`: The credential registry's TEL (`vcp`/`iss`/optional `rev`), for F.5.
228/// * `receipts`: Witness receipts handed to F.5's quorum math.
229/// * `witness_policy`: F.5 witness policy (`Warn` / `RequireWitnesses`).
230/// * `subject_kel`: The subject (holder) AID's KEL, replayed to recover its current key.
231/// * `subject_delegator_kel`: The subject's delegator KEL (its anchoring seals), needed
232/// only when the subject is a delegated identifier (`dip`/`drt`). Pass `&[]` for a
233/// non-delegated subject.
234/// * `expected_audience`: The audience the verifier requires the presentation to be bound to.
235/// * `expected_challenge`: `Some(nonce)` for the interactive challenge path (one-shot,
236/// session-consumed); `None` for the non-interactive TTL path.
237/// * `now`: Verification time, injected at the boundary (no wall clock here).
238/// * `_provider`: Accepted but unused — see [`verify_presentation_sync`]. Signature
239/// verification runs through the in-crate pure-Rust `software_verify`; the parameter
240/// is retained only for source-compatibility of this async signature.
241///
242/// Usage:
243/// ```ignore
244/// let verdict = verify_presentation(
245/// &envelope, &signed, &issuer_kel, &tel, &receipts, policy,
246/// &subject_kel, &subject_delegator_kel, "audience.example", Some(&nonce), now, &provider,
247/// ).await;
248/// assert!(verdict.is_honored());
249/// ```
250#[allow(clippy::too_many_arguments)]
251pub async fn verify_presentation(
252 envelope: &PresentationEnvelope,
253 signed: &SignedAcdc,
254 issuer_kel: &[Event],
255 tel_events: &[auths_keri::TelEvent],
256 receipts: &[auths_keri::witness::StoredReceipt],
257 witness_policy: crate::commit_kel::VerifierWitnessPolicy,
258 subject_kel: &[Event],
259 subject_delegator_kel: &[Event],
260 expected_audience: &str,
261 expected_challenge: Option<&[u8]>,
262 now: DateTime<Utc>,
263 _provider: &dyn CryptoProvider,
264) -> PresentationVerdict {
265 verify_presentation_sync(
266 envelope,
267 signed,
268 issuer_kel,
269 tel_events,
270 receipts,
271 witness_policy,
272 subject_kel,
273 subject_delegator_kel,
274 expected_audience,
275 expected_challenge,
276 now,
277 )
278}
279
280/// Verify a credential presentation synchronously, with no executor — the WASM-safe
281/// core behind [`verify_presentation`].
282///
283/// Identical contract to [`verify_presentation`] but executor-free: `block_on` is
284/// impossible in browser WASM, so every non-Rust binding target (C-ABI, WASM, Node,
285/// Python, Go) calls this directly. It chains F.5's [`verify_credential_sync`] so a
286/// revoked/invalid credential never binds, then enforces the holder proof through the
287/// synchronous pure-Rust `software_verify`.
288///
289/// Args: identical to [`verify_presentation`] minus the trailing provider.
290///
291/// Usage:
292/// ```ignore
293/// let verdict = verify_presentation_sync(
294/// &envelope, &signed, &issuer_kel, &tel, &receipts, policy,
295/// &subject_kel, &subject_delegator_kel, "audience.example", Some(&nonce), now,
296/// );
297/// assert!(verdict.is_honored());
298/// ```
299#[allow(clippy::too_many_arguments)]
300pub fn verify_presentation_sync(
301 envelope: &PresentationEnvelope,
302 signed: &SignedAcdc,
303 issuer_kel: &[Event],
304 tel_events: &[auths_keri::TelEvent],
305 receipts: &[auths_keri::witness::StoredReceipt],
306 witness_policy: crate::commit_kel::VerifierWitnessPolicy,
307 subject_kel: &[Event],
308 subject_delegator_kel: &[Event],
309 expected_audience: &str,
310 expected_challenge: Option<&[u8]>,
311 now: DateTime<Utc>,
312) -> PresentationVerdict {
313 let credential_verdict = verify_credential_sync(
314 signed,
315 issuer_kel,
316 tel_events,
317 receipts,
318 witness_policy,
319 now,
320 );
321 if !credential_verdict.is_valid() {
322 return PresentationVerdict::CredentialNotValid(credential_verdict);
323 }
324
325 if envelope.credential_said != signed.acdc.d.as_str() {
326 return PresentationVerdict::CredentialNotValid(CredentialVerdict::SaidMismatch);
327 }
328
329 if envelope.audience != expected_audience {
330 return PresentationVerdict::WrongAudience;
331 }
332
333 if let Some(verdict) = check_binding(&envelope.binding, expected_challenge, now) {
334 return verdict;
335 }
336
337 let (issuer, caps) = match credential_verdict {
338 CredentialVerdict::Valid { issuer, caps, .. } => (issuer, caps),
339 // `is_valid()` above guarantees `Valid`; on the impossible arm return a credential
340 // failure rather than panicking or fabricating an identity (keeps this WASM/FFI-safe).
341 other => return PresentationVerdict::CredentialNotValid(other),
342 };
343
344 let grant = GrantFacts {
345 issuer,
346 caps,
347 role: read_attribute(signed, ROLE_FIELD),
348 expires_at: read_expiry(signed),
349 };
350
351 verify_holder_signature(envelope, signed, subject_kel, subject_delegator_kel, grant)
352}
353
354/// The credential grant facts surfaced on a `Valid` presentation verdict.
355///
356/// Assembled from the inner F.5 [`CredentialVerdict::Valid`] (`issuer`/`caps`) and the
357/// verified `acdc.a` (`role`/`expiry`) once both the credential and the holder proof
358/// have passed, so they can never be read off an un-presented ACDC.
359struct GrantFacts {
360 issuer: IdentityDID,
361 caps: Vec<Capability>,
362 role: Option<String>,
363 expires_at: Option<DateTime<Utc>>,
364}
365
366/// Read an optional string claim from the verified ACDC attributes (`a.<field>`).
367fn read_attribute(signed: &SignedAcdc, field: &str) -> Option<String> {
368 signed
369 .acdc
370 .a
371 .data
372 .get(field)
373 .and_then(|v| v.as_str())
374 .map(str::to_string)
375}
376
377/// Read and parse the optional `a.expiry` claim into a UTC instant (RFC-3339), as F.4 wrote it.
378fn read_expiry(signed: &SignedAcdc) -> Option<DateTime<Utc>> {
379 let raw = read_attribute(signed, EXPIRY_FIELD)?;
380 DateTime::parse_from_rfc3339(&raw)
381 .ok()
382 .map(|dt| dt.with_timezone(&Utc))
383}
384
385/// Enforce the nonce/TTL binding; `None` means the binding passed.
386///
387/// Challenge path: `expected_challenge` must be present and equal the envelope nonce
388/// (mismatch / already-consumed → [`PresentationVerdict::NonceMismatchOrConsumed`]).
389/// TTL path: the envelope must be the TTL variant and unexpired against `now`. A
390/// challenge/TTL-mode disagreement between the verifier and the envelope is treated as a
391/// nonce mismatch (the verifier asked for a challenge it did not get, or vice versa).
392fn check_binding(
393 binding: &PresentationBinding,
394 expected_challenge: Option<&[u8]>,
395 now: DateTime<Utc>,
396) -> Option<PresentationVerdict> {
397 match (binding, expected_challenge) {
398 (PresentationBinding::Challenge { nonce }, Some(expected)) => {
399 (nonce.as_slice() != expected).then_some(PresentationVerdict::NonceMismatchOrConsumed)
400 }
401 (PresentationBinding::Ttl { not_after, .. }, None) => {
402 (now >= *not_after).then_some(PresentationVerdict::Expired)
403 }
404 // Mode disagreement: a challenge was expected but the envelope is TTL-bound (or
405 // the reverse). The interactive path treats a missing/extra challenge as a
406 // nonce failure; there is no honoring without the agreed binding.
407 (PresentationBinding::Challenge { .. }, None)
408 | (PresentationBinding::Ttl { .. }, Some(_)) => {
409 Some(PresentationVerdict::NonceMismatchOrConsumed)
410 }
411 }
412}
413
414/// Check the presentation signature against the subject KEL's current signing key.
415///
416/// The subject KEL is replayed (`validate_kel`) to recover the *current* key-state; the
417/// presentation must verify against one of those current keys. A rotation that advanced
418/// the subject's key invalidates a presentation signed by the old key — that is the
419/// "current control" requirement (distinct from F.5's signing-*time* issuer key).
420fn verify_holder_signature(
421 envelope: &PresentationEnvelope,
422 signed: &SignedAcdc,
423 subject_kel: &[Event],
424 subject_delegator_kel: &[Event],
425 grant: GrantFacts,
426) -> PresentationVerdict {
427 let Some(state) = replay_subject(subject_kel, subject_delegator_kel) else {
428 return PresentationVerdict::SubjectKelInvalid;
429 };
430 // The subject AID is the holder we just replayed; a DID that fails to parse means the
431 // subject is unusable as an identity (treated as an invalid subject KEL).
432 let Ok(subject) = CanonicalDid::parse(&format!("did:keri:{}", signed.acdc.a.i)) else {
433 return PresentationVerdict::SubjectKelInvalid;
434 };
435
436 let message = PresentationEnvelope::signed_message(
437 &envelope.credential_said,
438 &envelope.audience,
439 envelope.nonce(),
440 );
441
442 for cesr in &state.current_keys {
443 if let Some(key) = parse_cesr_key(cesr)
444 && verify_with_key_sync(&key, &message, &envelope.signature)
445 {
446 return PresentationVerdict::Valid {
447 issuer: grant.issuer,
448 subject,
449 caps: grant.caps,
450 role: grant.role,
451 expires_at: grant.expires_at,
452 };
453 }
454 }
455 PresentationVerdict::HolderNotCurrentKey
456}
457
458/// Decode a CESR verkey into a curve-tagged key, or `None` if it is undecodable.
459fn parse_cesr_key(cesr: &CesrKey) -> Option<KeriPublicKey> {
460 KeriPublicKey::parse(cesr.as_str()).ok()
461}
462
463#[cfg(test)]
464#[allow(clippy::unwrap_used, clippy::expect_used)]
465mod tests {
466 use super::*;
467
468 fn ttl_envelope(not_after: DateTime<Utc>) -> PresentationEnvelope {
469 PresentationEnvelope {
470 credential_said: "ECred".to_string(),
471 audience: "aud".to_string(),
472 binding: PresentationBinding::Ttl {
473 nonce: vec![1, 2, 3],
474 not_after,
475 },
476 signature: vec![],
477 }
478 }
479
480 fn challenge_envelope(nonce: Vec<u8>) -> PresentationEnvelope {
481 PresentationEnvelope {
482 credential_said: "ECred".to_string(),
483 audience: "aud".to_string(),
484 binding: PresentationBinding::Challenge { nonce },
485 signature: vec![],
486 }
487 }
488
489 #[test]
490 fn signed_message_separates_fields() {
491 let a = PresentationEnvelope::signed_message("E1", "aud", &[9]);
492 let b = PresentationEnvelope::signed_message("E1a", "ud", &[9]);
493 assert_ne!(a, b, "field boundaries must be unambiguous");
494 }
495
496 #[test]
497 fn challenge_match_passes_binding() {
498 let env = challenge_envelope(vec![7, 7, 7]);
499 let now = chrono::Utc::now();
500 assert_eq!(check_binding(&env.binding, Some(&[7, 7, 7]), now), None);
501 }
502
503 #[test]
504 fn challenge_mismatch_rejected() {
505 let env = challenge_envelope(vec![7, 7, 7]);
506 let now = chrono::Utc::now();
507 assert_eq!(
508 check_binding(&env.binding, Some(&[1, 2, 3]), now),
509 Some(PresentationVerdict::NonceMismatchOrConsumed)
510 );
511 }
512
513 #[test]
514 fn consumed_challenge_is_none_expected() {
515 // A consumed challenge is represented by the session no longer offering it:
516 // expected becomes None, which the challenge envelope cannot satisfy.
517 let env = challenge_envelope(vec![7, 7, 7]);
518 let now = chrono::Utc::now();
519 assert_eq!(
520 check_binding(&env.binding, None, now),
521 Some(PresentationVerdict::NonceMismatchOrConsumed)
522 );
523 }
524
525 #[test]
526 fn ttl_unexpired_passes_binding() {
527 let now = chrono::Utc::now();
528 let env = ttl_envelope(now + chrono::Duration::seconds(60));
529 assert_eq!(check_binding(&env.binding, None, now), None);
530 }
531
532 #[test]
533 fn ttl_expired_rejected() {
534 let now = chrono::Utc::now();
535 let env = ttl_envelope(now - chrono::Duration::seconds(1));
536 assert_eq!(
537 check_binding(&env.binding, None, now),
538 Some(PresentationVerdict::Expired)
539 );
540 }
541}