use std::collections::BTreeSet;
use std::error::Error;
use chrono::{DateTime, Utc};
use url::Url;
use crate::cause::cause;
use crate::did::{Did, DidDocument, DidUrl, ResolutionError, Service, ServiceEndpoint, Uri};
use crate::jose::{DefaultVerifier, JwsError, JwsHeader, JwsVerifier, Signer};
use crate::methods::web::{HttpClient, HttpError};
use crate::resolver::Resolver;
use crate::vc::validation::{
CredentialValidationOptions, DecodedPresentation, FailFast, JwtPresentationValidator,
KeyReference, KeyResolver, PresentationValidationError, PresentationValidationOptions,
SubjectHolderRelationship, TimeOptions, ValidationError, decode_presentation_with,
extract_holder,
};
use crate::vc::{CredentialError, Jwt, JwtClaims, Presentation};
pub const SERVICE_TYPE: &str = "LinkedVerifiablePresentation";
pub const CONTEXT: &str = "https://identity.foundation/linked-vp/contexts/v1";
pub const PRESENTATION_TYP: [&str; 2] = ["vp+ld+jwt", "'vp+ld+jwt'"];
pub const CREDENTIAL_TYP: [&str; 2] = ["vc+ld+jwt", "'vc+ld+jwt'"];
#[expect(
clippy::similar_names,
reason = "the URLs given and the URIs of the service endpoint are both needed"
)]
pub fn service(id: DidUrl, urls: impl IntoIterator<Item = Url>) -> Result<Service, LinkedVpError> {
let mut uris = Vec::new();
for url in urls {
let uri = Uri::parse(url.as_str()).map_err(|e| LinkedVpError::InvalidService {
id: id.clone(),
reason: format!("`{url}` is not a URI"),
source: Some(Box::new(e)),
})?;
uris.push(uri);
}
let endpoint = match <[Uri; 1]>::try_from(uris) {
Ok([uri]) => ServiceEndpoint::Uri(uri),
Err(uris) if uris.is_empty() => {
return Err(LinkedVpError::InvalidService {
id,
reason: "no URL".to_owned(),
source: None,
});
}
Err(uris) => ServiceEndpoint::Set(uris.into_iter().collect()),
};
Ok(Service::new(id, SERVICE_TYPE, endpoint))
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LinkedVpService<'a> {
pub service: &'a Service,
pub urls: Vec<Url>,
}
impl LinkedVpService<'_> {
pub fn id(&self) -> &DidUrl {
&self.service.id
}
}
pub fn services(
document: &DidDocument,
) -> impl Iterator<Item = Result<LinkedVpService<'_>, LinkedVpError>> {
document
.service
.iter()
.filter(|service| service.has_type(SERVICE_TYPE))
.map(linked_vp_service)
}
fn linked_vp_service(service: &Service) -> Result<LinkedVpService<'_>, LinkedVpError> {
let invalid = |reason: String, source: Option<Box<dyn Error + Send + Sync>>| {
LinkedVpError::InvalidService {
id: service.id.clone(),
reason,
source,
}
};
if matches!(service.service_endpoint, ServiceEndpoint::Map(_)) {
return Err(invalid(
"`serviceEndpoint` is a map, not URLs".to_owned(),
None,
));
}
let urls = service
.service_endpoint
.uris()
.map(|uri| {
Url::parse(uri.as_str())
.map_err(|e| invalid(format!("`{uri}` is not a URL"), Some(Box::new(e))))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(LinkedVpService { service, urls })
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct LinkedPresentationBuilder {
pub holder: Did,
pub id: Option<Uri>,
pub credentials: Vec<Jwt>,
pub issued_at: DateTime<Utc>,
pub expiration: Option<DateTime<Utc>>,
}
impl LinkedPresentationBuilder {
pub fn new(holder: Did) -> Self {
Self {
holder,
id: None,
credentials: Vec::new(),
issued_at: Utc::now(),
expiration: None,
}
}
#[must_use]
pub fn with_id(mut self, id: impl Into<Uri>) -> Self {
self.id = Some(id.into());
self
}
#[must_use]
pub fn with_credential(mut self, credential: Jwt) -> Self {
self.credentials.push(credential);
self
}
#[must_use]
pub fn with_issued_at(mut self, issued_at: DateTime<Utc>) -> Self {
self.issued_at = issued_at;
self
}
#[must_use]
pub fn with_expiration(mut self, expiration: DateTime<Utc>) -> Self {
self.expiration = Some(expiration);
self
}
pub fn presentation(&self) -> Presentation {
let mut presentation = Presentation::new().with_holder(self.holder.clone());
if let Some(id) = &self.id {
presentation = presentation.with_id(id.clone());
}
self.credentials
.iter()
.fold(presentation, |presentation, credential| {
presentation.with_credential(credential.clone())
})
}
pub fn jwt_claims(&self) -> Result<JwtClaims, CredentialError> {
let mut claims = self
.presentation()
.to_jwt_claims()?
.with_sub(self.holder.as_str())
.with_iat(self.issued_at)
.with_nbf(self.issued_at);
if let Some(expiration) = self.expiration {
claims = claims.with_exp(expiration);
}
Ok(claims)
}
pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, LinkedVpError> {
if kid.did() != &self.holder {
return Err(ValidationError::HolderMismatch {
holder: self.holder.clone().into(),
key: Box::new(KeyReference::DidUrl(kid.clone())),
}
.into());
}
let header = JwsHeader::new(signer.alg())
.with_typ("JWT")
.with_kid(kid.as_str());
let claims = self.jwt_claims().map_err(ValidationError::from)?;
let jwt = Jwt::sign(&header, &claims, signer)
.await
.map_err(ValidationError::from)?;
Ok(jwt)
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LinkedVpError {
#[error("service `{id}`: {reason}{}", cause(.source.as_ref()))]
InvalidService {
id: DidUrl,
reason: String,
source: Option<Box<dyn Error + Send + Sync>>,
},
#[error("`{0}` is not an HTTPS URL")]
UnsupportedEndpoint(Url),
#[error("fetching `{url}` failed: {source}")]
Http {
url: Url,
#[source]
source: HttpError,
},
#[error("fetching `{url}` returned HTTP {status}")]
HttpStatus {
url: Url,
status: u16,
},
#[error("`{0}` serves a JSON-LD presentation, which is not supported")]
UnsupportedFormat(Url),
#[error("`{url}` does not serve a compact JWT: {reason}{}", cause(.source.as_ref()))]
InvalidBody {
url: Url,
reason: &'static str,
source: Option<Box<dyn Error + Send + Sync>>,
},
#[error("the holder `{holder}` is not `{did}`, the DID linking the presentation")]
HolderMismatch {
did: Did,
holder: Did,
},
#[error(transparent)]
Presentation(#[from] PresentationValidationError),
#[error(transparent)]
Validation(#[from] ValidationError),
#[error("resolving the DID failed: {0}")]
Resolution(#[from] ResolutionError),
#[error("the DID document lists more than {0} presentation URLs")]
TooManyUrls(usize),
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct LinkedVpValidationOptions {
pub time: TimeOptions,
pub credentials: CredentialValidationOptions,
pub subject_holder: SubjectHolderRelationship,
pub fail_fast: FailFast,
}
impl LinkedVpValidationOptions {
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_credentials(mut self, credentials: CredentialValidationOptions) -> Self {
self.credentials = credentials;
self
}
#[must_use]
pub fn with_subject_holder(mut self, relationship: SubjectHolderRelationship) -> Self {
self.subject_holder = relationship;
self
}
#[must_use]
pub fn with_fail_fast(mut self, fail_fast: FailFast) -> Self {
self.fail_fast = fail_fast;
self
}
pub fn presentation_options(&self) -> PresentationValidationOptions {
let mut credentials = self.credentials.clone();
credentials
.additional_typ
.extend(CREDENTIAL_TYP.map(str::to_owned));
let mut options = PresentationValidationOptions::new()
.with_time(self.time)
.with_subject_holder(self.subject_holder)
.with_credentials(credentials)
.with_fail_fast(self.fail_fast);
options
.additional_typ
.extend(PRESENTATION_TYP.map(str::to_owned));
options
}
}
pub fn decode(jwt: &Jwt) -> Result<DecodedPresentation, ValidationError> {
let presentation_typ: BTreeSet<String> = PRESENTATION_TYP.map(str::to_owned).into();
let credential_typ: BTreeSet<String> = CREDENTIAL_TYP.map(str::to_owned).into();
decode_presentation_with(jwt, &presentation_typ, &credential_typ)
}
#[derive(Debug)]
pub struct LinkedVpValidator<R, V = DefaultVerifier> {
presentations: JwtPresentationValidator<R, V>,
}
impl<R: KeyResolver, V: JwsVerifier> LinkedVpValidator<R, V> {
pub fn new(resolver: R, verifier: V) -> Self {
Self {
presentations: JwtPresentationValidator::new(resolver, verifier),
}
}
pub fn from_presentation_validator(presentations: JwtPresentationValidator<R, V>) -> Self {
Self { presentations }
}
pub fn presentation_validator(&self) -> &JwtPresentationValidator<R, V> {
&self.presentations
}
pub async fn validate(
&self,
jwt: &Jwt,
did: &Did,
options: &LinkedVpValidationOptions,
) -> Result<DecodedPresentation, LinkedVpError> {
let holder = extract_holder(jwt).map_err(PresentationValidationError::presentation)?;
if holder != *did {
return Err(LinkedVpError::HolderMismatch {
did: did.clone(),
holder,
});
}
let jws = jwt
.decode()
.map_err(|e| PresentationValidationError::presentation(e.into()))?;
if let Ok(reference) = KeyReference::from_jws(&jws, Some(did.as_str())) {
let of_did = match &reference {
KeyReference::DidUrl(url) => url.did() == did,
KeyReference::Jwk(jwk) => reference.binds_to(Some(did.as_str()), jwk),
_ => false,
};
if !of_did {
return Err(PresentationValidationError::presentation(
ValidationError::HolderMismatch {
holder: did.clone().into(),
key: Box::new(reference),
},
)
.into());
}
}
let decoded = self
.presentations
.validate(jwt, &options.presentation_options())
.await?;
Ok(decoded)
}
}
#[derive(Debug)]
#[non_exhaustive]
pub struct LinkedPresentationReport {
pub service: DidUrl,
pub url: Option<Url>,
pub result: Result<DecodedPresentation, LinkedVpError>,
}
#[derive(Debug)]
pub struct LinkedVpClient<C, R, V = DefaultVerifier> {
http: C,
validator: LinkedVpValidator<R, V>,
max_urls: usize,
}
impl<C, R, V> LinkedVpClient<C, R, V> {
pub const DEFAULT_MAX_URLS: usize = 32;
}
impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> LinkedVpClient<C, R, V> {
pub fn new(http: C, validator: LinkedVpValidator<R, V>) -> Self {
Self {
http,
validator,
max_urls: Self::DEFAULT_MAX_URLS,
}
}
#[must_use]
pub fn with_max_urls(mut self, max_urls: usize) -> Self {
self.max_urls = max_urls;
self
}
pub fn max_urls(&self) -> usize {
self.max_urls
}
pub fn http(&self) -> &C {
&self.http
}
pub fn validator(&self) -> &LinkedVpValidator<R, V> {
&self.validator
}
pub async fn fetch(&self, url: &Url) -> Result<Jwt, LinkedVpError> {
if url.scheme() != "https" {
return Err(LinkedVpError::UnsupportedEndpoint(url.clone()));
}
let response = self
.http
.get(url)
.await
.map_err(|source| LinkedVpError::Http {
url: url.clone(),
source,
})?;
if !(200..300).contains(&response.status) {
return Err(LinkedVpError::HttpStatus {
url: url.clone(),
status: response.status,
});
}
let invalid = |reason: &'static str, source: Box<dyn Error + Send + Sync>| {
LinkedVpError::InvalidBody {
url: url.clone(),
reason,
source: Some(source),
}
};
let body = core::str::from_utf8(&response.body)
.map_err(|e| invalid("the body is not UTF-8", Box::new(e)))?
.trim();
if body.starts_with('{') {
return Err(LinkedVpError::UnsupportedFormat(url.clone()));
}
Jwt::new(body).map_err(|e: JwsError| invalid("not a compact JWS", Box::new(e)))
}
pub async fn validate_url(
&self,
url: &Url,
did: &Did,
options: &LinkedVpValidationOptions,
) -> Result<DecodedPresentation, LinkedVpError> {
let jwt = self.fetch(url).await?;
self.validator.validate(&jwt, did, options).await
}
pub async fn validate_document(
&self,
document: &DidDocument,
options: &LinkedVpValidationOptions,
) -> Vec<LinkedPresentationReport> {
let mut reports = Vec::new();
let mut fetched = 0;
for service in document
.service
.iter()
.filter(|service| service.has_type(SERVICE_TYPE))
{
let linked = match linked_vp_service(service) {
Ok(linked) => linked,
Err(error) => {
reports.push(LinkedPresentationReport {
service: service.id.clone(),
url: None,
result: Err(error),
});
continue;
}
};
for url in linked.urls {
let result = if fetched < self.max_urls {
fetched += 1;
self.validate_url(&url, &document.id, options).await
} else {
Err(LinkedVpError::TooManyUrls(self.max_urls))
};
reports.push(LinkedPresentationReport {
service: service.id.clone(),
url: Some(url),
result,
});
}
}
reports
}
}
impl<C: HttpClient, V: JwsVerifier> LinkedVpClient<C, Resolver, V> {
pub async fn validate_did(
&self,
did: &Did,
options: &LinkedVpValidationOptions,
) -> Result<Vec<LinkedPresentationReport>, LinkedVpError> {
let document = self
.validator
.presentation_validator()
.credential_validator()
.resolver()
.resolve_document(did)
.await?;
Ok(self.validate_document(&document, options).await)
}
}