identity_kit/vc/validation/mod.rs
1//! Validation of credential and presentation JWTs: signature, binding to the issuer or holder,
2//! validity period, audience, nonce and status.
3//!
4//! The validators take two extension points: a [`KeyResolver`], which turns the `kid` of a JWT
5//! into the public key of its signer and is implemented by the crate's
6//! [`Resolver`], and a [`JwsVerifier`](crate::jose::JwsVerifier), which checks the signature
7//! and is [`DefaultVerifier`](crate::jose::DefaultVerifier) unless an application substitutes
8//! its own. A [`StatusCheck`] is a third, optional one for `credentialStatus`.
9//!
10//! # What is checked
11//!
12//! For a credential ([`JwtCredentialValidator`]):
13//!
14//! 1. The token is a compact JWS whose header has a `kid` that is a DID URL of the form
15//! `did:…#fragment`: a path or DID parameters such as `versionId` are refused, so the signer
16//! cannot pick the document version its key is looked up in (ADR 0004). When `typ` is
17//! present it names the profile: `JWT` for VCDM 1.1, `vc+jwt` for VCDM 2.0.
18//! 2. The payload is a claims set that maps to a [`Credential`](crate::vc::Credential) (or a
19//! [`CredentialV2`](crate::vc::CredentialV2)), with its registered claims agreeing with the
20//! credential.
21//! 3. The key `kid` names is resolved under the `assertionMethod` relationship and verifies the
22//! signature.
23//! 4. The issuer is the DID of `kid`: the key that signed is the issuer's.
24//! 5. `nbf`, `exp` and `iat`, and the credential's own validity period, hold at the validation
25//! time, within the leeway.
26//! 6. `credentialStatus` passes the [`StatusCheck`], per the [`StatusPolicy`].
27//!
28//! For a presentation ([`JwtPresentationValidator`]): the same for the presentation JWT, with
29//! the key resolved under `authentication` and the holder as the signer, plus the expected
30//! audience and nonce; then every credential it carries is validated as above, and its subject
31//! is checked against the holder per the [`SubjectHolderRelationship`].
32//!
33//! Everything a verifier decides by policy (which issuers to trust, which credential types to
34//! accept, what the claims must say) is left to the application, which gets the decoded
35//! credential back. [`decode_credential`], [`decode_credential_v2`] and
36//! [`decode_presentation`] decode without verifying, for a look at a token before deciding how
37//! to validate it; [`extract_issuer`] and [`extract_holder`] read just the signer's DID.
38
39use core::time::Duration;
40use std::sync::Arc;
41
42use chrono::{DateTime, TimeDelta, Utc};
43
44use crate::did::{Did, DidUrl, VerificationRelationship};
45use crate::jose::Jwk;
46use crate::resolver::Resolver;
47use crate::vc::{Jwt, JwtClaims, TypedObject, claims};
48
49mod credential;
50mod error;
51mod presentation;
52
53pub use credential::{
54 DecodedCredential, DecodedCredentialV2, JwtCredentialValidator, decode_credential,
55 decode_credential_v2,
56};
57pub use error::{KeyResolutionError, PresentationValidationError, StatusError, ValidationError};
58pub use presentation::{DecodedPresentation, JwtPresentationValidator, decode_presentation};
59
60/// Finds the public key a JWT was signed with, given the DID URL in its `kid`.
61///
62/// [`Resolver`] implements it by dereferencing the URL; an application can implement it over a
63/// cache, a JWKS, or a fixed set of trusted keys.
64#[async_trait::async_trait]
65pub trait KeyResolver: Send + Sync {
66 /// The public key of the verification method `url` names, which must be usable for
67 /// `relationship`: `assertionMethod` for a credential, `authentication` for a presentation.
68 ///
69 /// # Errors
70 ///
71 /// A [`KeyResolutionError`] when the DID does not resolve, the method does not exist or is
72 /// not usable for `relationship`, or its material cannot be read as a JWK.
73 async fn resolve_key(
74 &self,
75 url: &DidUrl,
76 relationship: VerificationRelationship,
77 ) -> Result<Jwk, KeyResolutionError>;
78}
79
80#[async_trait::async_trait]
81impl KeyResolver for Resolver {
82 async fn resolve_key(
83 &self,
84 url: &DidUrl,
85 relationship: VerificationRelationship,
86 ) -> Result<Jwk, KeyResolutionError> {
87 self.resolve_public_key(url, Some(relationship))
88 .await
89 .map_err(KeyResolutionError::from)
90 }
91}
92
93macro_rules! forward_key_resolver {
94 ($($ty:ty),* $(,)?) => {$(
95 #[async_trait::async_trait]
96 impl<T: KeyResolver + ?Sized> KeyResolver for $ty {
97 async fn resolve_key(
98 &self,
99 url: &DidUrl,
100 relationship: VerificationRelationship,
101 ) -> Result<Jwk, KeyResolutionError> {
102 (**self).resolve_key(url, relationship).await
103 }
104 }
105 )*};
106}
107
108forward_key_resolver!(&T, Box<T>, Arc<T>);
109
110/// Checks the `credentialStatus` of a credential: fetches the status list, bitmap or registry
111/// the entry points to and reports whether the credential is still good.
112///
113/// The crate ships no implementation; the status mechanisms live with the credential formats
114/// that define them. A check that does not understand the entry's type reports
115/// [`StatusError::Unsupported`], which the [`StatusPolicy`] decides about.
116#[async_trait::async_trait]
117pub trait StatusCheck: Send + Sync {
118 /// Checks `status`.
119 ///
120 /// # Errors
121 ///
122 /// A [`StatusError`]: [`Unsupported`](StatusError::Unsupported) for a type the check does
123 /// not know, [`Invalid`](StatusError::Invalid) when the credential is revoked or suspended.
124 async fn check(&self, status: &TypedObject) -> Result<(), StatusError>;
125}
126
127/// What to do about a `credentialStatus` entry.
128#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
129#[non_exhaustive]
130pub enum StatusPolicy {
131 /// Every entry must pass the validator's [`StatusCheck`]; without one, or for a type it
132 /// does not support, validation fails.
133 #[default]
134 Strict,
135 /// Entries of a type the [`StatusCheck`] does not support, or any entry when there is no
136 /// check, are ignored.
137 SkipUnsupported,
138 /// `credentialStatus` is ignored.
139 SkipAll,
140}
141
142/// Whether to stop at the first failing credential of a presentation.
143#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
144#[non_exhaustive]
145pub enum FailFast {
146 /// Stop at the first credential that fails.
147 #[default]
148 FirstError,
149 /// Validate every credential and report all failures.
150 AllErrors,
151}
152
153/// How the subject of a presented credential must relate to the holder presenting it.
154#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
155#[non_exhaustive]
156pub enum SubjectHolderRelationship {
157 /// Every subject of every credential must be the holder. A bearer credential fails.
158 #[default]
159 AlwaysSubject,
160 /// As [`AlwaysSubject`](Self::AlwaysSubject), but only for credentials whose
161 /// `nonTransferable` property is `true`.
162 SubjectOnNonTransferable,
163 /// Not checked.
164 Any,
165}
166
167/// The validation time and the clock skew allowed around it.
168///
169/// # Examples
170///
171/// ```
172/// use std::time::Duration;
173///
174/// use chrono::{TimeZone as _, Utc};
175/// use identity_kit::vc::validation::TimeOptions;
176///
177/// let fixed = TimeOptions::at(Utc.with_ymd_and_hms(2019, 11, 6, 0, 0, 0).unwrap())
178/// .with_leeway(Duration::from_secs(60));
179/// assert_eq!(fixed.now().to_rfc3339(), "2019-11-06T00:00:00+00:00");
180/// assert_eq!(fixed.leeway, Duration::from_secs(60));
181/// assert!(TimeOptions::default().now() > fixed.now());
182/// ```
183#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
184#[non_exhaustive]
185pub struct TimeOptions {
186 /// The validation time; the current time when `None`.
187 pub now: Option<DateTime<Utc>>,
188 /// How far `nbf`, `exp` and `iat` may miss the validation time and still pass; zero by
189 /// default.
190 pub leeway: Duration,
191}
192
193impl TimeOptions {
194 /// Options validating at `now`.
195 pub fn at(now: DateTime<Utc>) -> Self {
196 Self {
197 now: Some(now),
198 leeway: Duration::ZERO,
199 }
200 }
201
202 /// These options with `leeway`.
203 #[must_use]
204 pub fn with_leeway(mut self, leeway: Duration) -> Self {
205 self.leeway = leeway;
206 self
207 }
208
209 /// The validation time.
210 pub fn now(&self) -> DateTime<Utc> {
211 self.now.unwrap_or_else(Utc::now)
212 }
213
214 /// The leeway as a `chrono` delta, saturated.
215 fn delta(&self) -> TimeDelta {
216 TimeDelta::from_std(self.leeway).unwrap_or(TimeDelta::MAX)
217 }
218
219 /// Checks that `now` is within `[not_before, expires)` with the leeway, and that
220 /// `issued_at` is not in the future.
221 ///
222 /// # Errors
223 ///
224 /// [`ValidationError::NotYetValid`], [`ValidationError::Expired`] or
225 /// [`ValidationError::IssuedInFuture`].
226 pub fn check(
227 &self,
228 not_before: Option<DateTime<Utc>>,
229 expires: Option<DateTime<Utc>>,
230 issued_at: Option<DateTime<Utc>>,
231 ) -> Result<(), ValidationError> {
232 let now = self.now();
233 let delta = self.delta();
234 // A leeway past the range `chrono` represents saturates rather than overflowing.
235 let latest = now
236 .checked_add_signed(delta)
237 .unwrap_or(DateTime::<Utc>::MAX_UTC);
238 let earliest = now
239 .checked_sub_signed(delta)
240 .unwrap_or(DateTime::<Utc>::MIN_UTC);
241 if let Some(not_before) = not_before
242 && not_before > latest
243 {
244 return Err(ValidationError::NotYetValid { not_before, now });
245 }
246 if let Some(expired_at) = expires
247 && expired_at <= earliest
248 {
249 return Err(ValidationError::Expired { expired_at, now });
250 }
251 if let Some(issued_at) = issued_at
252 && issued_at > latest
253 {
254 return Err(ValidationError::IssuedInFuture { issued_at, now });
255 }
256 Ok(())
257 }
258
259 /// Checks the registered time claims of `claims`: `nbf`, `exp` and `iat`.
260 ///
261 /// # Errors
262 ///
263 /// As [`check`](Self::check), or [`ValidationError::Credential`] for a claim out of
264 /// range.
265 pub fn check_claims(&self, claims: &JwtClaims) -> Result<(), ValidationError> {
266 self.check(
267 timestamp(claims.nbf, "nbf")?,
268 timestamp(claims.exp, "exp")?,
269 timestamp(claims.iat, "iat")?,
270 )
271 }
272}
273
274/// The time a `NumericDate` claim names.
275fn timestamp(
276 value: Option<i64>,
277 claim: &'static str,
278) -> Result<Option<DateTime<Utc>>, ValidationError> {
279 value
280 .map(|value| {
281 JwtClaims::timestamp(value).ok_or_else(|| {
282 ValidationError::Credential(crate::vc::CredentialError::InvalidClaim {
283 claim,
284 reason: format!("{value} is out of range"),
285 })
286 })
287 })
288 .transpose()
289}
290
291/// Options of [`JwtCredentialValidator`].
292#[derive(Debug, Clone, Default, PartialEq, Eq)]
293#[non_exhaustive]
294pub struct CredentialValidationOptions {
295 /// The validation time and leeway.
296 pub time: TimeOptions,
297 /// What to do about `credentialStatus`.
298 pub status: StatusPolicy,
299}
300
301impl CredentialValidationOptions {
302 /// Default options: validate now without leeway, [`StatusPolicy::Strict`].
303 pub fn new() -> Self {
304 Self::default()
305 }
306
307 /// These options with `time`.
308 #[must_use]
309 pub fn with_time(mut self, time: TimeOptions) -> Self {
310 self.time = time;
311 self
312 }
313
314 /// These options with `status`.
315 #[must_use]
316 pub fn with_status(mut self, status: StatusPolicy) -> Self {
317 self.status = status;
318 self
319 }
320}
321
322/// Options of [`JwtPresentationValidator`].
323#[derive(Debug, Clone, Default, PartialEq, Eq)]
324#[non_exhaustive]
325pub struct PresentationValidationOptions {
326 /// The validation time and leeway of the presentation JWT.
327 pub time: TimeOptions,
328 /// The audience the presentation must name in `aud`, if any is required.
329 pub audience: Option<String>,
330 /// The `nonce` the presentation must carry, if one is required.
331 pub nonce: Option<String>,
332 /// How credential subjects must relate to the holder.
333 pub subject_holder: SubjectHolderRelationship,
334 /// The options every presented credential is validated with.
335 pub credentials: CredentialValidationOptions,
336 /// Whether to stop at the first failing credential.
337 pub fail_fast: FailFast,
338}
339
340impl PresentationValidationOptions {
341 /// Default options: validate now without leeway, no audience or nonce required,
342 /// [`SubjectHolderRelationship::AlwaysSubject`], default credential options,
343 /// [`FailFast::FirstError`].
344 pub fn new() -> Self {
345 Self::default()
346 }
347
348 /// These options with `time`, for the presentation JWT only.
349 #[must_use]
350 pub fn with_time(mut self, time: TimeOptions) -> Self {
351 self.time = time;
352 self
353 }
354
355 /// These options requiring `audience`.
356 #[must_use]
357 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
358 self.audience = Some(audience.into());
359 self
360 }
361
362 /// These options requiring `nonce`.
363 #[must_use]
364 pub fn with_nonce(mut self, nonce: impl Into<String>) -> Self {
365 self.nonce = Some(nonce.into());
366 self
367 }
368
369 /// These options with `relationship`.
370 #[must_use]
371 pub fn with_subject_holder(mut self, relationship: SubjectHolderRelationship) -> Self {
372 self.subject_holder = relationship;
373 self
374 }
375
376 /// These options validating credentials with `credentials`.
377 #[must_use]
378 pub fn with_credentials(mut self, credentials: CredentialValidationOptions) -> Self {
379 self.credentials = credentials;
380 self
381 }
382
383 /// These options with `fail_fast`.
384 #[must_use]
385 pub fn with_fail_fast(mut self, fail_fast: FailFast) -> Self {
386 self.fail_fast = fail_fast;
387 self
388 }
389}
390
391/// The DID of the issuer of a credential JWT, read without verifying anything: `iss`, else
392/// the `issuer` of the `vc` claim (VCDM 1.1) or of the payload (VCDM 2.0).
393///
394/// # Errors
395///
396/// [`ValidationError::Jws`] or [`ValidationError::Payload`] when the token does not decode,
397/// [`ValidationError::Credential`] when it names no issuer, and [`ValidationError::NotDid`]
398/// when the issuer is not a DID.
399pub fn extract_issuer(jwt: &Jwt) -> Result<Did, ValidationError> {
400 let claims = decode_claims(jwt)?;
401 let issuer = claims
402 .iss
403 .clone()
404 .or_else(|| property_id(&claims, "vc", "issuer"))
405 .ok_or(crate::vc::CredentialError::MissingClaim("iss"))?;
406 Did::parse(&issuer).map_err(|_| ValidationError::NotDid {
407 role: "issuer",
408 id: issuer,
409 })
410}
411
412/// The DID of the holder of a presentation JWT, read without verifying anything: `iss`, else
413/// the `holder` of the `vp` claim (VCDM 1.1) or of the payload (VCDM 2.0).
414///
415/// # Errors
416///
417/// As [`extract_issuer`], for the holder.
418pub fn extract_holder(jwt: &Jwt) -> Result<Did, ValidationError> {
419 let claims = decode_claims(jwt)?;
420 let holder = claims
421 .iss
422 .clone()
423 .or_else(|| property_id(&claims, "vp", "holder"))
424 .ok_or(crate::vc::CredentialError::MissingClaim("iss"))?;
425 Did::parse(&holder).map_err(|_| ValidationError::NotDid {
426 role: "holder",
427 id: holder,
428 })
429}
430
431/// The identifier of `property` in the `container` claim (VCDM 1.1), or in the payload.
432fn property_id(claims: &JwtClaims, container: &str, property: &str) -> Option<String> {
433 let value = match claims.additional.get(container) {
434 Some(container) => container.get(property)?,
435 None => claims.additional.get(property)?,
436 };
437 claims::id_of(value).map(str::to_owned)
438}
439
440/// The claims set of `jwt`, unverified.
441pub(super) fn decode_claims(jwt: &Jwt) -> Result<JwtClaims, ValidationError> {
442 let jws = jwt.decode()?;
443 serde_json::from_slice(jws.payload()).map_err(|e| ValidationError::Payload(e.to_string()))
444}