Skip to main content

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}