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}