Skip to main content

identity_kit/sd_jwt_vc/
validation.rs

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