auths-sdk 0.1.16

Application services layer for Auths identity operations
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
//! Credential verification — the resolution + freshness layer (Epic F.4).
//!
//! The pure verifier (F.5, [`auths_verifier::verify_credential`]) reports facts about
//! the exact KEL/TEL/receipts it is handed; it can neither resolve a KEL tip nor judge
//! staleness. This module owns both: it resolves the issuer KEL + the credential TEL,
//! collects the witness receipts for **every lifecycle anchor — the establishment
//! events AND the `vcp`/`iss`/`rev` anchoring `ixn`s — to the witnessed tip**, hands
//! them to the pure verifier, and then owns the freshness decision (fail-closed
//! [`CredentialVerdict::StaleOrUnresolvable`] when no fresh witnessed tip is reachable).
//!
//! ## The F.4 / F.5 split
//!
//! - **F.5 (pure):** quorum math, SAID/schema/signature/revocation checks → a verdict.
//! - **F.4 (here):** supply the receipts + judge freshness. F.4 never re-does the
//!   quorum math; it only refuses to ask F.5 when no fresh witnessed tip is reachable.

use std::ops::ControlFlow;

use auths_crypto::RingCryptoProvider;
use auths_id::keri::Event;
use auths_id::keri::credential_registry::{find_registry, read_credential_tel};
use auths_id::keri::types::Prefix;
use auths_id::storage::GitReceiptStorage;
use auths_id::storage::receipts::ReceiptStorage;
use auths_keri::witness::StoredReceipt;
use auths_keri::{Said, TelEvent};
use chrono::{DateTime, Utc};

pub use auths_verifier::VerifierWitnessPolicy;

use crate::context::AuthsContext;
use crate::domains::credentials::error::CredentialError;
use crate::domains::credentials::stored::StoredCredential;
use crate::domains::credentials::usage_ledger::{UsageDecision, UsageLedger, UsageObservation};
use auths_keri::UsageCap;

/// The KEL position a verification verdict is as-of (the resolved witnessed tip).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedAsOf {
    /// The tip sequence of the resolved issuer KEL.
    pub seq: u128,
    /// The SAID of the tip event.
    pub said: String,
}

/// The outcome of [`verify`], owning the freshness decision the pure verifier cannot make.
///
/// `Resolved` carries the pure verifier's verdict plus the resolved "as-of" position;
/// `StaleOrUnresolvable` is the SDK's fail-closed freshness verdict when no fresh
/// witnessed tip was reachable (the pure verifier is never asked to resolve).
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CredentialVerdict {
    /// The pure verifier (F.5) produced a verdict against the resolved witnessed tip.
    Resolved {
        /// The pure verifier's fact-reporting verdict.
        verdict: auths_verifier::CredentialVerdict,
        /// The resolved tip position the verdict is as-of.
        as_of: ResolvedAsOf,
    },
    /// No fresh-enough witnessed tip was reachable — fail-closed (F.4 owns this).
    StaleOrUnresolvable {
        /// The tip position that could not be confirmed witnessed.
        as_of: ResolvedAsOf,
        /// Why no fresh witnessed tip was reachable.
        reason: String,
    },
    /// The credential is authentic but its quantitative usage cap is spent: the
    /// observed call count has reached the bound, so this exercise is over budget
    /// (the `(N+1)`-th use of an `N`-call cap). Fail-closed — F.4 owns this against
    /// the verifier's monotonic usage ledger.
    UsageCapExceeded {
        /// The tip position the credential was otherwise valid as-of.
        as_of: ResolvedAsOf,
        /// The observed call count that exceeded the cap (`>= cap`).
        observed: u64,
        /// The credential's call-count cap.
        cap: u64,
    },
    /// The presented usage counter is below the highest already accepted — a
    /// replayed/rolled-back counter. Fail-closed so an attacker cannot reset a spent
    /// budget by replaying an earlier (lower) snapshot.
    UsageCounterRolledBack {
        /// The tip position the credential was otherwise valid as-of.
        as_of: ResolvedAsOf,
        /// The (lower) observed count presented.
        observed: u64,
        /// The verifier's recorded high-water mark the observation fell below.
        high_water: u64,
    },
}

impl CredentialVerdict {
    /// Whether the credential verified (`Resolved` with a valid inner verdict).
    ///
    /// The cap-enforcement verdicts ([`Self::UsageCapExceeded`],
    /// [`Self::UsageCounterRolledBack`]) are never valid: a spent or replayed budget
    /// is a failed verification, not a logged warning.
    pub fn is_valid(&self) -> bool {
        matches!(
            self,
            CredentialVerdict::Resolved { verdict, .. } if verdict.is_valid()
        )
    }
}

