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::error::CredentialError;
26use crate::domains::credentials::present_inputs::load_presentation_inputs;
27use crate::domains::org::error::OrgError;
28use crate::domains::org::policy::{evaluate_with_org_policy, load_org_policy};
29
30/// Errors from [`authenticate_presentation`] (`thiserror`, exhaustive).
31#[derive(Debug, thiserror::Error)]
32pub enum PresentationAuthError {
33    /// The `Auths-Presentation` wire shape was malformed.
34    #[error("wire: {0}")]
35    Wire(WireError),
36    /// The presented nonce was not exactly 32 bytes.
37    #[error("nonce must be 32 bytes")]
38    NonceLength,
39    /// No live single-use challenge matched (absent, replayed, or expired).
40    #[error("challenge: {0}")]
41    Challenge(ChallengeError),
42    /// The issuer/subject KELs or the credential could not be resolved.
43    #[error("resolve: {0}")]
44    Resolve(CredentialError),
45    /// The presentation verified-as-a-process but was not honored (see the inner reason).
46    #[error("denied: {0}")]
47    Denied(Denied),
48    /// The presentation verified and the holder is authenticated, but the issuer's org
49    /// policy denied the principal (403, not 401 — authentication succeeded).
50    #[error("org policy denied: {reason}")]
51    PolicyDenied {
52        /// The typed denial reason from the policy engine.
53        reason: String,
54    },
55    /// The issuer's org policy could not be loaded or evaluated — fail closed (treated
56    /// as a denial; the principal is not authorized when policy cannot be confirmed).
57    #[error("org policy could not be evaluated: {0}")]
58    Policy(OrgError),
59}
60
61impl PresentationAuthError {
62    /// The HTTP status: 400 for a malformed request, 403 for insufficient capability or a
63    /// policy denial, else 401.
64    pub fn http_status(&self) -> u16 {
65        match self {
66            PresentationAuthError::Wire(_) | PresentationAuthError::NonceLength => 400,
67            PresentationAuthError::Denied(denied) => denied.http_status(),
68            PresentationAuthError::PolicyDenied { .. } | PresentationAuthError::Policy(_) => 403,
69            PresentationAuthError::Challenge(_) | PresentationAuthError::Resolve(_) => 401,
70        }
71    }
72}
73
74/// Authenticate an `Auths-Presentation` request, yielding a verified, scoped principal.
75///
76/// Flow: parse the wire shape → consume the single-use challenge (the only place single-use is
77/// enforced) → resolve issuer + subject + delegator KELs → `verify_presentation` against the
78/// relying party's **configured** audience → map the verdict to a [`VerifiedPrincipal`]. The
79/// nonce is consumed only after the cheap wire parse, so a third party cannot burn a legitimate
80/// client's nonce with garbage. This is the interactive challenge path (the v1 default); a TTL
81/// binding has no store entry to consume and is therefore rejected here.
82///
83/// Args:
84/// * `ctx`: Auths context (registry for KEL/TEL/credential resolution).
85/// * `issuer_alias`: The pinned issuer whose namespace holds the presented credential.
86/// * `challenges`: The relying party's single-use challenge store.
87/// * `config_audience`: The relying party's own audience — the trust source, not the wire header.
88/// * `wire`: The `WirePresentation` parsed from the `Authorization` header.
89/// * `now`: Verification time, injected at the boundary.
90///
91/// Usage:
92/// ```ignore
93/// let principal = authenticate_presentation(&ctx, &issuer, &store, &audience, wire, now).await?;
94/// let grant = principal.authorize(&needed_capability)?;
95/// ```
96pub async fn authenticate_presentation(
97    ctx: &AuthsContext,
98    issuer_alias: &KeyAlias,
99    challenges: &dyn ChallengeStore,
100    config_audience: &Audience,
101    wire: WirePresentation,
102    now: DateTime<Utc>,
103) -> Result<VerifiedPrincipal, PresentationAuthError> {
104    let (envelope, presented_audience) = wire.parse().map_err(PresentationAuthError::Wire)?;
105
106    let nonce = binding_nonce(&envelope.binding)?;
107    let expected = challenges
108        .consume(&presented_audience, &nonce, now)
109        .map_err(PresentationAuthError::Challenge)?;
110
111    let inputs = load_presentation_inputs(ctx, issuer_alias, &envelope.credential_said)
112        .map_err(PresentationAuthError::Resolve)?;
113
114    let verdict = verify_presentation(
115        &envelope,
116        &inputs.signed,
117        &inputs.issuer_kel,
118        &inputs.tel,
119        &inputs.receipts,
120        VerifierWitnessPolicy::Warn,
121        &inputs.subject_kel,
122        &inputs.subject_delegator_kel,
123        config_audience.as_str(),
124        Some(expected.as_bytes()),
125        now,
126        &RingCryptoProvider,
127    )
128    .await;
129
130    enforce_issuer_policy(ctx, &verdict, now)?;
131
132    VerifiedPrincipal::from_verdict(verdict).map_err(PresentationAuthError::Denied)
133}
134
135/// Enforce the issuer's org policy against a holder-verified presentation (E1 A4).
136///
137/// A no-op unless the verdict is `Valid` AND the issuer anchored an org policy. The
138/// caps/role context comes from the holder-verified credential
139/// ([`context_from_credential`]). A policy deny is a 403 ([`PresentationAuthError::PolicyDenied`])
140/// — distinct from the 401 authentication failures — and a policy that cannot be
141/// evaluated fails closed ([`PresentationAuthError::Policy`]). An issuer with no policy
142/// leaves authentication unchanged (legacy allow).
143fn enforce_issuer_policy(
144    ctx: &AuthsContext,
145    verdict: &PresentationVerdict,
146    now: DateTime<Utc>,
147) -> Result<(), PresentationAuthError> {
148    let PresentationVerdict::Valid {
149        issuer, subject, ..
150    } = verdict
151    else {
152        return Ok(()); // non-Valid verdicts are handled by `from_verdict` (401).
153    };
154    let issuer_prefix = Prefix::new_unchecked(
155        issuer
156            .as_str()
157            .strip_prefix("did:keri:")
158            .unwrap_or(issuer.as_str())
159            .to_string(),
160    );
161
162    let Some(policy) =
163        load_org_policy(ctx, &issuer_prefix).map_err(PresentationAuthError::Policy)?
164    else {
165        return Ok(()); // issuer anchored no policy → legacy allow.
166    };
167
168    let eval_ctx = context_from_credential(verdict, now)
169        .map_err(|e| PresentationAuthError::Policy(OrgError::InvalidDid(e.to_string())))?;
170    let decision = evaluate_with_org_policy(&policy, &eval_ctx);
171    // A5: record every enforcement decision (allow + deny). Gate stays pure.
172    crate::audit::emit_policy_decision(ctx, "request", subject.as_str(), &decision);
173    if decision.is_allowed() {
174        Ok(())
175    } else {
176        Err(PresentationAuthError::PolicyDenied {
177            reason: format!("{} [{}]", decision.message, decision.reason),
178        })
179    }
180}
181
182/// Extract the 32-byte nonce from a presentation binding.
183fn binding_nonce(binding: &PresentationBinding) -> Result<Nonce, PresentationAuthError> {
184    let bytes = match binding {
185        PresentationBinding::Challenge { nonce } => nonce.as_slice(),
186        PresentationBinding::Ttl { nonce, .. } => nonce.as_slice(),
187    };
188    let arr: [u8; NONCE_LEN] = bytes
189        .try_into()
190        .map_err(|_| PresentationAuthError::NonceLength)?;
191    Ok(Nonce::from_bytes(arr))
192}