Skip to main content

auths_sdk/domains/credentials/
authenticate.rs

1//! Relying-party presentation authentication (Epic D1 / fn-151.5).
2//!
3//! The full relying-party flow that turns an `Auths-Presentation` request into a verified,
4//! scoped principal: parse the wire shape (`auths_rp`), consume the single-use challenge
5//! (`auths_rp`), resolve the issuer + subject + delegator KELs (the F.4 + D1 loader), run the
6//! pure `auths_verifier::verify_presentation`, and map the verdict to an `auths_rp::VerifiedPrincipal`.
7//! The expected audience is the relying party's own configured identity (NOT the wire header),
8//! and witness policy is `Warn` (first-party; revocation freshness is the registry-pull cadence).
9
10use auths_core::storage::keychain::KeyAlias;
11use auths_crypto::RingCryptoProvider;
12use auths_rp::{
13    Audience, ChallengeError, ChallengeStore, Denied, NONCE_LEN, Nonce, VerifiedPrincipal,
14    WireError, WirePresentation,
15};
16use auths_verifier::{
17    PresentationBinding, PresentationVerdict, VerifierWitnessPolicy, verify_presentation,
18};
19use chrono::{DateTime, Utc};
20
21use auths_id::keri::types::Prefix;
22use auths_id::policy::context_from_credential;
23
24use crate::context::AuthsContext;
25use crate::domains::credentials::FreshnessDecision;
26use crate::domains::credentials::error::CredentialError;
27use crate::domains::credentials::present_inputs::load_presentation_inputs;
28use crate::domains::org::error::OrgError;
29use crate::domains::org::policy::{evaluate_with_org_policy, load_org_policy};
30
31/// Errors from [`authenticate_presentation`] (`thiserror`, exhaustive).
32#[derive(Debug, thiserror::Error)]
33pub enum PresentationAuthError {
34    /// The `Auths-Presentation` wire shape was malformed.
35    #[error("wire: {0}")]
36    Wire(WireError),
37    /// The presented nonce was not exactly 32 bytes.
38    #[error("nonce must be 32 bytes")]
39    NonceLength,
40    /// No live single-use challenge matched (absent, replayed, or expired).
41    #[error("challenge: {0}")]
42    Challenge(ChallengeError),
43    /// The issuer/subject KELs or the credential could not be resolved.
44    #[error("resolve: {0}")]
45    Resolve(CredentialError),
46    /// The presentation verified-as-a-process but was not honored (see the inner reason).
47    #[error("denied: {0}")]
48    Denied(Denied),
49    /// The presentation verified and the holder is authenticated, but the issuer's org
50    /// policy denied the principal (403, not 401 — authentication succeeded).
51    #[error("org policy denied: {reason}")]
52    PolicyDenied {
53        /// The typed denial reason from the policy engine.
54        reason: String,
55    },
56    /// The issuer's org policy could not be loaded or evaluated — fail closed (treated
57    /// as a denial; the principal is not authorized when policy cannot be confirmed).
58    #[error("org policy could not be evaluated: {0}")]
59    Policy(OrgError),
60    /// The locally-held copy of the issuer's logs is too stale to trust its revocation status —
61    /// fail closed, since a `rev` may have landed unseen.
62    #[error("revocation freshness: issuer log copy too stale to honor ({0:?})")]
63    RevocationStale(FreshnessDecision),
64}
65
66impl PresentationAuthError {
67    /// The HTTP status: 400 for a malformed request, 403 for insufficient capability or a
68    /// policy denial, else 401.
69    pub fn http_status(&self) -> u16 {
70        match self {
71            PresentationAuthError::Wire(_) | PresentationAuthError::NonceLength => 400,
72            PresentationAuthError::Denied(denied) => denied.http_status(),
73            PresentationAuthError::PolicyDenied { .. } | PresentationAuthError::Policy(_) => 403,
74            PresentationAuthError::Challenge(_)
75            | PresentationAuthError::Resolve(_)
76            | PresentationAuthError::RevocationStale(_) => 401,
77        }
78    }
79}
80
81/// Authenticate an `Auths-Presentation` request, yielding a verified, scoped principal.
82///
83/// Flow: parse the wire shape → consume the single-use challenge (the only place single-use is
84/// enforced) → resolve issuer + subject + delegator KELs → `verify_presentation` against the
85/// relying party's **configured** audience → map the verdict to a [`VerifiedPrincipal`]. The
86/// nonce is consumed only after the cheap wire parse, so a third party cannot burn a legitimate
87/// client's nonce with garbage. This is the interactive challenge path (the v1 default); a TTL
88/// binding has no store entry to consume and is therefore rejected here.
89///
90/// Args:
91/// * `ctx`: Auths context (registry for KEL/TEL/credential resolution).
92/// * `issuer_alias`: The pinned issuer whose namespace holds the presented credential.
93/// * `challenges`: The relying party's single-use challenge store.
94/// * `config_audience`: The relying party's own audience — the trust source, not the wire header.
95/// * `wire`: The `WirePresentation` parsed from the `Authorization` header.
96/// * `now`: Verification time, injected at the boundary.
97///
98/// Usage:
99/// ```ignore
100/// let principal = authenticate_presentation(&ctx, &issuer, &store, &audience, wire, now).await?;
101/// let grant = principal.authorize(&needed_capability)?;
102/// ```
103pub async fn authenticate_presentation(
104    ctx: &AuthsContext,
105    issuer_alias: &KeyAlias,
106    challenges: &dyn ChallengeStore,
107    config_audience: &Audience,
108    wire: WirePresentation,
109    now: DateTime<Utc>,
110) -> Result<VerifiedPrincipal, PresentationAuthError> {
111    let (envelope, presented_audience) = wire.parse().map_err(PresentationAuthError::Wire)?;
112
113    let nonce = binding_nonce(&envelope.binding)?;
114    let expected = challenges
115        .consume(&presented_audience, &nonce, now)
116        .map_err(PresentationAuthError::Challenge)?;
117
118    let inputs = load_presentation_inputs(ctx, issuer_alias, &envelope.credential_said)
119        .map_err(PresentationAuthError::Resolve)?;
120
121    let verdict = verify_presentation(
122        &envelope,
123        &inputs.signed,
124        &inputs.issuer_kel,
125        &inputs.tel,
126        &inputs.receipts,
127        VerifierWitnessPolicy::Warn,
128        &inputs.subject_kel,
129        &inputs.subject_delegator_kel,
130        config_audience.as_str(),
131        Some(expected.as_bytes()),
132        now,
133        // The presentation path now consumes freshness: the verdict is graded and the gate is
134        // `is_trusted`. The independent fresher-issuer-tip source that makes a behind-slice fail
135        // closed is resolved by the revocation-freshness refresh layer; until that is threaded
136        // here, no fresher tip is supplied, so an offline-resolved slice grades Unknown and the
137        // default policy tolerates it.
138        &auths_verifier::freshness::FreshnessPolicy::default(),
139        None,
140        &RingCryptoProvider,
141    )
142    .await;
143
144    // Revocation freshness: when the relying party has configured a staleness source, refuse to
145    // honor a presentation whose issuer's locally-held logs are too stale to trust their revocation
146    // status (a `rev` may have landed unseen). Fail closed; skipped when no source is configured.
147    if let (Some(source), PresentationVerdict::Valid { issuer, .. }) =
148        (&ctx.revocation_freshness, &verdict)
149    {
150        let delegator = Prefix::new_unchecked(
151            issuer
152                .as_str()
153                .strip_prefix("did:keri:")
154                .unwrap_or(issuer.as_str())
155                .to_string(),
156        );
157        let decision = source.freshness(&delegator, now);
158        if !decision.is_honored() {
159            return Err(PresentationAuthError::RevocationStale(decision));
160        }
161    }
162
163    enforce_issuer_policy(ctx, &verdict, now)?;
164
165    VerifiedPrincipal::from_verdict(verdict).map_err(PresentationAuthError::Denied)
166}
167
168/// Enforce the issuer's org policy against a holder-verified presentation (E1 A4).
169///
170/// A no-op unless the verdict is `Valid` AND the issuer anchored an org policy. The
171/// caps/role context comes from the holder-verified credential
172/// ([`context_from_credential`]). A policy deny is a 403 ([`PresentationAuthError::PolicyDenied`])
173/// — distinct from the 401 authentication failures — and a policy that cannot be
174/// evaluated fails closed ([`PresentationAuthError::Policy`]). An issuer with no policy
175/// leaves authentication unchanged (legacy allow).
176fn enforce_issuer_policy(
177    ctx: &AuthsContext,
178    verdict: &PresentationVerdict,
179    now: DateTime<Utc>,
180) -> Result<(), PresentationAuthError> {
181    let PresentationVerdict::Valid {
182        issuer, subject, ..
183    } = verdict
184    else {
185        return Ok(()); // non-Valid verdicts are handled by `from_verdict` (401).
186    };
187    let issuer_prefix = Prefix::new_unchecked(
188        issuer
189            .as_str()
190            .strip_prefix("did:keri:")
191            .unwrap_or(issuer.as_str())
192            .to_string(),
193    );
194
195    let Some(policy) =
196        load_org_policy(ctx, &issuer_prefix).map_err(PresentationAuthError::Policy)?
197    else {
198        return Ok(()); // issuer anchored no policy → legacy allow.
199    };
200
201    let eval_ctx = context_from_credential(verdict, now)
202        .map_err(|e| PresentationAuthError::Policy(OrgError::InvalidDid(e.to_string())))?;
203    let decision = evaluate_with_org_policy(&policy, &eval_ctx);
204    // A5: record every enforcement decision (allow + deny). Gate stays pure.
205    crate::audit::emit_policy_decision(ctx, "request", subject.as_str(), &decision);
206    if decision.is_allowed() {
207        Ok(())
208    } else {
209        Err(PresentationAuthError::PolicyDenied {
210            reason: format!("{} [{}]", decision.message, decision.reason),
211        })
212    }
213}
214
215/// Extract the 32-byte nonce from a presentation binding.
216fn binding_nonce(binding: &PresentationBinding) -> Result<Nonce, PresentationAuthError> {
217    let bytes = match binding {
218        PresentationBinding::Challenge { nonce } => nonce.as_slice(),
219        PresentationBinding::Ttl { nonce, .. } => nonce.as_slice(),
220    };
221    let arr: [u8; NONCE_LEN] = bytes
222        .try_into()
223        .map_err(|_| PresentationAuthError::NonceLength)?;
224    Ok(Nonce::from_bytes(arr))
225}