Skip to main content

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}