/// Verify a stored credential, resolving the issuer KEL/TEL + lifecycle-anchor receipts.
///
/// The resolution + freshness layer: resolves the issuer KEL (from `acdc.i`) and the
/// credential's TEL, collects the witness receipts for **every lifecycle anchor — the
/// establishment events and the `vcp`/`iss`/`rev` anchoring `ixn`s — to the witnessed
/// tip**, hands them to the pure [`auths_verifier::verify_credential`], and owns the
/// freshness decision. Under [`VerifierWitnessPolicy::RequireWitnesses`], when the
/// issuer declares backers but no receipts are reachable for the lifecycle anchors,
/// no fresh witnessed tip exists and verification fails closed with
/// [`CredentialVerdict::StaleOrUnresolvable`] (the pure verifier is never asked).
///
/// Args:
/// * `ctx`: Auths context (registry + repo path for receipt lookup).
/// * `stored`: The credential body + the issuer's detached signature.
/// * `witness_policy`: `Warn` (TOFS) or `RequireWitnesses` (fail-closed).
/// * `now`: Verification time, injected at the boundary (the SDK passes `clock.now()`).
///
/// Usage:
/// ```ignore
/// let verdict = verify(&ctx, &stored, VerifierWitnessPolicy::RequireWitnesses, now).await?;
/// assert!(verdict.is_valid());
/// ```
pub async fn verify(
    ctx: &AuthsContext,
    stored: &StoredCredential,
    witness_policy: VerifierWitnessPolicy,
    now: DateTime<Utc>,
) -> Result<CredentialVerdict, CredentialError> {
    // The ACDC's `i` is already the issuer's bare KERI prefix (curve-tagged); no
    // `did:keri:` wrapper to parse.
    let issuer_prefix = Prefix::new_unchecked(stored.acdc.i.as_str().to_string());

    let issuer_kel = resolve_kel(ctx, &issuer_prefix)?;
    verify_with_issuer_kel(
        ctx,
        stored,
        &issuer_prefix,
        &issuer_kel,
        witness_policy,
        now,
    )
    .await
}

/// [`verify`] with a caller-resolved issuer KEL — no per-call KEL replay.
///
/// Batch consumers verifying many credentials from the same issuer should resolve
/// the issuer KEL once and call this per credential; [`verify`] re-resolves it on
/// every call.
///
/// Args:
/// * `ctx`: Auths context (registry + repo path for receipt lookup).
/// * `stored`: The credential body + the issuer's detached signature.
/// * `issuer_prefix`: The issuer's bare KERI prefix (must match `stored.acdc.i`).
/// * `issuer_kel`: The issuer's full KEL, oldest first.
/// * `witness_policy`: `Warn` (TOFS) or `RequireWitnesses` (fail-closed).
/// * `now`: Verification time, injected at the boundary.
///
/// Usage:
/// ```ignore
/// let kel = resolve_kel(&ctx, &issuer_prefix)?;
/// for stored in &credentials {
///     verify_with_issuer_kel(&ctx, stored, &issuer_prefix, &kel, policy, now).await?;
/// }
/// ```
pub async fn verify_with_issuer_kel(
    ctx: &AuthsContext,
    stored: &StoredCredential,
    issuer_prefix: &Prefix,
    issuer_kel: &[Event],
    witness_policy: VerifierWitnessPolicy,
    now: DateTime<Utc>,
) -> Result<CredentialVerdict, CredentialError> {
    if issuer_kel.is_empty() {
        return Err(CredentialError::StaleOrUnresolvable {
            reason: format!("issuer KEL not found: {issuer_prefix}"),
        });
    }

    let tel = resolve_tel(ctx, issuer_prefix, &stored.acdc.ri, &stored.acdc.d)?;

    let receipts = collect_lifecycle_receipts(ctx, issuer_prefix, issuer_kel, &tel);

    let as_of = tip_as_of(issuer_kel);

    // Freshness (F.4): under RequireWitnesses, if the issuer declares backers but the
    // witnessed tip is unreachable (no receipts at all for the lifecycle anchors),
    // there is no fresh witnessed tip — fail closed without asking the pure verifier.
    if let VerifierWitnessPolicy::RequireWitnesses = witness_policy
        && declares_backers(ctx, issuer_prefix)
        && receipts.is_empty()
    {
        return Ok(CredentialVerdict::StaleOrUnresolvable {
            as_of,
            reason:
                "issuer declares witnesses but no receipts were reachable for any lifecycle anchor"
                    .to_string(),
        });
    }

    let signed = auths_verifier::SignedAcdc {
        acdc: stored.acdc.clone(),
        signature: stored.signature.clone(),
    };
    let provider = RingCryptoProvider;
    let verdict = auths_verifier::verify_credential(
        &signed,
        issuer_kel,
        &tel,
        &receipts,
        witness_policy,
        now,
        &provider,
    )
    .await;

    Ok(CredentialVerdict::Resolved { verdict, as_of })
}

