use core::time::Duration;
use chrono::{DateTime, Utc};
use super::{SdJwt, SdJwtVc, SdJwtVcError, Status};
use crate::did::{Did, DidUrl, Uri, VerificationRelationship};
use crate::jose::{DefaultVerifier, JwsVerifier};
use crate::vc::validation::{KeyResolver, StatusError, StatusPolicy, TimeOptions, ValidationError};
use crate::vc::{CredentialError, JwtClaims};
#[cfg(feature = "did-web")]
pub use status_list::StatusListCheck;
#[async_trait::async_trait]
pub trait SdJwtStatusCheck: Send + Sync {
async fn check(&self, status: &Status, now: DateTime<Utc>) -> Result<(), StatusError>;
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct KeyBindingOptions {
pub audience: String,
pub nonce: String,
pub time: TimeOptions,
pub max_age: Option<Duration>,
}
impl KeyBindingOptions {
pub fn new(audience: impl Into<String>, nonce: impl Into<String>) -> Self {
Self {
audience: audience.into(),
nonce: nonce.into(),
time: TimeOptions::default(),
max_age: None,
}
}
#[must_use]
pub fn with_time(mut self, time: TimeOptions) -> Self {
self.time = time;
self
}
#[must_use]
pub fn with_max_age(mut self, max_age: Duration) -> Self {
self.max_age = Some(max_age);
self
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct SdJwtValidationOptions {
pub time: TimeOptions,
pub key_binding: Option<KeyBindingOptions>,
pub status: StatusPolicy,
pub reject_legacy_typ: bool,
}
impl SdJwtValidationOptions {
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_time(mut self, time: TimeOptions) -> Self {
self.time = time;
self
}
#[must_use]
pub fn with_key_binding(mut self, key_binding: KeyBindingOptions) -> Self {
self.key_binding = Some(key_binding);
self
}
#[must_use]
pub fn with_status(mut self, status: StatusPolicy) -> Self {
self.status = status;
self
}
#[must_use]
pub fn rejecting_legacy_typ(mut self) -> Self {
self.reject_legacy_typ = true;
self
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum SdJwtValidationError {
#[error(transparent)]
Credential(#[from] SdJwtVcError),
#[error("header `typ` is the pre-standard `vc+sd-jwt`")]
LegacyTyp,
#[error(transparent)]
Validation(#[from] ValidationError),
#[error("key binding is required, but the SD-JWT has no key binding JWT")]
KeyBindingRequired,
#[error("key binding is required, but `cnf` carries no `jwk`")]
NoHolderKey,
#[error("key binding JWT issued at {} is older than {max_age:?} (now is {})", issued_at.to_rfc3339(), now.to_rfc3339())]
KeyBindingTooOld {
issued_at: DateTime<Utc>,
now: DateTime<Utc>,
max_age: Duration,
},
#[error("key binding `sd_hash` {found:?} is not the digest {expected:?} of the SD-JWT")]
SdHashMismatch {
expected: String,
found: Option<String>,
},
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct ValidatedSdJwtVc {
pub credential: SdJwtVc,
pub kid: DidUrl,
pub key_binding: Option<JwtClaims>,
}
pub struct SdJwtCredentialValidator<R, V = DefaultVerifier> {
resolver: R,
verifier: V,
status: Option<Box<dyn SdJwtStatusCheck>>,
}
impl<R: KeyResolver, V: JwsVerifier> SdJwtCredentialValidator<R, V> {
pub fn new(resolver: R, verifier: V) -> Self {
Self {
resolver,
verifier,
status: None,
}
}
#[must_use]
pub fn with_status_check(mut self, check: impl SdJwtStatusCheck + 'static) -> Self {
self.status = Some(Box::new(check));
self
}
pub fn resolver(&self) -> &R {
&self.resolver
}
pub fn verifier(&self) -> &V {
&self.verifier
}
pub fn has_status_check(&self) -> bool {
self.status.is_some()
}
pub async fn validate(
&self,
sd_jwt: &SdJwt,
options: &SdJwtValidationOptions,
) -> Result<ValidatedSdJwtVc, SdJwtValidationError> {
let credential = SdJwtVc::from_sd_jwt(sd_jwt.clone())?;
self.validate_credential(credential, options).await
}
pub async fn validate_credential(
&self,
credential: SdJwtVc,
options: &SdJwtValidationOptions,
) -> Result<ValidatedSdJwtVc, SdJwtValidationError> {
if options.reject_legacy_typ && credential.has_legacy_typ() {
return Err(SdJwtValidationError::LegacyTyp);
}
let jws = credential
.sd_jwt()
.jwt()
.decode()
.map_err(ValidationError::from)?;
let kid = jws.kid().ok_or(ValidationError::MissingKid)?;
let kid = DidUrl::parse(kid).map_err(|source| ValidationError::InvalidKid {
kid: kid.to_owned(),
source,
})?;
if kid.path().is_some() || kid.query().is_some() {
return Err(ValidationError::KidWithParameters(kid).into());
}
let key = self
.resolver
.resolve_key(&kid, VerificationRelationship::AssertionMethod)
.await
.map_err(|source| ValidationError::KeyResolution {
kid: kid.clone(),
source,
})?;
jws.verify(&self.verifier, &key)
.map_err(ValidationError::from)?;
let claims = credential.claims();
let issuer = claims
.iss
.as_deref()
.ok_or(CredentialError::MissingClaim("iss"))
.map_err(ValidationError::from)?;
let issuer_did = Did::parse(issuer).map_err(|_| ValidationError::NotDid {
role: "issuer",
id: issuer.to_owned(),
})?;
if issuer_did != *kid.did() {
return Err(ValidationError::IssuerMismatch {
issuer: Uri::from(issuer_did),
kid,
}
.into());
}
let mut time_claims = JwtClaims::new();
time_claims.nbf = claims.nbf;
time_claims.exp = claims.exp;
time_claims.iat = claims.iat;
options.time.check_claims(&time_claims)?;
let key_binding = match &options.key_binding {
Some(required) => Some(self.check_key_binding(&credential, required)?),
None => None,
};
if let Some(status) = &claims.status {
self.check_status(status, options).await?;
}
Ok(ValidatedSdJwtVc {
credential,
kid,
key_binding,
})
}
fn check_key_binding(
&self,
credential: &SdJwtVc,
required: &KeyBindingOptions,
) -> Result<JwtClaims, SdJwtValidationError> {
let sd_jwt = credential.sd_jwt();
let kb_jwt = sd_jwt
.key_binding_jwt()
.ok_or(SdJwtValidationError::KeyBindingRequired)?;
let holder_key = credential
.holder_key()
.ok_or(SdJwtValidationError::NoHolderKey)?;
let jws = kb_jwt.decode().map_err(ValidationError::from)?;
match jws.header().typ.as_deref() {
Some(super::KB_JWT_TYP) => {}
found => {
return Err(ValidationError::UnexpectedTyp {
expected: super::KB_JWT_TYP,
found: found.unwrap_or("").to_owned(),
}
.into());
}
}
jws.verify(&self.verifier, holder_key)
.map_err(ValidationError::from)?;
let claims: JwtClaims = serde_json::from_slice(jws.payload())
.map_err(|e| ValidationError::Payload(e.to_string()))?;
if !claims.audiences().any(|aud| aud == required.audience) {
return Err(ValidationError::AudienceMismatch {
expected: required.audience.clone(),
found: claims.aud.clone(),
}
.into());
}
let nonce = claims.additional.get("nonce").and_then(|v| v.as_str());
if nonce != Some(required.nonce.as_str()) {
return Err(ValidationError::NonceMismatch {
expected: required.nonce.clone(),
found: nonce.map(str::to_owned),
}
.into());
}
let iat = claims
.iat
.ok_or(CredentialError::MissingClaim("iat"))
.map_err(ValidationError::from)?;
let issued_at = JwtClaims::timestamp(iat).ok_or_else(|| {
ValidationError::from(CredentialError::InvalidClaim {
claim: "iat",
reason: format!("{iat} is out of range"),
})
})?;
required.time.check(None, None, Some(issued_at))?;
if let Some(max_age) = required.max_age {
let now = required.time.now();
let oldest = chrono::TimeDelta::from_std(max_age)
.ok()
.and_then(|delta| now.checked_sub_signed(delta))
.unwrap_or(DateTime::<Utc>::MIN_UTC);
if issued_at < oldest {
return Err(SdJwtValidationError::KeyBindingTooOld {
issued_at,
now,
max_age,
});
}
}
let alg = sd_jwt.hash_algorithm().map_err(SdJwtVcError::from)?;
let expected = sd_jwt.sd_hash(alg);
let found = claims.additional.get("sd_hash").and_then(|v| v.as_str());
if found != Some(expected.as_str()) {
return Err(SdJwtValidationError::SdHashMismatch {
expected,
found: found.map(str::to_owned),
});
}
Ok(claims)
}
async fn check_status(
&self,
status: &Status,
options: &SdJwtValidationOptions,
) -> Result<(), SdJwtValidationError> {
if options.status == StatusPolicy::SkipAll {
return Ok(());
}
let names = || {
status
.mechanisms()
.map(|m| match m {
super::StatusMechanism::StatusList(_) => "status_list".to_owned(),
super::StatusMechanism::Other { name, .. } => name.to_owned(),
})
.collect::<Vec<_>>()
.join(", ")
};
let result = match &self.status {
Some(check) => check.check(status, options.time.now()).await,
None => Err(StatusError::Unsupported(names())),
};
match result {
Ok(()) => Ok(()),
Err(StatusError::Unsupported(_)) if options.status == StatusPolicy::SkipUnsupported => {
Ok(())
}
Err(StatusError::Unsupported(mechanism)) => {
Err(ValidationError::StatusUnchecked(mechanism).into())
}
Err(error) => Err(ValidationError::Status(error).into()),
}
}
}
impl<R: core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug for SdJwtCredentialValidator<R, V> {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("SdJwtCredentialValidator")
.field("resolver", &self.resolver)
.field("verifier", &self.verifier)
.field("status", &self.status.as_ref().map(|_| "SdJwtStatusCheck"))
.finish()
}
}
#[cfg(feature = "did-web")]
mod status_list {
use chrono::{DateTime, Utc};
use url::Url;
use super::SdJwtStatusCheck;
use crate::did::{DidUrl, VerificationRelationship};
use crate::jose::{DefaultVerifier, JwsVerifier};
use crate::methods::web::HttpClient;
use crate::sd_jwt_vc::{Status, StatusListToken, StatusMechanism};
use crate::vc::Jwt;
use crate::vc::validation::{KeyResolver, StatusError};
pub struct StatusListCheck<C, R, V = DefaultVerifier> {
client: C,
resolver: R,
verifier: V,
}
impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> StatusListCheck<C, R, V> {
pub fn new(client: C, resolver: R, verifier: V) -> Self {
Self {
client,
resolver,
verifier,
}
}
async fn fetch(&self, uri: &str) -> Result<StatusListToken, StatusError> {
let url = Url::parse(uri)
.map_err(|e| StatusError::Unavailable(format!("`uri` {uri:?}: {e}")))?;
let response = self
.client
.get(&url)
.await
.map_err(|e| StatusError::Unavailable(format!("fetching {uri}: {e}")))?;
if !(200..300).contains(&response.status) {
return Err(StatusError::Unavailable(format!(
"fetching {uri}: HTTP {}",
response.status
)));
}
let body = String::from_utf8(response.body)
.map_err(|_| StatusError::Unavailable(format!("{uri}: body is not UTF-8")))?;
let jwt = Jwt::new(body.trim())
.map_err(|e| StatusError::Unavailable(format!("{uri}: {e}")))?;
let (header, token) = StatusListToken::decode(&jwt)
.map_err(|e| StatusError::Unavailable(format!("{uri}: {e}")))?;
let kid = header
.kid
.as_deref()
.ok_or_else(|| StatusError::Unavailable(format!("{uri}: no `kid`")))?;
let kid = DidUrl::parse(kid).map_err(|e| {
StatusError::Unavailable(format!("{uri}: `kid` {kid:?} is not a DID URL: {e}"))
})?;
if kid.path().is_some() || kid.query().is_some() {
return Err(StatusError::Unavailable(format!(
"{uri}: `kid` `{kid}` carries a path or DID parameters"
)));
}
let key = self
.resolver
.resolve_key(&kid, VerificationRelationship::AssertionMethod)
.await
.map_err(|e| StatusError::Unavailable(format!("{uri}: resolving `kid`: {e}")))?;
jwt.decode()
.map_err(|e| StatusError::Unavailable(format!("{uri}: {e}")))?
.verify(&self.verifier, &key)
.map_err(|e| StatusError::Unavailable(format!("{uri}: signature: {e}")))?;
Ok(token)
}
}
#[async_trait::async_trait]
impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> SdJwtStatusCheck for StatusListCheck<C, R, V> {
async fn check(&self, status: &Status, now: DateTime<Utc>) -> Result<(), StatusError> {
let Some(reference) = &status.status_list else {
let names: Vec<&str> = status
.mechanisms()
.filter_map(|m| match m {
StatusMechanism::Other { name, .. } => Some(name),
_ => None,
})
.collect();
return Err(StatusError::Unsupported(names.join(", ")));
};
let token = self.fetch(&reference.uri).await?;
let status_type = token
.status_of(reference, now)
.map_err(|e| StatusError::Unavailable(e.to_string()))?;
if status_type.is_valid() {
Ok(())
} else {
Err(StatusError::Invalid(status_type.to_string()))
}
}
}
impl<C: core::fmt::Debug, R: core::fmt::Debug, V: core::fmt::Debug> core::fmt::Debug
for StatusListCheck<C, R, V>
{
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
f.debug_struct("StatusListCheck")
.field("client", &self.client)
.field("resolver", &self.resolver)
.field("verifier", &self.verifier)
.finish()
}
}
}