identity_kit/linked_vp/mod.rs
1//! DIF Linked Verifiable Presentation: verifiable presentations a DID controller publishes for
2//! anyone to discover, through a `LinkedVerifiablePresentation` service in its DID document.
3//!
4//! The service entry is the normative part of the specification: `type` is
5//! [`SERVICE_TYPE`] and `serviceEndpoint` one URL or a non-empty array of them. [`service`]
6//! builds one, [`services`] reads them out of a document. Each URL serves one presentation;
7//! this crate handles the JSON Web Token form (a VCDM 1.1 presentation in the `vp` claim, its
8//! credentials as JWTs) and reports the JSON-LD form as unsupported.
9//!
10//! - [`LinkedPresentationBuilder`] signs a presentation for publishing: `iss`, `sub` and
11//! `holder` the DID, no `aud` and no `nonce`, which a presentation published for anyone
12//! cannot carry.
13//! - [`LinkedVpValidator`] validates one presentation for the DID whose document lists it:
14//! holder and `iss` are that DID, the holder signed with a key of the DID under
15//! `authentication`, `exp` holds when present, and every credential validates with its
16//! subject the holder (the specification's SHOULD, configurable).
17//! - [`LinkedVpClient`] fetches what a document's services point to through an
18//! [`HttpClient`] and validates each presentation, reporting per URL: every linked
19//! presentation stands on its own.
20//!
21//! # A linked presentation proves control at link time only
22//!
23//! Anyone can fetch a linked presentation and replay it, and it says nothing about who
24//! controls the DID now. A verifier must not grant access on a linked presentation alone, but
25//! ask the holder for an interactive proof of control, as the specification's security
26//! considerations require. Hashlinks (`hl=` in a URL) are passed through and not verified.
27//!
28//! # `typ`
29//!
30//! The specification does not constrain `typ`. Its example presentation carries the literal
31//! `'vp+ld+jwt'`, stray quotes included, and its credential `'vc+ld+jwt'`; the validator
32//! accepts these ([`PRESENTATION_TYP`], [`CREDENTIAL_TYP`]) next to `JWT` and no `typ`. The
33//! builder writes `JWT`.
34//!
35//! # Examples
36//!
37//! The specification's example document lists two presentations, one over HTTPS and one on
38//! IPFS:
39//!
40//! ```
41//! use identity_kit::did::DidDocument;
42//! use identity_kit::linked_vp::services;
43//! use serde_json::json;
44//!
45//! let document: DidDocument = serde_json::from_value(json!({
46//! "@context": ["https://www.w3.org/ns/did/v1", "https://identity.foundation/linked-vp/contexts/v1"],
47//! "id": "did:example:123",
48//! "service": [
49//! {
50//! "id": "did:example:123#foo",
51//! "type": "LinkedVerifiablePresentation",
52//! "serviceEndpoint": ["https://bar.example.com/verifiable-presentation.jsonld"]
53//! },
54//! {
55//! "id": "did:example:123#baz",
56//! "type": "LinkedVerifiablePresentation",
57//! "serviceEndpoint": "ipfs://bafybeihkoviema7g3gxyt6la7vd5ho32ictqbilu3wnlo3rs7ewhnp7lly/verifiable-presentation.jwt"
58//! }
59//! ]
60//! }))?;
61//! let urls: Vec<String> = services(&document)
62//! .map(|service| service.map(|service| service.urls[0].to_string()))
63//! .collect::<Result<_, _>>()?;
64//! assert_eq!(urls, [
65//! "https://bar.example.com/verifiable-presentation.jsonld",
66//! "ipfs://bafybeihkoviema7g3gxyt6la7vd5ho32ictqbilu3wnlo3rs7ewhnp7lly/verifiable-presentation.jwt",
67//! ]);
68//! # Ok::<(), Box<dyn std::error::Error>>(())
69//! ```
70
71use std::collections::BTreeSet;
72use std::error::Error;
73
74use chrono::{DateTime, Utc};
75use url::Url;
76
77use crate::cause::cause;
78use crate::did::{Did, DidDocument, DidUrl, ResolutionError, Service, ServiceEndpoint, Uri};
79use crate::jose::{DefaultVerifier, JwsError, JwsHeader, JwsVerifier, Signer};
80use crate::methods::web::{HttpClient, HttpError};
81use crate::resolver::Resolver;
82use crate::vc::validation::{
83 CredentialValidationOptions, DecodedPresentation, FailFast, JwtPresentationValidator,
84 KeyReference, KeyResolver, PresentationValidationError, PresentationValidationOptions,
85 SubjectHolderRelationship, TimeOptions, ValidationError, decode_presentation_with,
86 extract_holder,
87};
88use crate::vc::{CredentialError, Jwt, JwtClaims, Presentation};
89
90/// The service type, `LinkedVerifiablePresentation`.
91pub const SERVICE_TYPE: &str = "LinkedVerifiablePresentation";
92
93/// The JSON-LD context defining [`SERVICE_TYPE`], for the `@context` of a DID document that
94/// lists linked presentations.
95pub const CONTEXT: &str = "https://identity.foundation/linked-vp/contexts/v1";
96
97/// The header `typ` values a linked presentation may carry besides `JWT`: `vp+ld+jwt`, and the
98/// literal `'vp+ld+jwt'` with the single quotes of the specification's example.
99pub const PRESENTATION_TYP: [&str; 2] = ["vp+ld+jwt", "'vp+ld+jwt'"];
100
101/// The header `typ` values a credential in a linked presentation may carry besides `JWT`:
102/// `vc+ld+jwt`, and the literal `'vc+ld+jwt'` of the specification's example.
103pub const CREDENTIAL_TYP: [&str; 2] = ["vc+ld+jwt", "'vc+ld+jwt'"];
104
105/// A `LinkedVerifiablePresentation` service of `id` pointing to `urls`.
106///
107/// # Errors
108///
109/// [`LinkedVpError::InvalidService`] when `urls` is empty or a URL is not an RFC 3986 URI.
110///
111/// # Examples
112///
113/// ```
114/// use identity_kit::did::{DidUrl, ServiceEndpoint};
115/// use identity_kit::linked_vp::{SERVICE_TYPE, service};
116/// use url::Url;
117///
118/// let service = service(
119/// DidUrl::parse("did:example:123#foo")?,
120/// [Url::parse("https://bar.example.com/verifiable-presentation.jwt")?],
121/// )?;
122/// assert!(service.has_type(SERVICE_TYPE));
123/// assert!(matches!(service.service_endpoint, ServiceEndpoint::Uri(_)));
124/// # Ok::<(), Box<dyn std::error::Error>>(())
125/// ```
126#[expect(
127 clippy::similar_names,
128 reason = "the URLs given and the URIs of the service endpoint are both needed"
129)]
130pub fn service(id: DidUrl, urls: impl IntoIterator<Item = Url>) -> Result<Service, LinkedVpError> {
131 let mut uris = Vec::new();
132 for url in urls {
133 let uri = Uri::parse(url.as_str()).map_err(|e| LinkedVpError::InvalidService {
134 id: id.clone(),
135 reason: format!("`{url}` is not a URI"),
136 source: Some(Box::new(e)),
137 })?;
138 uris.push(uri);
139 }
140 let endpoint = match <[Uri; 1]>::try_from(uris) {
141 Ok([uri]) => ServiceEndpoint::Uri(uri),
142 Err(uris) if uris.is_empty() => {
143 return Err(LinkedVpError::InvalidService {
144 id,
145 reason: "no URL".to_owned(),
146 source: None,
147 });
148 }
149 Err(uris) => ServiceEndpoint::Set(uris.into_iter().collect()),
150 };
151 Ok(Service::new(id, SERVICE_TYPE, endpoint))
152}
153
154/// A `LinkedVerifiablePresentation` service of a DID document, with its URLs.
155#[derive(Debug, Clone, PartialEq, Eq)]
156#[non_exhaustive]
157pub struct LinkedVpService<'a> {
158 /// The service entry.
159 pub service: &'a Service,
160 /// Its URLs, in document order.
161 pub urls: Vec<Url>,
162}
163
164impl LinkedVpService<'_> {
165 /// The id of the service.
166 pub fn id(&self) -> &DidUrl {
167 &self.service.id
168 }
169}
170
171/// The `LinkedVerifiablePresentation` services of `document`, in document order. A service of
172/// that type whose endpoint is a map, or names something other than a URL, is an
173/// [`LinkedVpError::InvalidService`] rather than silently skipped.
174pub fn services(
175 document: &DidDocument,
176) -> impl Iterator<Item = Result<LinkedVpService<'_>, LinkedVpError>> {
177 document
178 .service
179 .iter()
180 .filter(|service| service.has_type(SERVICE_TYPE))
181 .map(linked_vp_service)
182}
183
184/// `service` with its URLs parsed.
185fn linked_vp_service(service: &Service) -> Result<LinkedVpService<'_>, LinkedVpError> {
186 let invalid = |reason: String, source: Option<Box<dyn Error + Send + Sync>>| {
187 LinkedVpError::InvalidService {
188 id: service.id.clone(),
189 reason,
190 source,
191 }
192 };
193 if matches!(service.service_endpoint, ServiceEndpoint::Map(_)) {
194 return Err(invalid(
195 "`serviceEndpoint` is a map, not URLs".to_owned(),
196 None,
197 ));
198 }
199 let urls = service
200 .service_endpoint
201 .uris()
202 .map(|uri| {
203 Url::parse(uri.as_str())
204 .map_err(|e| invalid(format!("`{uri}` is not a URL"), Some(Box::new(e))))
205 })
206 .collect::<Result<Vec<_>, _>>()?;
207 Ok(LinkedVpService { service, urls })
208}
209
210/// Builds a linked presentation in the JSON Web Token form: a VCDM 1.1 presentation of
211/// credential JWTs held by `holder`, with `iss`, `sub` and `holder` the holder, `jti` the
212/// presentation's `id`, `iat` and `nbf` the issuance time, `exp` when an expiration is set,
213/// and neither `aud` nor `nonce`.
214///
215/// # Examples
216///
217/// ```
218/// use chrono::{TimeZone as _, Utc};
219/// use identity_kit::did::{Did, Uri};
220/// use identity_kit::linked_vp::LinkedPresentationBuilder;
221///
222/// let holder = Did::parse("did:example:123")?;
223/// let claims = LinkedPresentationBuilder::new(holder)
224/// .with_id(Uri::parse("https://bar.example.com/verifiable-presentation.jwt")?)
225/// .with_issued_at(Utc.with_ymd_and_hms(2024, 2, 8, 17, 38, 46).unwrap())
226/// .with_expiration(Utc.with_ymd_and_hms(2029, 2, 8, 17, 38, 46).unwrap())
227/// .jwt_claims()?;
228/// assert_eq!(claims.iss.as_deref(), Some("did:example:123"));
229/// assert_eq!(claims.sub, claims.iss);
230/// assert_eq!((claims.iat, claims.nbf, claims.exp), (Some(1707413926), Some(1707413926), Some(1865266726)));
231/// assert_eq!(claims.jti.as_deref(), Some("https://bar.example.com/verifiable-presentation.jwt"));
232/// assert!(claims.aud.is_none() && !claims.additional.contains_key("nonce"));
233/// # Ok::<(), Box<dyn std::error::Error>>(())
234/// ```
235#[derive(Debug, Clone, PartialEq, Eq)]
236#[non_exhaustive]
237pub struct LinkedPresentationBuilder {
238 /// The holder: the DID whose document links the presentation.
239 pub holder: Did,
240 /// The presentation's `id`, usually the URL it is published at.
241 pub id: Option<Uri>,
242 /// The credential JWTs.
243 pub credentials: Vec<Jwt>,
244 /// `iat` and `nbf`.
245 pub issued_at: DateTime<Utc>,
246 /// `exp`, if the presentation expires.
247 pub expiration: Option<DateTime<Utc>>,
248}
249
250impl LinkedPresentationBuilder {
251 /// A presentation by `holder` of no credentials, issued now, without `id` or expiration.
252 pub fn new(holder: Did) -> Self {
253 Self {
254 holder,
255 id: None,
256 credentials: Vec::new(),
257 issued_at: Utc::now(),
258 expiration: None,
259 }
260 }
261
262 /// This presentation with its `id` set.
263 #[must_use]
264 pub fn with_id(mut self, id: impl Into<Uri>) -> Self {
265 self.id = Some(id.into());
266 self
267 }
268
269 /// This presentation with `credential` added.
270 #[must_use]
271 pub fn with_credential(mut self, credential: Jwt) -> Self {
272 self.credentials.push(credential);
273 self
274 }
275
276 /// This presentation issued at `issued_at`.
277 #[must_use]
278 pub fn with_issued_at(mut self, issued_at: DateTime<Utc>) -> Self {
279 self.issued_at = issued_at;
280 self
281 }
282
283 /// This presentation expiring at `expiration`, which the specification recommends to limit
284 /// how long a published presentation stays valid.
285 #[must_use]
286 pub fn with_expiration(mut self, expiration: DateTime<Utc>) -> Self {
287 self.expiration = Some(expiration);
288 self
289 }
290
291 /// The presentation.
292 pub fn presentation(&self) -> Presentation {
293 let mut presentation = Presentation::new().with_holder(self.holder.clone());
294 if let Some(id) = &self.id {
295 presentation = presentation.with_id(id.clone());
296 }
297 self.credentials
298 .iter()
299 .fold(presentation, |presentation, credential| {
300 presentation.with_credential(credential.clone())
301 })
302 }
303
304 /// The JWT claims: the presentation's (`iss`, `jti`, `vp`) with `sub`, `iat`, `nbf` and
305 /// `exp`.
306 ///
307 /// # Errors
308 ///
309 /// A [`CredentialError`] when the presentation does not map to claims; one built here
310 /// always does.
311 pub fn jwt_claims(&self) -> Result<JwtClaims, CredentialError> {
312 let mut claims = self
313 .presentation()
314 .to_jwt_claims()?
315 .with_sub(self.holder.as_str())
316 .with_iat(self.issued_at)
317 .with_nbf(self.issued_at);
318 if let Some(expiration) = self.expiration {
319 claims = claims.with_exp(expiration);
320 }
321 Ok(claims)
322 }
323
324 /// The presentation signed with `signer` under a header of `alg`, `typ` `JWT` and `kid`,
325 /// `kid` naming a verification method of the holder, which a verifier resolves under
326 /// `authentication`.
327 ///
328 /// # Errors
329 ///
330 /// [`LinkedVpError::Validation`] with [`ValidationError::HolderMismatch`] when `kid` is not
331 /// a key of the holder, or with [`ValidationError::Jws`] when signing fails.
332 pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, LinkedVpError> {
333 if kid.did() != &self.holder {
334 return Err(ValidationError::HolderMismatch {
335 holder: self.holder.clone().into(),
336 key: Box::new(KeyReference::DidUrl(kid.clone())),
337 }
338 .into());
339 }
340 let header = JwsHeader::new(signer.alg())
341 .with_typ("JWT")
342 .with_kid(kid.as_str());
343 let claims = self.jwt_claims().map_err(ValidationError::from)?;
344 let jwt = Jwt::sign(&header, &claims, signer)
345 .await
346 .map_err(ValidationError::from)?;
347 Ok(jwt)
348 }
349}
350
351/// Why a linked presentation could not be found, fetched or validated.
352#[derive(Debug, thiserror::Error)]
353#[non_exhaustive]
354pub enum LinkedVpError {
355 /// A `LinkedVerifiablePresentation` service does not name one or more URLs. The source
356 /// is the URI or URL error, when there is one.
357 #[error("service `{id}`: {reason}{}", cause(.source.as_ref()))]
358 InvalidService {
359 /// The id of the service.
360 id: DidUrl,
361 /// What is wrong with it.
362 reason: String,
363 /// The underlying error.
364 source: Option<Box<dyn Error + Send + Sync>>,
365 },
366 /// A URL is not `https`: an `ipfs://` endpoint, say. Only HTTPS is fetched.
367 #[error("`{0}` is not an HTTPS URL")]
368 UnsupportedEndpoint(Url),
369 /// The [`HttpClient`] got no response.
370 #[error("fetching `{url}` failed: {source}")]
371 Http {
372 /// The URL fetched.
373 url: Url,
374 /// Why there was no response.
375 #[source]
376 source: HttpError,
377 },
378 /// The server answered with a status other than `2xx`.
379 #[error("fetching `{url}` returned HTTP {status}")]
380 HttpStatus {
381 /// The URL fetched.
382 url: Url,
383 /// The status code.
384 status: u16,
385 },
386 /// The body is a JSON object: a presentation in the JSON-LD form, which this crate does
387 /// not process.
388 #[error("`{0}` serves a JSON-LD presentation, which is not supported")]
389 UnsupportedFormat(Url),
390 /// The body is not a compact JWT. The source is the UTF-8 or [`JwsError`].
391 #[error("`{url}` does not serve a compact JWT: {reason}{}", cause(.source.as_ref()))]
392 InvalidBody {
393 /// The URL fetched.
394 url: Url,
395 /// What is wrong with the body.
396 reason: &'static str,
397 /// The underlying error.
398 source: Option<Box<dyn Error + Send + Sync>>,
399 },
400 /// The presentation's holder is not the DID whose document links it.
401 #[error("the holder `{holder}` is not `{did}`, the DID linking the presentation")]
402 HolderMismatch {
403 /// The DID linking the presentation.
404 did: Did,
405 /// The holder the presentation names.
406 holder: Did,
407 },
408 /// The presentation or some of its credentials did not validate.
409 #[error(transparent)]
410 Presentation(#[from] PresentationValidationError),
411 /// The presentation did not decode, or did not sign.
412 #[error(transparent)]
413 Validation(#[from] ValidationError),
414 /// The DID did not resolve.
415 #[error("resolving the DID failed: {0}")]
416 Resolution(#[from] ResolutionError),
417 /// The URL was not fetched: the DID document lists more URLs than the client's limit,
418 /// given here.
419 #[error("the DID document lists more than {0} presentation URLs")]
420 TooManyUrls(usize),
421}
422
423/// Options of [`LinkedVpValidator`]. The audience and nonce are fixed: a linked presentation
424/// has neither.
425#[derive(Debug, Clone, Default, PartialEq, Eq)]
426#[non_exhaustive]
427pub struct LinkedVpValidationOptions {
428 /// The validation time and leeway of the presentation JWT; `exp` is honoured when present
429 /// and not required.
430 pub time: TimeOptions,
431 /// The options every credential is validated with. [`CREDENTIAL_TYP`] is accepted on top
432 /// of their `additional_typ`.
433 pub credentials: CredentialValidationOptions,
434 /// How credential subjects must relate to the holder;
435 /// [`SubjectHolderRelationship::AlwaysSubject`] by default, as the specification
436 /// recommends.
437 pub subject_holder: SubjectHolderRelationship,
438 /// Whether to stop at the first failing credential.
439 pub fail_fast: FailFast,
440}
441
442impl LinkedVpValidationOptions {
443 /// Default options: validate now without leeway, default credential options,
444 /// [`SubjectHolderRelationship::AlwaysSubject`], [`FailFast::FirstError`].
445 pub fn new() -> Self {
446 Self::default()
447 }
448
449 /// These options with `time`, for the presentation JWT only.
450 #[must_use]
451 pub fn with_time(mut self, time: TimeOptions) -> Self {
452 self.time = time;
453 self
454 }
455
456 /// These options validating credentials with `credentials`.
457 #[must_use]
458 pub fn with_credentials(mut self, credentials: CredentialValidationOptions) -> Self {
459 self.credentials = credentials;
460 self
461 }
462
463 /// These options with `relationship`.
464 #[must_use]
465 pub fn with_subject_holder(mut self, relationship: SubjectHolderRelationship) -> Self {
466 self.subject_holder = relationship;
467 self
468 }
469
470 /// These options with `fail_fast`.
471 #[must_use]
472 pub fn with_fail_fast(mut self, fail_fast: FailFast) -> Self {
473 self.fail_fast = fail_fast;
474 self
475 }
476
477 /// The options of the underlying [`JwtPresentationValidator`]: no audience, no nonce, and
478 /// [`PRESENTATION_TYP`] and [`CREDENTIAL_TYP`] accepted.
479 pub fn presentation_options(&self) -> PresentationValidationOptions {
480 let mut credentials = self.credentials.clone();
481 credentials
482 .additional_typ
483 .extend(CREDENTIAL_TYP.map(str::to_owned));
484 let mut options = PresentationValidationOptions::new()
485 .with_time(self.time)
486 .with_subject_holder(self.subject_holder)
487 .with_credentials(credentials)
488 .with_fail_fast(self.fail_fast);
489 options
490 .additional_typ
491 .extend(PRESENTATION_TYP.map(str::to_owned));
492 options
493 }
494}
495
496/// Decodes a linked presentation and its credentials without verifying or validating
497/// anything, accepting [`PRESENTATION_TYP`] and [`CREDENTIAL_TYP`].
498///
499/// # Errors
500///
501/// As [`decode_presentation`](crate::vc::validation::decode_presentation).
502pub fn decode(jwt: &Jwt) -> Result<DecodedPresentation, ValidationError> {
503 let presentation_typ: BTreeSet<String> = PRESENTATION_TYP.map(str::to_owned).into();
504 let credential_typ: BTreeSet<String> = CREDENTIAL_TYP.map(str::to_owned).into();
505 decode_presentation_with(jwt, &presentation_typ, &credential_typ)
506}
507
508/// Validates linked presentations: [`JwtPresentationValidator`] with the options fixed to the
509/// specification, plus the holder and the signer being the DID that links the presentation.
510///
511/// # Examples
512///
513/// ```
514/// # #[cfg(feature = "did-key")]
515/// # {
516/// use identity_kit::jose::DefaultVerifier;
517/// use identity_kit::linked_vp::LinkedVpValidator;
518/// use identity_kit::methods::DidKey;
519/// use identity_kit::resolver::Resolver;
520///
521/// let resolver = Resolver::builder().with(DidKey::default()).build();
522/// let validator = LinkedVpValidator::new(resolver, DefaultVerifier);
523/// assert!(!validator.presentation_validator().credential_validator().has_status_check());
524/// # }
525/// ```
526#[derive(Debug)]
527pub struct LinkedVpValidator<R, V = DefaultVerifier> {
528 presentations: JwtPresentationValidator<R, V>,
529}
530
531impl<R: KeyResolver, V: JwsVerifier> LinkedVpValidator<R, V> {
532 /// A validator resolving keys with `resolver` and verifying signatures with `verifier`.
533 pub fn new(resolver: R, verifier: V) -> Self {
534 Self {
535 presentations: JwtPresentationValidator::new(resolver, verifier),
536 }
537 }
538
539 /// A validator over `presentations`, with its status check.
540 pub fn from_presentation_validator(presentations: JwtPresentationValidator<R, V>) -> Self {
541 Self { presentations }
542 }
543
544 /// The presentation validator.
545 pub fn presentation_validator(&self) -> &JwtPresentationValidator<R, V> {
546 &self.presentations
547 }
548
549 /// Validates `jwt` as a presentation linked from the document of `did`.
550 ///
551 /// The holder, and the party the token's [`KeyReference`] names, are checked against
552 /// `did` before any key is resolved, so a presentation naming another DID never makes the
553 /// resolver fetch that DID's document: a DID URL must be of `did`, a key by value must be
554 /// the `did:jwk` of `did`, and a key set of an HTTPS issuer never belongs to a DID.
555 ///
556 /// # Errors
557 ///
558 /// [`LinkedVpError::HolderMismatch`] when the holder is not `did`, otherwise
559 /// [`LinkedVpError::Presentation`] with what [`JwtPresentationValidator::validate`]
560 /// reports; a key of another DID is a [`ValidationError::HolderMismatch`] there.
561 pub async fn validate(
562 &self,
563 jwt: &Jwt,
564 did: &Did,
565 options: &LinkedVpValidationOptions,
566 ) -> Result<DecodedPresentation, LinkedVpError> {
567 let holder = extract_holder(jwt).map_err(PresentationValidationError::presentation)?;
568 if holder != *did {
569 return Err(LinkedVpError::HolderMismatch {
570 did: did.clone(),
571 holder,
572 });
573 }
574 let jws = jwt
575 .decode()
576 .map_err(|e| PresentationValidationError::presentation(e.into()))?;
577 if let Ok(reference) = KeyReference::from_jws(&jws, Some(did.as_str())) {
578 let of_did = match &reference {
579 KeyReference::DidUrl(url) => url.did() == did,
580 KeyReference::Jwk(jwk) => reference.binds_to(Some(did.as_str()), jwk),
581 _ => false,
582 };
583 if !of_did {
584 return Err(PresentationValidationError::presentation(
585 ValidationError::HolderMismatch {
586 holder: did.clone().into(),
587 key: Box::new(reference),
588 },
589 )
590 .into());
591 }
592 }
593 let decoded = self
594 .presentations
595 .validate(jwt, &options.presentation_options())
596 .await?;
597 Ok(decoded)
598 }
599}
600
601/// What became of one URL of a `LinkedVerifiablePresentation` service.
602#[derive(Debug)]
603#[non_exhaustive]
604pub struct LinkedPresentationReport {
605 /// The id of the service.
606 pub service: DidUrl,
607 /// The URL; `None` when the service entry itself is invalid.
608 pub url: Option<Url>,
609 /// The presentation, validated, or why it was not.
610 pub result: Result<DecodedPresentation, LinkedVpError>,
611}
612
613/// Fetches the presentations a DID document links with an [`HttpClient`] and validates them
614/// with a [`LinkedVpValidator`].
615///
616/// The URLs come from the document, so the client fetches whatever its controller names:
617/// `http` must enforce the protections `ReqwestClient` (feature `reqwest`) does (HTTPS only,
618/// no redirects, no non-public addresses after name resolution, a body limit and a timeout),
619/// or fetching becomes a request forgery primitive. A DID document can also list any number of
620/// URLs, so [`validate_document`](Self::validate_document) fetches at most
621/// [`max_urls`](Self::max_urls) of them.
622#[derive(Debug)]
623pub struct LinkedVpClient<C, R, V = DefaultVerifier> {
624 http: C,
625 validator: LinkedVpValidator<R, V>,
626 max_urls: usize,
627}
628
629impl<C, R, V> LinkedVpClient<C, R, V> {
630 /// The most URLs [`new`](Self::new) fetches for one DID document: 32.
631 pub const DEFAULT_MAX_URLS: usize = 32;
632}
633
634impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> LinkedVpClient<C, R, V> {
635 /// A client fetching with `http` and validating with `validator`, fetching at most
636 /// [`DEFAULT_MAX_URLS`](Self::DEFAULT_MAX_URLS) URLs of a DID document.
637 pub fn new(http: C, validator: LinkedVpValidator<R, V>) -> Self {
638 Self {
639 http,
640 validator,
641 max_urls: Self::DEFAULT_MAX_URLS,
642 }
643 }
644
645 /// This client fetching at most `max_urls` URLs of a DID document.
646 #[must_use]
647 pub fn with_max_urls(mut self, max_urls: usize) -> Self {
648 self.max_urls = max_urls;
649 self
650 }
651
652 /// The most URLs [`validate_document`](Self::validate_document) fetches for one document.
653 pub fn max_urls(&self) -> usize {
654 self.max_urls
655 }
656
657 /// The HTTP client.
658 pub fn http(&self) -> &C {
659 &self.http
660 }
661
662 /// The validator.
663 pub fn validator(&self) -> &LinkedVpValidator<R, V> {
664 &self.validator
665 }
666
667 /// The presentation JWT at `url`, unverified: the body, trimmed, must be one compact JWT.
668 ///
669 /// # Errors
670 ///
671 /// [`LinkedVpError::UnsupportedEndpoint`] for a URL that is not `https`, without fetching;
672 /// [`LinkedVpError::Http`] or [`LinkedVpError::HttpStatus`] when there is no `2xx`
673 /// response; [`LinkedVpError::UnsupportedFormat`] for a JSON-LD presentation;
674 /// [`LinkedVpError::InvalidBody`] for anything else that is not a compact JWT.
675 pub async fn fetch(&self, url: &Url) -> Result<Jwt, LinkedVpError> {
676 if url.scheme() != "https" {
677 return Err(LinkedVpError::UnsupportedEndpoint(url.clone()));
678 }
679 let response = self
680 .http
681 .get(url)
682 .await
683 .map_err(|source| LinkedVpError::Http {
684 url: url.clone(),
685 source,
686 })?;
687 if !(200..300).contains(&response.status) {
688 return Err(LinkedVpError::HttpStatus {
689 url: url.clone(),
690 status: response.status,
691 });
692 }
693 let invalid = |reason: &'static str, source: Box<dyn Error + Send + Sync>| {
694 LinkedVpError::InvalidBody {
695 url: url.clone(),
696 reason,
697 source: Some(source),
698 }
699 };
700 let body = core::str::from_utf8(&response.body)
701 .map_err(|e| invalid("the body is not UTF-8", Box::new(e)))?
702 .trim();
703 if body.starts_with('{') {
704 return Err(LinkedVpError::UnsupportedFormat(url.clone()));
705 }
706 Jwt::new(body).map_err(|e: JwsError| invalid("not a compact JWS", Box::new(e)))
707 }
708
709 /// Fetches and validates `url` as a presentation linked from the document of `did`.
710 ///
711 /// # Errors
712 ///
713 /// As [`fetch`](Self::fetch) and [`LinkedVpValidator::validate`].
714 pub async fn validate_url(
715 &self,
716 url: &Url,
717 did: &Did,
718 options: &LinkedVpValidationOptions,
719 ) -> Result<DecodedPresentation, LinkedVpError> {
720 let jwt = self.fetch(url).await?;
721 self.validator.validate(&jwt, did, options).await
722 }
723
724 /// Fetches and validates every presentation `document` links, one report per URL in
725 /// document order, and one for each invalid service entry. URLs past the first
726 /// [`max_urls`](Self::max_urls) are not fetched and report [`LinkedVpError::TooManyUrls`].
727 pub async fn validate_document(
728 &self,
729 document: &DidDocument,
730 options: &LinkedVpValidationOptions,
731 ) -> Vec<LinkedPresentationReport> {
732 let mut reports = Vec::new();
733 let mut fetched = 0;
734 for service in document
735 .service
736 .iter()
737 .filter(|service| service.has_type(SERVICE_TYPE))
738 {
739 let linked = match linked_vp_service(service) {
740 Ok(linked) => linked,
741 Err(error) => {
742 reports.push(LinkedPresentationReport {
743 service: service.id.clone(),
744 url: None,
745 result: Err(error),
746 });
747 continue;
748 }
749 };
750 for url in linked.urls {
751 let result = if fetched < self.max_urls {
752 fetched += 1;
753 self.validate_url(&url, &document.id, options).await
754 } else {
755 Err(LinkedVpError::TooManyUrls(self.max_urls))
756 };
757 reports.push(LinkedPresentationReport {
758 service: service.id.clone(),
759 url: Some(url),
760 result,
761 });
762 }
763 }
764 reports
765 }
766}
767
768impl<C: HttpClient, V: JwsVerifier> LinkedVpClient<C, Resolver, V> {
769 /// Resolves `did` with the validator's resolver, then validates every presentation its
770 /// document links, as [`validate_document`](Self::validate_document).
771 ///
772 /// # Errors
773 ///
774 /// [`LinkedVpError::Resolution`] when `did` does not resolve to a document.
775 pub async fn validate_did(
776 &self,
777 did: &Did,
778 options: &LinkedVpValidationOptions,
779 ) -> Result<Vec<LinkedPresentationReport>, LinkedVpError> {
780 let document = self
781 .validator
782 .presentation_validator()
783 .credential_validator()
784 .resolver()
785 .resolve_document(did)
786 .await?;
787 Ok(self.validate_document(&document, options).await)
788 }
789}