/// Verify a credential by its SAID, loading the stored envelope from the issuer.
///
/// The CLI-facing entry point: resolves the issuer's KEL prefix from its keychain
/// alias, loads the stored credential blob ([`StoredCredential`]) for `credential_said`,
/// and delegates to [`verify`]. The credential blob carries the issuer's signature, so
/// no separate signature input is needed.
///
/// Args:
/// * `ctx`: Auths context.
/// * `issuer_alias`: Keychain alias of the issuer whose namespace holds the credential.
/// * `credential_said`: The SAID of the credential to verify.
/// * `witness_policy`: `Warn` (TOFS) or `RequireWitnesses` (fail-closed).
/// * `now`: Verification time, injected at the boundary.
///
/// Usage:
/// ```ignore
/// let verdict = verify_by_said(&ctx, &issuer, "ECred…", policy, now).await?;
/// ```
pub async fn verify_by_said(
    ctx: &AuthsContext,
    issuer_alias: &auths_core::storage::keychain::KeyAlias,
    credential_said: &str,
    witness_policy: VerifierWitnessPolicy,
    now: DateTime<Utc>,
) -> Result<CredentialVerdict, CredentialError> {
    let issuer_prefix =
        crate::domains::credentials::issue::resolve_issuer_prefix(ctx, issuer_alias)?;
    let cred = Said::new_unchecked(credential_said.to_string());
    let blob = ctx
        .registry
        .load_credential(&issuer_prefix, &cred)
        .map_err(|e| CredentialError::StaleOrUnresolvable {
            reason: format!("credential blob read failed: {e}"),
        })?
        .ok_or_else(|| CredentialError::StaleOrUnresolvable {
            reason: format!("credential not found: {credential_said}"),
        })?;
    let stored =
        StoredCredential::from_bytes(&blob).map_err(|e| CredentialError::StaleOrUnresolvable {
            reason: format!("credential blob parse failed: {e}"),
        })?;
    verify(ctx, &stored, witness_policy, now).await
}

/// Verify a credential by SAID, then enforce its quantitative usage cap (if any).
///
/// The cap-aware CLI entry point. It runs the full authenticity verification
/// ([`verify_by_said`]) and, when that yields a `Valid` verdict AND the credential
/// carries a `calls:<N>` cap AND an observed usage count was presented, enforces the
/// cap against the verifier's monotonic usage ledger:
///
/// - the observed count reaching the bound → [`CredentialVerdict::UsageCapExceeded`];
/// - an observed count below the highest already accepted →
///   [`CredentialVerdict::UsageCounterRolledBack`] (replayed counter);
/// - otherwise the ledger advances and the original `Valid` verdict stands.
///
/// When the credential has no quantitative cap, or no observation is presented, the
/// underlying verdict is returned unchanged — cap enforcement is purely additive over
/// the existing authenticity verification.
///
/// Args:
/// * `ctx`: Auths context.
/// * `issuer_alias`: Keychain alias of the issuer whose namespace holds the credential.
/// * `credential_said`: The SAID of the credential to verify.
/// * `witness_policy`: `Warn` (TOFS) or `RequireWitnesses` (fail-closed).
/// * `now`: Verification time, injected at the boundary.
/// * `usage_ledger_root`: Repo path under which the monotonic usage ledger is kept.
/// * `observation`: The presented usage count, or `None` to skip cap enforcement.
pub async fn verify_by_said_with_usage(
    ctx: &AuthsContext,
    issuer_alias: &auths_core::storage::keychain::KeyAlias,
    credential_said: &str,
    witness_policy: VerifierWitnessPolicy,
    now: DateTime<Utc>,
    usage_ledger_root: &std::path::Path,
    observation: Option<UsageObservation>,
) -> Result<CredentialVerdict, CredentialError> {
    let verdict = verify_by_said(ctx, issuer_alias, credential_said, witness_policy, now).await?;

    // Cap enforcement only applies on an otherwise-valid credential with a presented
    // observation. A non-valid verdict already failed for an authenticity reason that
    // takes precedence; no observation means the caller is not exercising the cap.
    let Some(observation) = observation else {
        return Ok(verdict);
    };
    let CredentialVerdict::Resolved {
        verdict: inner,
        as_of,
    } = &verdict
    else {
        return Ok(verdict);
    };
    let auths_verifier::CredentialVerdict::Valid { caps, .. } = inner else {
        return Ok(verdict);
    };
    let Some(cap) = UsageCap::from_capabilities(caps) else {
        // The credential grants only presence tokens — no quantitative bound to enforce.
        return Ok(verdict);
    };

    let as_of = as_of.clone();
    let said = Said::new_unchecked(credential_said.to_string());
    let ledger = UsageLedger::new(usage_ledger_root);
    match ledger.enforce(&said, cap, observation)? {
        // Within budget: the credential remains valid and the ledger has advanced.
        // Return the original valid verdict unchanged.
        UsageDecision::Admitted { .. } => Ok(verdict),
        UsageDecision::CapExceeded { cap, observed } => Ok(CredentialVerdict::UsageCapExceeded {
            as_of,
            observed,
            cap: cap.max_calls(),
        }),
        UsageDecision::RolledBack {
            observed,
            high_water,
        } => Ok(CredentialVerdict::UsageCounterRolledBack {
            as_of,
            observed,
            high_water,
        }),
    }
}

