Skip to main content

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}