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}