/// Resolve a full KEL (oldest first) for any prefix via the registry.
pub(crate) fn resolve_kel(
    ctx: &AuthsContext,
    prefix: &Prefix,
) -> Result<Vec<Event>, CredentialError> {
    let mut events = Vec::new();
    ctx.registry
        .visit_events(prefix, 0, &mut |e| {
            events.push(e.clone());
            ControlFlow::Continue(())
        })
        .map_err(|e| CredentialError::StaleOrUnresolvable {
            reason: format!("issuer KEL read failed: {e}"),
        })?;
    Ok(events)
}

/// Resolve the credential's TEL (`vcp` + the `iss`/`rev` chain) under its registry.
pub(crate) fn resolve_tel(
    ctx: &AuthsContext,
    issuer_prefix: &Prefix,
    registry_said: &Said,
    credential_said: &Said,
) -> Result<Vec<TelEvent>, CredentialError> {
    let registry = match find_registry(ctx.registry.as_ref(), issuer_prefix)? {
        Some(reg) => reg,
        None => Said::new_unchecked(registry_said.as_str().to_string()),
    };
    Ok(read_credential_tel(
        ctx.registry.as_ref(),
        issuer_prefix,
        &registry,
        credential_said,
    )?)
}

/// The tip position of the resolved KEL (the as-of the verdict is reported against).
pub(crate) fn tip_as_of(issuer_kel: &[Event]) -> ResolvedAsOf {
    match issuer_kel.last() {
        Some(tip) => ResolvedAsOf {
            seq: tip.sequence().value(),
            said: tip.said().as_str().to_string(),
        },
        None => ResolvedAsOf {
            seq: 0,
            said: String::new(),
        },
    }
}

/// Whether the issuer's current key-state declares a non-empty backer (witness) set.
fn declares_backers(ctx: &AuthsContext, issuer_prefix: &Prefix) -> bool {
    ctx.registry
        .get_key_state(issuer_prefix)
        .map(|state| !state.backers.is_empty())
        .unwrap_or(false)
}

/// Collect the witness receipts for every lifecycle anchor to the witnessed tip.
///
/// The witnessed tip is established by receipts on the **establishment events**
/// (`icp`/`rot`/`dip`/`drt`) AND the **`vcp`/`iss`/`rev` anchoring `ixn`s**. This
/// gathers, for each establishment-event SAID and each TEL-event SAID, the stored
/// receipts and returns the de-duplicated union — exactly the set the pure verifier's
/// per-anchor quorum math (KAWA) filters against the in-force backer set.
pub(crate) fn collect_lifecycle_receipts(
    ctx: &AuthsContext,
    issuer_prefix: &Prefix,
    issuer_kel: &[Event],
    tel: &[TelEvent],
) -> Vec<StoredReceipt> {
    let Some(repo_path) = ctx.repo_path.as_ref() else {
        return Vec::new();
    };
    let lookup = GitReceiptStorage::new(repo_path.clone());

    let mut saids: Vec<Said> = Vec::new();
    for event in issuer_kel {
        if event.is_inception() || event.is_rotation() {
            push_unique(&mut saids, event.said().clone());
        }
    }
    for event in tel {
        push_unique(&mut saids, tel_event_said(event));
    }

    let mut receipts: Vec<StoredReceipt> = Vec::new();
    for said in &saids {
        if let Ok(Some(event_receipts)) = lookup.get_receipts(issuer_prefix, said) {
            for stored in event_receipts.receipts {
                receipts.push(stored);
            }
        }
    }
    receipts
}

/// Push `said` only if absent (the receipt-key set is de-duplicated).
fn push_unique(saids: &mut Vec<Said>, said: Said) {
    if !saids.contains(&said) {
        saids.push(said);
    }
}

/// The SAID of a TEL event (`vcp`/`iss`/`rev`).
fn tel_event_said(event: &TelEvent) -> Said {
    match event {
        TelEvent::Vcp(vcp) => vcp.d.clone(),
        TelEvent::Iss(iss) => iss.d.clone(),
        TelEvent::Rev(rev) => rev.d.clone(),
    }
}