Skip to main content

identity_kit/sd_jwt_vc/
validation.rs

1//! Validation of SD-JWT credentials: issuer signature and binding, time claims, key binding,
2//! status.
3//!
4//! [`SdJwtCredentialValidator`] is the SD-JWT counterpart of
5//! [`JwtCredentialValidator`](crate::vc::validation::JwtCredentialValidator), over the same
6//! [`KeyResolver`] and [`JwsVerifier`]. It validates both SD-JWT credential types
7//! ([two SD-JWT credential types](super#two-sd-jwt-credential-types)):
8//! [`validate`](SdJwtCredentialValidator::validate) an SD-JWT VC,
9//! [`validate_v2`](SdJwtCredentialValidator::validate_v2) a VCDM 2.0 credential secured with
10//! SD-JWT. Each checks, in order:
11//!
12//! 1. The SD-JWT parses as an [`SdJwtVc`] (`typ`, disclosures, claims; RFC 9901 §7.1 steps 3
13//!    to 5, SD-JWT VC §2.2) or as an [`SdJwtCredentialV2`] (`typ` `vc+sd-jwt` when present,
14//!    or one of `options.additional_typ`; disclosures; the payload as a
15//!    [`CredentialV2`](crate::vc::CredentialV2) with its registered claims agreeing with it).
16//! 2. The header of the issuer-signed JWT names its key ([`KeyReference::from_jws`], with the
17//!    issuer, `iss` or the credential's `issuer`, as `iss`): a `kid` that is a DID URL of the
18//!    form `did:…#fragment` (a path or DID parameters such as `versionId` are refused, as in
19//!    the JWT validators, ADR 0004), a `#fragment` relative to a DID issuer, a `kid` into the
20//!    JWT VC issuer metadata of an HTTPS issuer (SD-JWT VC §3.5, resolved by `JwksResolver`
21//!    with the `jwks` feature), or a `jwk`, `x5c` or `jku` header. The key is resolved for
22//!    [`KeyRole::Issuer`], verifies the signature, and belongs to the issuer
23//!    ([`KeyReference::binds_to`]).
24//! 3. `nbf`, `exp` and `iat` hold at the validation time, and so do `validFrom` and
25//!    `validUntil` of a VCDM 2.0 credential.
26//! 4. When the options require key binding (RFC 9901 §7.3): the SD-JWT+KB carries a key
27//!    binding JWT with `typ: kb+jwt`, signed by the holder's key, for the expected audience
28//!    and nonce, issued within the window, with an `sd_hash` over the presented SD-JWT. The
29//!    holder's key is `cnf.jwk`, used as it is since the issuer signed it, or else the key
30//!    `cnf.kid` names, an absolute DID URL resolved for [`KeyRole::Subject`]
31//!    (`authentication`); see [`KeyReference::from_confirmation`].
32//! 5. `status` (as [`CredentialStatus::StatusClaim`]) and the `credentialStatus` entries of a VCDM
33//!    2.0 credential (as [`CredentialStatus::Vcdm`]) pass the [`StatusCheck`], per the
34//!    [`StatusPolicy`].
35//!
36//! Type metadata is a separate step the application runs when its policy asks for it:
37//! [`TypeMetadata::check_claims`](super::TypeMetadata::check_claims).
38
39use core::time::Duration;
40use std::collections::BTreeSet;
41
42use chrono::{DateTime, Utc};
43
44use super::{
45    Confirmation, SdJwt, SdJwtCredentialV2, SdJwtCredentialV2Error, SdJwtVc, SdJwtVcError,
46};
47use crate::did::Uri;
48use crate::jose::{DefaultVerifier, Jwk, JwsVerifier};
49use crate::resolver::{KeyReference, KeyReferenceError, KeyResolver, KeyRole};
50use crate::vc::validation::{
51    CredentialStatus, StatusCheck, StatusPolicy, TimeOptions, ValidationError, check_statuses,
52};
53use crate::vc::{CredentialError, JwtClaims};
54
55/// Moved to [`status_list::StatusListCheck`](crate::status_list::StatusListCheck).
56#[cfg(feature = "did-web")]
57#[cfg_attr(docsrs, doc(cfg(feature = "did-web")))]
58#[deprecated(since = "0.3.0", note = "moved to `status_list::StatusListCheck`")]
59pub type StatusListCheck<C, R, V = DefaultVerifier> = crate::status_list::StatusListCheck<C, R, V>;
60
61/// What a key binding JWT must prove (RFC 9901 §7.3, step 5).
62///
63/// The key binding JWT must be signed by the holder's key the credential's `cnf` names: by
64/// value in `cnf.jwk`, or by reference in `cnf.kid`, an absolute DID URL the validator's
65/// [`KeyResolver`] resolves for [`KeyRole::Subject`] (`authentication`).
66#[derive(Debug, Clone, PartialEq, Eq)]
67#[non_exhaustive]
68pub struct KeyBindingOptions {
69    /// The audience the key binding JWT must name: this verifier.
70    pub audience: String,
71    /// The nonce it must carry: the one of this transaction.
72    pub nonce: String,
73    /// The time its `iat` is checked against, with leeway.
74    pub time: TimeOptions,
75    /// How old it may be, if bounded.
76    pub max_age: Option<Duration>,
77}
78
79impl KeyBindingOptions {
80    /// Key binding for `audience` with `nonce`, checked at the current time with no age
81    /// bound.
82    pub fn new(audience: impl Into<String>, nonce: impl Into<String>) -> Self {
83        Self {
84            audience: audience.into(),
85            nonce: nonce.into(),
86            time: TimeOptions::default(),
87            max_age: None,
88        }
89    }
90
91    /// These options checking `iat` against `time`.
92    #[must_use]
93    pub fn with_time(mut self, time: TimeOptions) -> Self {
94        self.time = time;
95        self
96    }
97
98    /// These options rejecting a key binding JWT older than `max_age`.
99    #[must_use]
100    pub fn with_max_age(mut self, max_age: Duration) -> Self {
101        self.max_age = Some(max_age);
102        self
103    }
104}
105
106/// Options of [`SdJwtCredentialValidator`].
107#[derive(Debug, Clone, Default, PartialEq, Eq)]
108#[non_exhaustive]
109pub struct SdJwtValidationOptions {
110    /// The validation time and leeway of the issuer-signed JWT.
111    pub time: TimeOptions,
112    /// The key binding required, if any; a key binding JWT is not verified otherwise.
113    pub key_binding: Option<KeyBindingOptions>,
114    /// What to do about `status` (and `credentialStatus`).
115    pub status: StatusPolicy,
116    /// Whether to reject the pre-standard `typ: vc+sd-jwt` on an SD-JWT VC.
117    pub reject_legacy_typ: bool,
118    /// Header `typ` values accepted for a VCDM 2.0 credential besides `vc+sd-jwt`; none by
119    /// default. A token without `typ` is always accepted there. An SD-JWT VC's `typ` is fixed
120    /// by its specification and not affected.
121    pub additional_typ: BTreeSet<String>,
122}
123
124impl SdJwtValidationOptions {
125    /// Default options: validate now without leeway, no key binding required,
126    /// [`StatusPolicy::Strict`], `vc+sd-jwt` accepted.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// These options with `time`.
132    #[must_use]
133    pub fn with_time(mut self, time: TimeOptions) -> Self {
134        self.time = time;
135        self
136    }
137
138    /// These options requiring `key_binding`.
139    #[must_use]
140    pub fn with_key_binding(mut self, key_binding: KeyBindingOptions) -> Self {
141        self.key_binding = Some(key_binding);
142        self
143    }
144
145    /// These options with `status`.
146    #[must_use]
147    pub fn with_status(mut self, status: StatusPolicy) -> Self {
148        self.status = status;
149        self
150    }
151
152    /// These options rejecting `typ: vc+sd-jwt` on an SD-JWT VC.
153    #[must_use]
154    pub fn rejecting_legacy_typ(mut self) -> Self {
155        self.reject_legacy_typ = true;
156        self
157    }
158
159    /// These options also accepting a VCDM 2.0 credential whose header `typ` is `typ`.
160    #[must_use]
161    pub fn with_additional_typ(mut self, typ: impl Into<String>) -> Self {
162        self.additional_typ.insert(typ.into());
163        self
164    }
165}
166
167/// Why an SD-JWT credential did not validate.
168#[derive(Debug, thiserror::Error)]
169#[non_exhaustive]
170pub enum SdJwtValidationError {
171    /// The SD-JWT is not a well-formed SD-JWT VC.
172    #[error(transparent)]
173    Credential(#[from] SdJwtVcError),
174    /// The SD-JWT is not a well-formed VCDM 2.0 credential secured with SD-JWT.
175    #[error(transparent)]
176    CredentialV2(#[from] SdJwtCredentialV2Error),
177    /// The issuer-signed JWT has the pre-standard `typ: vc+sd-jwt`, which the options reject.
178    #[error("header `typ` is the pre-standard `vc+sd-jwt`")]
179    LegacyTyp,
180    /// A check shared with the JWT validators failed: `kid`, key resolution, signature,
181    /// issuer binding, time, audience, nonce, status.
182    #[error(transparent)]
183    Validation(#[from] ValidationError),
184    /// Key binding is required and the SD-JWT carries no key binding JWT.
185    #[error("key binding is required, but the SD-JWT has no key binding JWT")]
186    KeyBindingRequired,
187    /// Key binding is required and `cnf` names no key this crate can look for: there is no
188    /// `cnf`, or [`KeyReference::from_confirmation`] refuses it.
189    #[error("key binding is required, but `cnf` carries no usable key reference: {0}")]
190    NoHolderKey(KeyReferenceError),
191    /// The key binding JWT is older than allowed.
192    #[error("key binding JWT issued at {} is older than {max_age:?} (now is {})", issued_at.to_rfc3339(), now.to_rfc3339())]
193    KeyBindingTooOld {
194        /// Its `iat`.
195        issued_at: DateTime<Utc>,
196        /// The validation time.
197        now: DateTime<Utc>,
198        /// The bound.
199        max_age: Duration,
200    },
201    /// The key binding JWT's `sd_hash` is not the digest of the presented SD-JWT.
202    #[error("key binding `sd_hash` {found:?} is not the digest {expected:?} of the SD-JWT")]
203    SdHashMismatch {
204        /// The digest of the SD-JWT.
205        expected: String,
206        /// The `sd_hash` found.
207        found: Option<String>,
208    },
209}
210
211/// A validated SD-JWT VC.
212#[derive(Debug, Clone)]
213#[non_exhaustive]
214pub struct ValidatedSdJwtVc {
215    /// The credential, with the presented disclosures applied.
216    pub credential: SdJwtVc,
217    /// Where the issuer-signed JWT says its key is.
218    pub issuer_key: KeyReference,
219    /// The claims of the key binding JWT, when key binding was required and verified.
220    pub key_binding: Option<JwtClaims>,
221    /// The holder's key that verified the key binding JWT, and where `cnf` said it is (a DID
222    /// URL to compare with the presentation's holder or the credential subject, say), when key
223    /// binding was required and verified.
224    pub holder_key: Option<(KeyReference, Jwk)>,
225}
226
227/// A validated VCDM 2.0 credential secured with SD-JWT.
228#[derive(Debug, Clone)]
229#[non_exhaustive]
230pub struct ValidatedSdJwtCredentialV2 {
231    /// The credential, with the presented disclosures applied.
232    pub credential: SdJwtCredentialV2,
233    /// Where the issuer-signed JWT says its key is.
234    pub issuer_key: KeyReference,
235    /// The claims of the key binding JWT, when key binding was required and verified.
236    pub key_binding: Option<JwtClaims>,
237    /// The holder's key that verified the key binding JWT, and where `cnf` said it is, when key
238    /// binding was required and verified.
239    pub holder_key: Option<(KeyReference, Jwk)>,
240}
241
242/// Validates SD-JWT credentials; see the [module documentation](self) for the checks.
243pub struct SdJwtCredentialValidator<R, V = DefaultVerifier> {
244    resolver: R,
245    verifier: V,
246    status: Option<Box<dyn StatusCheck>>,
247}
248
249impl<R: KeyResolver, V: JwsVerifier> SdJwtCredentialValidator<R, V> {
250    /// A validator resolving keys with `resolver` and verifying signatures with `verifier`,
251    /// without a status check.
252    pub fn new(resolver: R, verifier: V) -> Self {
253        Self {
254            resolver,
255            verifier,
256            status: None,
257        }
258    }
259
260    /// This validator checking the `status` claim and, for a VCDM 2.0 credential, the
261    /// `credentialStatus` entries with `check`.
262    #[must_use]
263    pub fn with_status_check(mut self, check: impl StatusCheck + 'static) -> Self {
264        self.status = Some(Box::new(check));
265        self
266    }
267
268    /// The key resolver.
269    pub fn resolver(&self) -> &R {
270        &self.resolver
271    }
272
273    /// The signature verifier.
274    pub fn verifier(&self) -> &V {
275        &self.verifier
276    }
277
278    /// Whether a status check is configured.
279    pub fn has_status_check(&self) -> bool {
280        self.status.is_some()
281    }
282
283    /// Validates `sd_jwt` as an SD-JWT VC.
284    ///
285    /// # Errors
286    ///
287    /// The first [`SdJwtValidationError`] found, in the order the [module
288    /// documentation](self) lists the checks.
289    pub async fn validate(
290        &self,
291        sd_jwt: &SdJwt,
292        options: &SdJwtValidationOptions,
293    ) -> Result<ValidatedSdJwtVc, SdJwtValidationError> {
294        let credential = SdJwtVc::from_sd_jwt(sd_jwt.clone())?;
295        self.validate_credential(credential, options).await
296    }
297
298    /// Validates the parsed `credential`.
299    ///
300    /// # Errors
301    ///
302    /// As [`validate`](Self::validate).
303    pub async fn validate_credential(
304        &self,
305        credential: SdJwtVc,
306        options: &SdJwtValidationOptions,
307    ) -> Result<ValidatedSdJwtVc, SdJwtValidationError> {
308        if options.reject_legacy_typ && credential.has_legacy_typ() {
309            return Err(SdJwtValidationError::LegacyTyp);
310        }
311        let claims = credential.claims();
312        let issuer = claims
313            .iss
314            .as_deref()
315            .ok_or(CredentialError::MissingClaim("iss"))
316            .map_err(ValidationError::from)?;
317        let (issuer_key, key) = self
318            .verify_issuer_signature(credential.sd_jwt(), issuer)
319            .await?;
320        check_issuer(issuer, &issuer_key, &key)?;
321
322        let mut time_claims = JwtClaims::new();
323        time_claims.nbf = claims.nbf;
324        time_claims.exp = claims.exp;
325        time_claims.iat = claims.iat;
326        options.time.check_claims(&time_claims)?;
327
328        let (key_binding, holder_key) = match &options.key_binding {
329            Some(required) => {
330                let (claims, holder_key) = self
331                    .check_key_binding(credential.sd_jwt(), credential.confirmation(), required)
332                    .await?;
333                (Some(claims), Some(holder_key))
334            }
335            None => (None, None),
336        };
337
338        self.check_status(
339            claims
340                .status
341                .iter()
342                .map(CredentialStatus::StatusClaim)
343                .collect(),
344            options,
345        )
346        .await?;
347
348        Ok(ValidatedSdJwtVc {
349            credential,
350            issuer_key,
351            key_binding,
352            holder_key,
353        })
354    }
355
356    /// Validates `sd_jwt` as a VCDM 2.0 credential secured with SD-JWT (VC-JOSE-COSE §3.2.1).
357    ///
358    /// # Errors
359    ///
360    /// The first [`SdJwtValidationError`] found, in the order the [module
361    /// documentation](self) lists the checks.
362    pub async fn validate_v2(
363        &self,
364        sd_jwt: &SdJwt,
365        options: &SdJwtValidationOptions,
366    ) -> Result<ValidatedSdJwtCredentialV2, SdJwtValidationError> {
367        let credential =
368            SdJwtCredentialV2::from_sd_jwt_with(sd_jwt.clone(), &options.additional_typ)?;
369        self.validate_credential_v2(credential, options).await
370    }
371
372    /// Validates the parsed `credential`.
373    ///
374    /// # Errors
375    ///
376    /// As [`validate_v2`](Self::validate_v2).
377    pub async fn validate_credential_v2(
378        &self,
379        credential: SdJwtCredentialV2,
380        options: &SdJwtValidationOptions,
381    ) -> Result<ValidatedSdJwtCredentialV2, SdJwtValidationError> {
382        let validated = self
383            .validate_credential_v2_unchecked_status(credential, options)
384            .await?;
385        let credential = &validated.credential;
386        self.check_status(
387            credential
388                .credential()
389                .statuses()
390                .map(CredentialStatus::Vcdm)
391                .chain(credential.status().map(CredentialStatus::StatusClaim))
392                .collect(),
393            options,
394        )
395        .await?;
396        Ok(validated)
397    }
398
399    /// Steps 2 to 4 of [`validate_credential_v2`](Self::validate_credential_v2): everything
400    /// but the status checks, which the presentation validator runs with its own check.
401    pub(crate) async fn validate_credential_v2_unchecked_status(
402        &self,
403        credential: SdJwtCredentialV2,
404        options: &SdJwtValidationOptions,
405    ) -> Result<ValidatedSdJwtCredentialV2, SdJwtValidationError> {
406        let vc = credential.credential();
407        let issuer = vc.issuer.id().as_str();
408        let (issuer_key, key) = self
409            .verify_issuer_signature(credential.sd_jwt(), issuer)
410            .await?;
411        check_issuer(issuer, &issuer_key, &key)?;
412        options.time.check_claims(credential.claims())?;
413        options.time.check(vc.valid_from, vc.valid_until, None)?;
414        let (key_binding, holder_key) = match &options.key_binding {
415            Some(required) => {
416                let (claims, holder_key) = self
417                    .check_key_binding(credential.sd_jwt(), credential.confirmation(), required)
418                    .await?;
419                (Some(claims), Some(holder_key))
420            }
421            None => (None, None),
422        };
423        Ok(ValidatedSdJwtCredentialV2 {
424            credential,
425            issuer_key,
426            key_binding,
427            holder_key,
428        })
429    }
430
431    /// Resolves the key the issuer-signed JWT names, `issuer` being its issuer, for
432    /// [`KeyRole::Issuer`] and verifies the signature; the reference and the key.
433    async fn verify_issuer_signature(
434        &self,
435        sd_jwt: &SdJwt,
436        issuer: &str,
437    ) -> Result<(KeyReference, Jwk), ValidationError> {
438        let jws = sd_jwt.jwt().decode()?;
439        let reference = KeyReference::from_jws(&jws, Some(issuer))?;
440        let key = self
441            .resolver
442            .resolve_key(&reference, KeyRole::Issuer)
443            .await
444            .map_err(|source| ValidationError::KeyResolution {
445                key: Box::new(reference.clone()),
446                source,
447            })?;
448        jws.verify(&self.verifier, &key)?;
449        Ok((reference, key))
450    }
451
452    /// The holder's key `cnf` names: `cnf.jwk` as it is, since the issuer signed it, or the
453    /// key a `cnf.kid` resolves to for [`KeyRole::Subject`].
454    async fn holder_key(
455        &self,
456        cnf: Option<&Confirmation>,
457    ) -> Result<(KeyReference, Jwk), SdJwtValidationError> {
458        let cnf = cnf.ok_or(SdJwtValidationError::NoHolderKey(
459            KeyReferenceError::Missing,
460        ))?;
461        let reference =
462            KeyReference::from_confirmation(cnf).map_err(SdJwtValidationError::NoHolderKey)?;
463        let key = match &reference {
464            KeyReference::Jwk(jwk) => jwk.public(),
465            reference => self
466                .resolver
467                .resolve_key(reference, KeyRole::Subject)
468                .await
469                .map_err(|source| ValidationError::KeyResolution {
470                    key: Box::new(reference.clone()),
471                    source,
472                })?,
473        };
474        Ok((reference, key))
475    }
476
477    /// RFC 9901 §7.3, step 5: the key binding JWT of `sd_jwt`, signed by the key `cnf` names;
478    /// its claims and that key.
479    async fn check_key_binding(
480        &self,
481        sd_jwt: &SdJwt,
482        cnf: Option<&Confirmation>,
483        required: &KeyBindingOptions,
484    ) -> Result<(JwtClaims, (KeyReference, Jwk)), SdJwtValidationError> {
485        let kb_jwt = sd_jwt
486            .key_binding_jwt()
487            .ok_or(SdJwtValidationError::KeyBindingRequired)?;
488        let holder_key = self.holder_key(cnf).await?;
489        let jws = kb_jwt.decode().map_err(ValidationError::from)?;
490        match jws.header().typ.as_deref() {
491            Some(super::KB_JWT_TYP) => {}
492            found => {
493                return Err(ValidationError::UnexpectedTyp {
494                    expected: super::KB_JWT_TYP,
495                    found: found.unwrap_or("").to_owned(),
496                }
497                .into());
498            }
499        }
500        jws.verify(&self.verifier, &holder_key.1)
501            .map_err(ValidationError::from)?;
502        let claims: JwtClaims =
503            serde_json::from_slice(jws.payload()).map_err(ValidationError::Payload)?;
504
505        if !claims.audiences().any(|aud| aud == required.audience) {
506            return Err(ValidationError::AudienceMismatch {
507                expected: required.audience.clone(),
508                found: claims.aud.clone(),
509            }
510            .into());
511        }
512        let nonce = claims.additional.get("nonce").and_then(|v| v.as_str());
513        if nonce != Some(required.nonce.as_str()) {
514            return Err(ValidationError::NonceMismatch {
515                expected: required.nonce.clone(),
516                found: nonce.map(str::to_owned),
517            }
518            .into());
519        }
520        let iat = claims
521            .iat
522            .ok_or(CredentialError::MissingClaim("iat"))
523            .map_err(ValidationError::from)?;
524        let issued_at = JwtClaims::timestamp(iat).ok_or_else(|| {
525            ValidationError::from(CredentialError::InvalidClaim {
526                claim: "iat",
527                reason: format!("{iat} is out of range"),
528            })
529        })?;
530        required.time.check(None, None, Some(issued_at))?;
531        if let Some(max_age) = required.max_age {
532            let now = required.time.now();
533            // A `max_age` beyond what `chrono` represents bounds nothing.
534            let oldest = chrono::TimeDelta::from_std(max_age)
535                .ok()
536                .and_then(|delta| now.checked_sub_signed(delta))
537                .unwrap_or(DateTime::<Utc>::MIN_UTC);
538            if issued_at < oldest {
539                return Err(SdJwtValidationError::KeyBindingTooOld {
540                    issued_at,
541                    now,
542                    max_age,
543                });
544            }
545        }
546        let alg = sd_jwt.hash_algorithm().map_err(SdJwtVcError::from)?;
547        let expected = sd_jwt.sd_hash(alg);
548        let found = claims.additional.get("sd_hash").and_then(|v| v.as_str());
549        if found != Some(expected.as_str()) {
550            return Err(SdJwtValidationError::SdHashMismatch {
551                expected,
552                found: found.map(str::to_owned),
553            });
554        }
555        Ok((claims, holder_key))
556    }
557
558    /// Checks every status entry with this validator's check, per the policy, at the
559    /// validation time.
560    async fn check_status(
561        &self,
562        statuses: Vec<CredentialStatus<'_>>,
563        options: &SdJwtValidationOptions,
564    ) -> Result<(), SdJwtValidationError> {
565        Ok(check_statuses(
566            self.status.as_deref(),
567            statuses,
568            options.status,
569            options.time.now(),
570        )
571        .await?)
572    }
573}
574
575/// Checks that `key`, which `reference` resolved to, belongs to `issuer`
576/// ([`KeyReference::binds_to`]).
577fn check_issuer(issuer: &str, reference: &KeyReference, key: &Jwk) -> Result<(), ValidationError> {
578    if !reference.binds_to(Some(issuer), key) {
579        return Err(ValidationError::IssuerMismatch {
580            issuer: Uri::parse(issuer).map_err(|e| CredentialError::InvalidClaim {
581                claim: "iss",
582                reason: e.to_string(),
583            })?,
584            key: Box::new(reference.clone()),
585        });
586    }
587    Ok(())
588}
589
590impl<R: core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug for SdJwtCredentialValidator<R, V> {
591    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
592        f.debug_struct("SdJwtCredentialValidator")
593            .field("resolver", &self.resolver)
594            .field("verifier", &self.verifier)
595            .field("status", &self.status.as_ref().map(|_| "StatusCheck"))
596            .finish()
597    }
598}