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::{CesrKey, Event, KelSealIndex, KeriPublicKey, KeyState, TrustedKel};
38use chrono::{DateTime, Utc};
39use subtle::ConstantTimeEq;
40
41use crate::credential::{CredentialVerdict, SignedAcdc, verify_credential_sync};
42use crate::freshness::{Freshness, FreshnessEvidence, FreshnessPolicy};
43use crate::software_verify::verify_with_key_sync;
44use crate::{CanonicalDid, Capability, IdentityDID};
45
46/// The optional informational role claim in the ACDC attributes (`a.role`),
47/// written by the F.4 issuance path. Surfaced on a `Valid` verdict for the F.6 bridge.
48const ROLE_FIELD: &str = "role";
49
50/// The optional ISO-8601 expiry claim in the ACDC attributes (`a.expiry`), written by
51/// the F.4 issuance path. Surfaced on a `Valid` verdict for the F.6 bridge.
52const EXPIRY_FIELD: &str = "expiry";
53
54/// Replay the subject KEL to its current key-state, supplying delegator seals if needed.
55///
56/// A non-delegated subject KEL (only `icp`/`rot`/`ixn`) replays with no lookup; a
57/// delegated subject (`dip`/`drt`) needs its delegator's anchoring seals, taken from
58/// `subject_delegator_kel`. An empty delegator KEL with a delegated subject yields a
59/// replay error (`SubjectKelInvalid`), which is the correct fail-closed outcome.
60fn replay_subject(subject_kel: &[Event], subject_delegator_kel: &[Event]) -> Option<KeyState> {
61    let lookup = KelSealIndex::from_events(subject_delegator_kel);
62    // rt-002-allow: subject_kel/delegator_kel are authenticated before they reach here — local trusted registry, authenticated bundle, or the JSON contract's authenticate_kel boundary (validate_signed_kel over the wire attachments). The presentation signature is additionally bound to the resulting subject key-state.
63    TrustedKel::from_trusted_source(subject_kel)
64        .replay_with_lookup(Some(&lookup))
65        .ok()
66}
67
68/// The presentation binding mode carried in a [`PresentationEnvelope`].
69///
70/// Selects how the `nonce` in the signed `(cred-SAID || audience || nonce)` is judged:
71/// a verifier-issued challenge (single-use, interactive) or a self-asserted TTL window
72/// (non-interactive, with the documented within-TTL same-audience replay residual).
73#[derive(Debug, Clone, PartialEq, Eq)]
74pub enum PresentationBinding {
75    /// Interactive challenge-response: the `nonce` is the verifier-issued challenge the
76    /// subject signed over. The verifier accepts it once (the session consumes it).
77    Challenge {
78        /// The verifier-issued nonce the subject signed.
79        nonce: Vec<u8>,
80    },
81    /// Non-interactive: the `nonce` is a subject-chosen value bound to a short TTL.
82    /// Valid while `now < not_after`. Carries the within-TTL replay residual.
83    Ttl {
84        /// The subject-chosen nonce the subject signed (uniqueness, not single-use).
85        nonce: Vec<u8>,
86        /// The presentation's expiry; `now >= not_after` → [`PresentationVerdict::Expired`].
87        not_after: DateTime<Utc>,
88    },
89}
90
91/// A minimal presentation envelope: the subject's proof of current control of `a.i`.
92///
93/// This is the binding + a minimal envelope, **not** the IPEX grant/admit protocol
94/// (deferred, tracked in F.7). The subject signs `(credential-SAID || audience || nonce)`
95/// with its signing-time key; the verifier recovers that key by replaying the subject
96/// KEL and checks this signature.
97#[derive(Debug, Clone, PartialEq, Eq)]
98pub struct PresentationEnvelope {
99    /// The SAID (`acdc.d`) of the credential being presented.
100    pub credential_said: String,
101    /// The audience this presentation is bound to (the relying party / verifier id).
102    pub audience: String,
103    /// The binding mode (interactive challenge or non-interactive TTL).
104    pub binding: PresentationBinding,
105    /// The subject's signature over `(credential-SAID || audience || nonce)`.
106    pub signature: Vec<u8>,
107}
108
109impl PresentationEnvelope {
110    /// The canonical bytes the subject signs: `credential-SAID || audience || nonce`.
111    ///
112    /// Length-prefix-free concatenation is unambiguous here because the SAID and the
113    /// audience are length-fixed by their domains at the call site, and the nonce is the
114    /// trailing field — but to avoid any cross-field ambiguity we separate fields with a
115    /// NUL byte that cannot occur in a SAID or a UTF-8 audience identifier boundary.
116    fn signed_message(credential_said: &str, audience: &str, nonce: &[u8]) -> Vec<u8> {
117        let mut message =
118            Vec::with_capacity(credential_said.len() + audience.len() + nonce.len() + 2);
119        message.extend_from_slice(credential_said.as_bytes());
120        message.push(0);
121        message.extend_from_slice(audience.as_bytes());
122        message.push(0);
123        message.extend_from_slice(nonce);
124        message
125    }
126
127    /// The nonce carried by this envelope's binding (challenge or TTL).
128    fn nonce(&self) -> &[u8] {
129        match &self.binding {
130            PresentationBinding::Challenge { nonce } => nonce,
131            PresentationBinding::Ttl { nonce, .. } => nonce,
132        }
133    }
134}
135
136/// The distinguishable outcome of [`verify_presentation`].
137///
138/// Every failure names *why* the presentation was not honored. A possessed credential
139/// alone never yields [`PresentationVerdict::Valid`]; current-control proof is mandatory.
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub enum PresentationVerdict {
142    /// Holder-binding proven: the credential is valid (F.5) AND the presentation was
143    /// signed by the subject AID's current signing-time key for the expected audience
144    /// and nonce/TTL. Carries the grant facts so the F.6 authority bridge can build a
145    /// policy context from the *verified presentation*, never from a raw ACDC.
146    Valid {
147        /// The issuer AID (`did:keri:`) that granted the now-bound credential.
148        issuer: IdentityDID,
149        /// The subject (holder) AID (`did:keri:`) whose current key signed the presentation.
150        subject: CanonicalDid,
151        /// The subject's proven root: for a delegated subject (dip-rooted KEL) the
152        /// delegator AID whose anchoring seal the replay verified; for a root subject,
153        /// the subject itself. Relying parties credit accounts to THIS identity —
154        /// never to a delegator parsed out of unverified input.
155        subject_root: CanonicalDid,
156        /// The capabilities the now-bound credential grants (`a.capability`).
157        caps: Vec<Capability>,
158        /// The optional informational role claim (`a.role`).
159        role: Option<String>,
160        /// The optional credential expiry (`a.expiry`), as carried in the ACDC attributes.
161        expires_at: Option<DateTime<Utc>>,
162        /// How fresh this honored verdict is under the relying party's freshness policy
163        /// (ADR 009). A possessed-but-offline credential slice yields [`Freshness::Unknown`];
164        /// the verdict is never a bare `Valid`, so a relying party can tell `Valid(Fresh)` from
165        /// `Valid(Unknown)` and apply a stricter policy. The gate already refused `Stale`/
166        /// policy-failing freshness, so this is `Fresh` or (tolerated) `Unknown`.
167        freshness: Freshness,
168        /// The issuer-KEL position the bound credential was verified as-of: the tip `(seq)` of
169        /// the given issuer KEL (ADR 009, the `{as_of, freshness}` pair). Carried through from
170        /// the inner credential verdict so a relying party can see *how current* the slice is,
171        /// not just the freshness grade — `Unknown` as-of which position.
172        as_of: u128,
173    },
174    /// The presentation signature did not verify against the subject KEL's current key —
175    /// the presenter does not currently control `a.i` (bearer / stale-key rejection).
176    HolderNotCurrentKey,
177    /// The presentation was bound to a different audience than expected.
178    WrongAudience,
179    /// Challenge path: the presented nonce did not match the verifier's challenge, or the
180    /// challenge was already consumed (single-use replay protection).
181    NonceMismatchOrConsumed,
182    /// TTL path: the non-interactive presentation's `not_after` has passed (`now >= not_after`).
183    Expired,
184    /// The subject's KEL could not be replayed (missing/forked/invalid) — no current key
185    /// to bind against.
186    SubjectKelInvalid,
187    /// The credential itself is not valid (chains F.5): revoked, expired, unanchored,
188    /// schema/SAID mismatch, etc. A presentation of an invalid credential binds nothing.
189    CredentialNotValid(CredentialVerdict),
190}
191
192impl PresentationVerdict {
193    /// Whether the presentation is honored (`Valid`).
194    pub fn is_honored(&self) -> bool {
195        matches!(self, PresentationVerdict::Valid { .. })
196    }
197
198    /// The freshness of an honored verdict (ADR 009), or `None` when the presentation was not
199    /// honored. An honored verdict always *names* its freshness — it is never a bare `Valid` —
200    /// so a relying party can distinguish `Valid(Fresh)` from a tolerated `Valid(Unknown)`.
201    pub fn freshness(&self) -> Option<Freshness> {
202        match self {
203            PresentationVerdict::Valid { freshness, .. } => Some(*freshness),
204            _ => None,
205        }
206    }
207
208    /// The issuer-KEL position an honored verdict was verified as-of (the `{as_of, freshness}`
209    /// pair, ADR 009), or `None` when the presentation was not honored.
210    pub fn as_of(&self) -> Option<u128> {
211        match self {
212            PresentationVerdict::Valid { as_of, .. } => Some(*as_of),
213            _ => None,
214        }
215    }
216}
217
218/// Verify a credential presentation — F.5 credential validity AND holder-binding proof.
219///
220/// This is the pure, WASM-safe authority gate. It refuses to honor a possessed-but-
221/// unbound ACDC: the presentation MUST be signed by the subject AID's current signing-
222/// time key (recovered by replaying `subject_kel`), bound to `expected_audience`, and
223/// (challenge path) carry the verifier's one-shot nonce or (TTL path) be within its TTL.
224///
225/// The credential check is delegated to F.5's [`verify_credential`] unchanged; a
226/// non-`Valid` inner verdict short-circuits to [`PresentationVerdict::CredentialNotValid`]
227/// so a revoked/expired credential never binds.
228///
229/// Args:
230/// * `envelope`: The subject's presentation (audience, binding, signature).
231/// * `signed`: The credential body + the issuer's detached signature (the F.5 input).
232/// * `issuer_kel`: The issuer identity's KEL (for the F.5 credential check), in sequence order.
233/// * `tel_events`: The credential registry's TEL (`vcp`/`iss`/optional `rev`), for F.5.
234/// * `receipts`: Witness receipts handed to F.5's quorum math.
235/// * `witness_policy`: F.5 witness policy (`Warn` / `RequireWitnesses`).
236/// * `subject_kel`: The subject (holder) AID's KEL, replayed to recover its current key.
237/// * `subject_delegator_kel`: The subject's delegator KEL (its anchoring seals), needed
238///   only when the subject is a delegated identifier (`dip`/`drt`). Pass `&[]` for a
239///   non-delegated subject.
240/// * `expected_audience`: The audience the verifier requires the presentation to be bound to.
241/// * `expected_challenge`: `Some(nonce)` for the interactive challenge path (one-shot,
242///   session-consumed); `None` for the non-interactive TTL path.
243/// * `now`: Verification time, injected at the boundary (no wall clock here).
244/// * `_provider`: Accepted but unused — see [`verify_presentation_sync`]. Signature
245///   verification runs through the in-crate pure-Rust `software_verify`; the parameter
246///   is retained only for source-compatibility of this async signature.
247///
248/// Usage:
249/// ```ignore
250/// let verdict = verify_presentation(
251///     &envelope, &signed, &issuer_kel, &tel, &receipts, policy,
252///     &subject_kel, &subject_delegator_kel, "audience.example", Some(&nonce), now, &provider,
253/// ).await;
254/// assert!(verdict.is_honored());
255/// ```
256#[allow(clippy::too_many_arguments)]
257pub async fn verify_presentation(
258    envelope: &PresentationEnvelope,
259    signed: &SignedAcdc,
260    issuer_kel: &[Event],
261    tel_events: &[auths_keri::TelEvent],
262    receipts: &[auths_keri::witness::StoredReceipt],
263    witness_policy: crate::commit_kel::VerifierWitnessPolicy,
264    subject_kel: &[Event],
265    subject_delegator_kel: &[Event],
266    expected_audience: &str,
267    expected_challenge: Option<&[u8]>,
268    now: DateTime<Utc>,
269    policy: &FreshnessPolicy,
270    fresher_tip_seq: Option<u128>,
271    _provider: &dyn CryptoProvider,
272) -> PresentationVerdict {
273    verify_presentation_sync(
274        envelope,
275        signed,
276        issuer_kel,
277        tel_events,
278        receipts,
279        witness_policy,
280        subject_kel,
281        subject_delegator_kel,
282        expected_audience,
283        expected_challenge,
284        now,
285        policy,
286        fresher_tip_seq,
287    )
288}
289
290/// Verify a credential presentation synchronously, with no executor — the WASM-safe
291/// core behind [`verify_presentation`].
292///
293/// Identical contract to [`verify_presentation`] but executor-free: `block_on` is
294/// impossible in browser WASM, so every non-Rust binding target (C-ABI, WASM, Node,
295/// Python, Go) calls this directly. It chains F.5's [`verify_credential_sync`] so a
296/// revoked/invalid credential never binds, then enforces the holder proof through the
297/// synchronous pure-Rust `software_verify`.
298///
299/// Args: identical to [`verify_presentation`] minus the trailing provider.
300///
301/// Usage:
302/// ```ignore
303/// let verdict = verify_presentation_sync(
304///     &envelope, &signed, &issuer_kel, &tel, &receipts, policy,
305///     &subject_kel, &subject_delegator_kel, "audience.example", Some(&nonce), now,
306/// );
307/// assert!(verdict.is_honored());
308/// ```
309#[allow(clippy::too_many_arguments)]
310pub fn verify_presentation_sync(
311    envelope: &PresentationEnvelope,
312    signed: &SignedAcdc,
313    issuer_kel: &[Event],
314    tel_events: &[auths_keri::TelEvent],
315    receipts: &[auths_keri::witness::StoredReceipt],
316    witness_policy: crate::commit_kel::VerifierWitnessPolicy,
317    subject_kel: &[Event],
318    subject_delegator_kel: &[Event],
319    expected_audience: &str,
320    expected_challenge: Option<&[u8]>,
321    now: DateTime<Utc>,
322    policy: &FreshnessPolicy,
323    fresher_tip_seq: Option<u128>,
324) -> PresentationVerdict {
325    let credential_verdict = verify_credential_sync(
326        signed,
327        issuer_kel,
328        tel_events,
329        receipts,
330        witness_policy,
331        now,
332    );
333    // Consume freshness: the bare credential verdict is positional (`Unknown`). Grade it against a
334    // known-fresher issuer tip — a slice behind the issuer's true tip is `Stale`, since it cannot
335    // see a later revocation — then gate on the relying party's policy. A bare `Valid` is never
336    // honored without clearing the policy (ADR 009 D7).
337    let evidence = match (&credential_verdict, fresher_tip_seq) {
338        (CredentialVerdict::Valid { as_of, .. }, Some(latest_seq)) => {
339            FreshnessEvidence::FresherTip {
340                latest_seq,
341                slice_as_of: *as_of,
342            }
343        }
344        _ => FreshnessEvidence::Offline,
345    };
346    let credential_verdict = credential_verdict.with_freshness(policy, evidence);
347    if !credential_verdict.is_trusted(policy) {
348        return PresentationVerdict::CredentialNotValid(credential_verdict);
349    }
350
351    if envelope.credential_said != signed.acdc.d.as_str() {
352        return PresentationVerdict::CredentialNotValid(CredentialVerdict::SaidMismatch);
353    }
354
355    if envelope.audience != expected_audience {
356        return PresentationVerdict::WrongAudience;
357    }
358
359    if let Some(verdict) = check_binding(&envelope.binding, expected_challenge, now) {
360        return verdict;
361    }
362
363    let (issuer, caps, freshness, as_of) = match credential_verdict {
364        CredentialVerdict::Valid {
365            issuer,
366            caps,
367            freshness,
368            as_of,
369            ..
370        } => (issuer, caps, freshness, as_of),
371        // The `is_trusted()` gate above guarantees `Valid`; on the impossible arm return a
372        // credential failure rather than panicking or fabricating an identity (keeps this
373        // WASM/FFI-safe).
374        other => return PresentationVerdict::CredentialNotValid(other),
375    };
376
377    let grant = GrantFacts {
378        issuer,
379        caps,
380        role: read_attribute(signed, ROLE_FIELD),
381        expires_at: read_expiry(signed),
382        freshness,
383        as_of,
384    };
385
386    verify_holder_signature(envelope, signed, subject_kel, subject_delegator_kel, grant)
387}
388
389/// The credential grant facts surfaced on a `Valid` presentation verdict.
390///
391/// Assembled from the inner F.5 [`CredentialVerdict::Valid`] (`issuer`/`caps`) and the
392/// verified `acdc.a` (`role`/`expiry`) once both the credential and the holder proof
393/// have passed, so they can never be read off an un-presented ACDC.
394struct GrantFacts {
395    issuer: IdentityDID,
396    caps: Vec<Capability>,
397    role: Option<String>,
398    expires_at: Option<DateTime<Utc>>,
399    freshness: Freshness,
400    as_of: u128,
401}
402
403/// Read an optional string claim from the verified ACDC attributes (`a.<field>`).
404fn read_attribute(signed: &SignedAcdc, field: &str) -> Option<String> {
405    signed
406        .acdc
407        .a
408        .data
409        .get(field)
410        .and_then(|v| v.as_str())
411        .map(str::to_string)
412}
413
414/// Read and parse the optional `a.expiry` claim into a UTC instant (RFC-3339), as F.4 wrote it.
415fn read_expiry(signed: &SignedAcdc) -> Option<DateTime<Utc>> {
416    let raw = read_attribute(signed, EXPIRY_FIELD)?;
417    DateTime::parse_from_rfc3339(&raw)
418        .ok()
419        .map(|dt| dt.with_timezone(&Utc))
420}
421
422/// Enforce the nonce/TTL binding; `None` means the binding passed.
423///
424/// Challenge path: `expected_challenge` must be present and equal the envelope nonce
425/// (mismatch / already-consumed → [`PresentationVerdict::NonceMismatchOrConsumed`]).
426/// TTL path: the envelope must be the TTL variant and unexpired against `now`. A
427/// challenge/TTL-mode disagreement between the verifier and the envelope is treated as a
428/// nonce mismatch (the verifier asked for a challenge it did not get, or vice versa).
429fn check_binding(
430    binding: &PresentationBinding,
431    expected_challenge: Option<&[u8]>,
432    now: DateTime<Utc>,
433) -> Option<PresentationVerdict> {
434    match (binding, expected_challenge) {
435        (PresentationBinding::Challenge { nonce }, Some(expected)) => {
436            // Constant-time comparison: a short-circuiting `!=` leaks how many
437            // leading bytes matched via response timing, which can recover the
438            // nonce byte-by-byte across attempts (RT-015). `ct_eq` short-circuits
439            // only on length (not secret) and compares contents in constant time.
440            (!bool::from(nonce.as_slice().ct_eq(expected)))
441                .then_some(PresentationVerdict::NonceMismatchOrConsumed)
442        }
443        (PresentationBinding::Ttl { not_after, .. }, None) => {
444            (now >= *not_after).then_some(PresentationVerdict::Expired)
445        }
446        // Mode disagreement: a challenge was expected but the envelope is TTL-bound (or
447        // the reverse). The interactive path treats a missing/extra challenge as a
448        // nonce failure; there is no honoring without the agreed binding.
449        (PresentationBinding::Challenge { .. }, None)
450        | (PresentationBinding::Ttl { .. }, Some(_)) => {
451            Some(PresentationVerdict::NonceMismatchOrConsumed)
452        }
453    }
454}
455
456/// Check the presentation signature against the subject KEL's current signing key.
457///
458/// The subject KEL is replayed (`validate_kel`) to recover the *current* key-state; the
459/// presentation must verify against one of those current keys. A rotation that advanced
460/// the subject's key invalidates a presentation signed by the old key — that is the
461/// "current control" requirement (distinct from F.5's signing-*time* issuer key).
462fn verify_holder_signature(
463    envelope: &PresentationEnvelope,
464    signed: &SignedAcdc,
465    subject_kel: &[Event],
466    subject_delegator_kel: &[Event],
467    grant: GrantFacts,
468) -> PresentationVerdict {
469    let Some(state) = replay_subject(subject_kel, subject_delegator_kel) else {
470        return PresentationVerdict::SubjectKelInvalid;
471    };
472    // The subject AID is the holder we just replayed; a DID that fails to parse means the
473    // subject is unusable as an identity (treated as an invalid subject KEL).
474    let Ok(subject) = CanonicalDid::parse(&format!("did:keri:{}", signed.acdc.a.i)) else {
475        return PresentationVerdict::SubjectKelInvalid;
476    };
477    // The proven root: a dip-rooted subject KEL only replays after its delegator's
478    // anchoring seal checked out (the KelSealIndex lookup above), so the delegator
479    // named by the KEL is verified, not claimed. A root subject is its own root.
480    let subject_root = match subject_kel.iter().find_map(|event| event.delegator()) {
481        Some(delegator) => {
482            let Ok(root) = CanonicalDid::parse(&format!("did:keri:{delegator}")) else {
483                return PresentationVerdict::SubjectKelInvalid;
484            };
485            root
486        }
487        None => subject.clone(),
488    };
489
490    let message = PresentationEnvelope::signed_message(
491        &envelope.credential_said,
492        &envelope.audience,
493        envelope.nonce(),
494    );
495
496    for cesr in &state.current_keys {
497        if let Some(key) = parse_cesr_key(cesr)
498            && verify_with_key_sync(&key, &message, &envelope.signature)
499        {
500            return PresentationVerdict::Valid {
501                issuer: grant.issuer,
502                subject,
503                subject_root,
504                caps: grant.caps,
505                role: grant.role,
506                expires_at: grant.expires_at,
507                freshness: grant.freshness,
508                as_of: grant.as_of,
509            };
510        }
511    }
512    PresentationVerdict::HolderNotCurrentKey
513}
514
515/// Decode a CESR verkey into a curve-tagged key, or `None` if it is undecodable.
516fn parse_cesr_key(cesr: &CesrKey) -> Option<KeriPublicKey> {
517    KeriPublicKey::parse(cesr.as_str()).ok()
518}
519
520#[cfg(test)]
521#[allow(clippy::unwrap_used, clippy::expect_used)]
522mod tests {
523    use super::*;
524
525    fn ttl_envelope(not_after: DateTime<Utc>) -> PresentationEnvelope {
526        PresentationEnvelope {
527            credential_said: "ECred".to_string(),
528            audience: "aud".to_string(),
529            binding: PresentationBinding::Ttl {
530                nonce: vec![1, 2, 3],
531                not_after,
532            },
533            signature: vec![],
534        }
535    }
536
537    fn challenge_envelope(nonce: Vec<u8>) -> PresentationEnvelope {
538        PresentationEnvelope {
539            credential_said: "ECred".to_string(),
540            audience: "aud".to_string(),
541            binding: PresentationBinding::Challenge { nonce },
542            signature: vec![],
543        }
544    }
545
546    #[test]
547    fn signed_message_separates_fields() {
548        let a = PresentationEnvelope::signed_message("E1", "aud", &[9]);
549        let b = PresentationEnvelope::signed_message("E1a", "ud", &[9]);
550        assert_ne!(a, b, "field boundaries must be unambiguous");
551    }
552
553    #[test]
554    fn challenge_match_passes_binding() {
555        let env = challenge_envelope(vec![7, 7, 7]);
556        let now = chrono::Utc::now();
557        assert_eq!(check_binding(&env.binding, Some(&[7, 7, 7]), now), None);
558    }
559
560    #[test]
561    fn challenge_mismatch_rejected() {
562        let env = challenge_envelope(vec![7, 7, 7]);
563        let now = chrono::Utc::now();
564        assert_eq!(
565            check_binding(&env.binding, Some(&[1, 2, 3]), now),
566            Some(PresentationVerdict::NonceMismatchOrConsumed)
567        );
568    }
569
570    #[test]
571    fn consumed_challenge_is_none_expected() {
572        // A consumed challenge is represented by the session no longer offering it:
573        // expected becomes None, which the challenge envelope cannot satisfy.
574        let env = challenge_envelope(vec![7, 7, 7]);
575        let now = chrono::Utc::now();
576        assert_eq!(
577            check_binding(&env.binding, None, now),
578            Some(PresentationVerdict::NonceMismatchOrConsumed)
579        );
580    }
581
582    #[test]
583    fn ttl_unexpired_passes_binding() {
584        let now = chrono::Utc::now();
585        let env = ttl_envelope(now + chrono::Duration::seconds(60));
586        assert_eq!(check_binding(&env.binding, None, now), None);
587    }
588
589    #[test]
590    fn ttl_expired_rejected() {
591        let now = chrono::Utc::now();
592        let env = ttl_envelope(now - chrono::Duration::seconds(1));
593        assert_eq!(
594            check_binding(&env.binding, None, now),
595            Some(PresentationVerdict::Expired)
596        );
597    }
598}