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