identity_kit/domain_linkage/mod.rs
1//! DIF Well Known DID Configuration: proving that the controller of an origin and the
2//! controller of a DID are the same entity.
3//!
4//! An origin publishes a [`DomainLinkageConfiguration`] at `/.well-known/did-configuration.json`
5//! ([`configuration_url`]) holding one domain linkage credential per linked DID. This crate
6//! handles the JSON Web Token proof format: [`DomainLinkageCredentialBuilder`] issues such a
7//! credential (a VCDM 1.1 [`Credential`] of type `DomainLinkageCredential` whose subject is
8//! the DID with the `origin`), and [`DomainLinkageValidator`] checks one, or a whole
9//! configuration, against the origin it was fetched from, resolving the DID's key under
10//! `assertionMethod`. Linked Data Proof entries are carried as data and not validated.
11//!
12//! The specification writes the origin with its scheme (`https://identity.foundation`), its
13//! own JWT example without (`identity.foundation`); the validator normalizes both to the
14//! `scheme://host[:port]` form, `https` when none is given, before comparing.
15//!
16//! # Fetching
17//!
18//! [`fetch_configuration`] gets the resource of an origin through an [`HttpClient`], the one
19//! `did:web` and status lists use, telling an origin that publishes no configuration
20//! ([`DomainLinkageError::NotPublished`], on `404` or `410`) apart from one whose
21//! configuration could not be fetched or read. [`DomainLinkageClient`] fetches and validates
22//! in one call, for an origin, for one DID at an origin, or for every origin a DID document
23//! lists in its `LinkedDomains` services ([`services`]), reporting per origin.
24//!
25//! # Maintaining a configuration
26//!
27//! The controller of an origin reads back the credentials it published with
28//! [`DomainLinkageConfiguration::entries`]: the DID, origin and validity period of each JWT
29//! entry ([`UnverifiedLinkage`]), read **without verifying it**, since it signed them itself.
30//! [`earliest_expiry`](DomainLinkageConfiguration::earliest_expiry) says when to renew,
31//! [`remove_origin`](DomainLinkageConfiguration::remove_origin) unlinks an origin, and
32//! [`linked_dids`](DomainLinkageConfiguration::linked_dids) is a public `Vec` for anything
33//! else, `retain` included.
34//!
35//! # Examples
36//!
37//! Verifying a domain: the specification's example resource, served for
38//! `https://identity.foundation`, links one `did:key`, and another origin publishes nothing.
39//!
40//! ```
41//! # #[cfg(feature = "did-key")]
42//! # {
43//! use chrono::{TimeZone as _, Utc};
44//! use identity_kit::did::Did;
45//! use identity_kit::domain_linkage::{DomainLinkageClient, DomainLinkageValidator};
46//! use identity_kit::jose::DefaultVerifier;
47//! use identity_kit::methods::DidKey;
48//! use identity_kit::methods::web::{HttpClient, HttpError, HttpResponse};
49//! use identity_kit::resolver::Resolver;
50//! use identity_kit::vc::validation::{CredentialValidationOptions, TimeOptions};
51//! use url::Url;
52//!
53//! /// Serves the example resource of `identity.foundation` and nothing else.
54//! struct Static;
55//!
56//! #[async_trait::async_trait]
57//! impl HttpClient for Static {
58//! async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
59//! Ok(match url.as_str() {
60//! "https://identity.foundation/.well-known/did-configuration.json" => {
61//! # let resource = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/did_configuration/resource.json"));
62//! HttpResponse::new(200, resource)
63//! }
64//! _ => HttpResponse::new(404, Vec::new()),
65//! })
66//! }
67//! }
68//!
69//! # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
70//! let resolver = Resolver::builder().with(DidKey::default()).build();
71//! let client = DomainLinkageClient::new(Static, DomainLinkageValidator::new(resolver, DefaultVerifier));
72//! // The example credential is valid from 2020-12-04 to 2025-12-04.
73//! let options = CredentialValidationOptions::new()
74//! .with_time(TimeOptions::at(Utc.with_ymd_and_hms(2021, 1, 1, 0, 0, 0).unwrap()));
75//!
76//! let report = client
77//! .validate_origin(&Url::parse("https://identity.foundation")?, &options)
78//! .await?;
79//! assert_eq!(
80//! report.dids().map(Did::as_str).collect::<Vec<_>>(),
81//! ["did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM"]
82//! );
83//!
84//! let error = client
85//! .validate_origin(&Url::parse("https://example.com")?, &options)
86//! .await
87//! .unwrap_err();
88//! assert!(error.is_not_published());
89//! # Ok::<(), Box<dyn std::error::Error>>(())
90//! # })?;
91//! # }
92//! # Ok::<(), Box<dyn std::error::Error>>(())
93//! ```
94
95use core::fmt;
96use std::collections::BTreeMap;
97use std::error::Error;
98
99use chrono::{DateTime, Utc};
100use serde::{Deserialize, Deserializer, Serialize};
101use serde_json::Value;
102use url::Url;
103
104use crate::cause::cause;
105use crate::did::{Did, DidDocument, DidUrl, ResolutionError, Service, ServiceEndpoint, Uri};
106use crate::jose::{DefaultVerifier, JwsHeader, JwsVerifier, Signer};
107use crate::methods::web::{HttpClient, HttpError};
108use crate::resolver::Resolver;
109use crate::vc::validation::{
110 CredentialValidationOptions, DecodedCredential, JwtCredentialValidator, KeyReferenceError,
111 KeyResolver, ValidationError, decode_credential,
112};
113use crate::vc::{Credential, CredentialError, Jwt, JwtClaims, Subject};
114
115/// The context of the DID configuration resource and of domain linkage credentials.
116pub const CONTEXT: &str = "https://identity.foundation/.well-known/did-configuration/v1";
117
118/// The credential type, `DomainLinkageCredential`.
119pub const CREDENTIAL_TYPE: &str = "DomainLinkageCredential";
120
121/// The path of the resource under an origin.
122pub const WELL_KNOWN_PATH: &str = "/.well-known/did-configuration.json";
123
124/// The type of the DID document service listing the origins of a DID, `LinkedDomains`.
125pub const SERVICE_TYPE: &str = "LinkedDomains";
126
127/// The member of a map `serviceEndpoint` holding the origins of a `LinkedDomains` service.
128const ORIGINS: &str = "origins";
129
130/// The URL of the DID configuration resource of `origin`.
131///
132/// # Errors
133///
134/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
135///
136/// # Examples
137///
138/// ```
139/// use identity_kit::domain_linkage::configuration_url;
140/// use url::Url;
141///
142/// let url = configuration_url(&Url::parse("https://identity.foundation")?)?;
143/// assert_eq!(url.as_str(), "https://identity.foundation/.well-known/did-configuration.json");
144/// # Ok::<(), Box<dyn std::error::Error>>(())
145/// ```
146pub fn configuration_url(origin: &Url) -> Result<Url, DomainLinkageError> {
147 let origin = normalize_origin(origin.as_str())?;
148 Url::parse(&format!("{origin}{WELL_KNOWN_PATH}")).map_err(|e| {
149 invalid_origin(
150 &origin,
151 "its well-known URL does not parse",
152 Some(Box::new(e)),
153 )
154 })
155}
156
157/// `origin` as `scheme://host[:port]`, lowercased, with `https` assumed when no scheme is
158/// given and a path, query or fragment dropped.
159///
160/// # Errors
161///
162/// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host, or one that is not an
163/// RFC 3986 host.
164pub fn normalize_origin(origin: &str) -> Result<String, DomainLinkageError> {
165 let with_scheme = if has_scheme(origin) {
166 origin.to_owned()
167 } else {
168 format!("https://{origin}")
169 };
170 let url = Url::parse(&with_scheme)
171 .map_err(|e| invalid_origin(origin, "not a URL", Some(Box::new(e))))?;
172 if url.host_str().is_none() {
173 return Err(invalid_origin(origin, "no host", None));
174 }
175 let normalized = url.origin();
176 if !normalized.is_tuple() {
177 return Err(invalid_origin(origin, "not a domain origin", None));
178 }
179 let normalized = normalized.ascii_serialization();
180 // The WHATWG URL parser accepts host characters RFC 3986 does not, such as `` ` ``.
181 Uri::parse(&normalized)
182 .map_err(|e| invalid_origin(origin, "not an RFC 3986 URI", Some(Box::new(e))))?;
183 Ok(normalized)
184}
185
186/// A [`DomainLinkageError::InvalidOrigin`] for `origin`.
187fn invalid_origin(
188 origin: &str,
189 reason: &str,
190 source: Option<Box<dyn Error + Send + Sync>>,
191) -> DomainLinkageError {
192 DomainLinkageError::InvalidOrigin {
193 origin: origin.to_owned(),
194 reason: reason.to_owned(),
195 source,
196 }
197}
198
199/// Whether `s` starts with a URI scheme (RFC 3986 §3.1) rather than a `host:port`.
200fn has_scheme(s: &str) -> bool {
201 let Some((scheme, rest)) = s.split_once(':') else {
202 return false;
203 };
204 let mut chars = scheme.chars();
205 chars.next().is_some_and(|c| c.is_ascii_alphabetic())
206 && chars.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'))
207 && !rest.starts_with(|c: char| c.is_ascii_digit())
208}
209
210/// A `LinkedDomains` service of `id` listing `origins`, normalized: the origin string when
211/// there is one, `{"origins": [...]}` otherwise, the two forms the specification defines.
212///
213/// # Errors
214///
215/// [`DomainLinkageError::InvalidService`] when `origins` is empty or an origin has no host.
216///
217/// # Examples
218///
219/// ```
220/// use identity_kit::did::{DidUrl, ServiceEndpoint};
221/// use identity_kit::domain_linkage::{SERVICE_TYPE, service};
222/// use url::Url;
223///
224/// let service = service(
225/// DidUrl::parse("did:example:123#foo")?,
226/// [Url::parse("https://foo.example.com")?, Url::parse("https://identity.foundation")?],
227/// )?;
228/// assert!(service.has_type(SERVICE_TYPE));
229/// assert_eq!(
230/// serde_json::to_value(&service.service_endpoint)?,
231/// serde_json::json!({ "origins": ["https://foo.example.com", "https://identity.foundation"] })
232/// );
233/// # Ok::<(), Box<dyn std::error::Error>>(())
234/// ```
235pub fn service(
236 id: DidUrl,
237 origins: impl IntoIterator<Item = Url>,
238) -> Result<Service, DomainLinkageError> {
239 let invalid = |reason: String, source: Option<Box<dyn Error + Send + Sync>>| {
240 DomainLinkageError::InvalidService {
241 id: id.clone(),
242 reason,
243 source,
244 }
245 };
246 let origins = origins
247 .into_iter()
248 .map(|origin| {
249 normalize_origin(origin.as_str())
250 .map_err(|e| invalid(format!("origin `{origin}` is invalid"), Some(Box::new(e))))
251 })
252 .collect::<Result<Vec<_>, _>>()?;
253 let endpoint = match origins.as_slice() {
254 [] => return Err(invalid("no origin".to_owned(), None)),
255 [origin] => ServiceEndpoint::Uri(
256 Uri::parse(origin)
257 .map_err(|e| invalid(format!("`{origin}` is not a URI"), Some(Box::new(e))))?,
258 ),
259 _ => ServiceEndpoint::Map([(ORIGINS.to_owned(), Value::from(origins))].into()),
260 };
261 Ok(Service::new(id, SERVICE_TYPE, endpoint))
262}
263
264/// A `LinkedDomains` service of a DID document, with its origins.
265#[derive(Debug, Clone, PartialEq, Eq)]
266#[non_exhaustive]
267pub struct LinkedDomainsService<'a> {
268 /// The service entry.
269 pub service: &'a Service,
270 /// Its origins, normalized, in document order.
271 pub origins: Vec<Url>,
272}
273
274impl LinkedDomainsService<'_> {
275 /// The id of the service.
276 pub fn id(&self) -> &DidUrl {
277 &self.service.id
278 }
279}
280
281/// The `LinkedDomains` services of `document`, in document order, with their origins
282/// normalized as by [`normalize_origin`].
283///
284/// `serviceEndpoint` is an origin string or an object whose `origins` member is a non-empty
285/// array of origin strings, the two forms the specification defines, or an array of origins,
286/// the set form DID Core allows for any service. A service of that type whose endpoint is
287/// none of these, or holds something that is not an origin, is a
288/// [`DomainLinkageError::InvalidService`] rather than silently skipped.
289///
290/// # Examples
291///
292/// ```
293/// use identity_kit::did::DidDocument;
294/// use identity_kit::domain_linkage::services;
295/// use serde_json::json;
296///
297/// let document: DidDocument = serde_json::from_value(json!({
298/// "id": "did:example:123",
299/// "service": [
300/// {
301/// "id": "did:example:123#foo",
302/// "type": "LinkedDomains",
303/// "serviceEndpoint": { "origins": ["https://foo.example.com", "https://identity.foundation"] }
304/// },
305/// { "id": "did:example:123#bar", "type": "LinkedDomains", "serviceEndpoint": "https://bar.example.com" }
306/// ]
307/// }))?;
308/// let origins: Vec<String> = services(&document)
309/// .map(|service| service.map(|service| service.origins))
310/// .collect::<Result<Vec<_>, _>>()?
311/// .into_iter()
312/// .flatten()
313/// .map(|origin| origin.origin().ascii_serialization())
314/// .collect();
315/// assert_eq!(origins, ["https://foo.example.com", "https://identity.foundation", "https://bar.example.com"]);
316/// # Ok::<(), Box<dyn std::error::Error>>(())
317/// ```
318pub fn services(
319 document: &DidDocument,
320) -> impl Iterator<Item = Result<LinkedDomainsService<'_>, DomainLinkageError>> {
321 document
322 .service
323 .iter()
324 .filter(|service| service.has_type(SERVICE_TYPE))
325 .map(linked_domains_service)
326}
327
328/// `service` with its origins read and normalized.
329fn linked_domains_service(
330 service: &Service,
331) -> Result<LinkedDomainsService<'_>, DomainLinkageError> {
332 let invalid = |reason: String, source: Option<Box<dyn Error + Send + Sync>>| {
333 DomainLinkageError::InvalidService {
334 id: service.id.clone(),
335 reason,
336 source,
337 }
338 };
339 let origins: Vec<&str> = match &service.service_endpoint {
340 ServiceEndpoint::Map(map) => {
341 let Some(Value::Array(origins)) = map.get(ORIGINS) else {
342 return Err(invalid(
343 format!("`serviceEndpoint` is a map without an `{ORIGINS}` array"),
344 None,
345 ));
346 };
347 origins
348 .iter()
349 .map(|origin| {
350 origin
351 .as_str()
352 .ok_or_else(|| invalid(format!("origin {origin} is not a string"), None))
353 })
354 .collect::<Result<_, _>>()?
355 }
356 endpoint => endpoint.uris().map(Uri::as_str).collect(),
357 };
358 if origins.is_empty() {
359 return Err(invalid("no origin".to_owned(), None));
360 }
361 let origins = origins
362 .into_iter()
363 .map(|origin| {
364 let normalized = normalize_origin(origin)
365 .map_err(|e| invalid(format!("origin {origin:?} is invalid"), Some(Box::new(e))))?;
366 Url::parse(&normalized)
367 .map_err(|e| invalid(format!("origin {origin:?} is not a URL"), Some(Box::new(e))))
368 })
369 .collect::<Result<_, _>>()?;
370 Ok(LinkedDomainsService { service, origins })
371}
372
373/// One entry of `linked_dids`.
374#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
375#[serde(untagged)]
376#[non_exhaustive]
377pub enum LinkedDid {
378 /// A domain linkage credential in the JSON Web Token proof format.
379 Jwt(Jwt),
380 /// A domain linkage credential in the Linked Data Proof format, carried as data. An entry
381 /// that is neither a compact JWT nor an object is a parse error, not a silently skipped
382 /// credential.
383 LinkedDataProof(serde_json::Map<String, Value>),
384}
385
386/// The DID configuration resource: `@context` and the domain linkage credentials.
387///
388/// # Examples
389///
390/// ```
391/// use identity_kit::domain_linkage::{DomainLinkageConfiguration, LinkedDid};
392/// use serde_json::json;
393///
394/// let configuration: DomainLinkageConfiguration = serde_json::from_value(json!({
395/// "@context": "https://identity.foundation/.well-known/did-configuration/v1",
396/// "linked_dids": [
397/// "eyJhbGciOiJFZERTQSJ9.RXhhbXBsZSBvZiBFZDI1NTE5IHNpZ25pbmc.hgyY0il_MGCjP0JzlnLWG1PPOt7-09PGcvMg3AIbQR6dWbhijcNR4ki4iylGjg5BhVsPt9g7sVvpAr_MuM0KAg"
398/// ]
399/// }))?;
400/// assert_eq!(configuration.jwts().count(), 1);
401/// assert!(serde_json::from_value::<DomainLinkageConfiguration>(json!({
402/// "@context": "https://example.com/other", "linked_dids": []
403/// })).is_err());
404/// # Ok::<(), serde_json::Error>(())
405/// ```
406#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
407#[non_exhaustive]
408pub struct DomainLinkageConfiguration {
409 /// `@context`, [`CONTEXT`].
410 #[serde(rename = "@context")]
411 pub context: String,
412 /// The domain linkage credentials, one per linked DID.
413 pub linked_dids: Vec<LinkedDid>,
414}
415
416impl Default for DomainLinkageConfiguration {
417 fn default() -> Self {
418 Self::new()
419 }
420}
421
422impl DomainLinkageConfiguration {
423 /// An empty configuration.
424 pub fn new() -> Self {
425 Self {
426 context: CONTEXT.to_owned(),
427 linked_dids: Vec::new(),
428 }
429 }
430
431 /// This configuration with `credential` added.
432 #[must_use]
433 pub fn with_credential(mut self, credential: Jwt) -> Self {
434 self.linked_dids.push(LinkedDid::Jwt(credential));
435 self
436 }
437
438 /// The credentials in the JSON Web Token proof format, with their positions.
439 pub fn jwts(&self) -> impl Iterator<Item = (usize, &Jwt)> {
440 self.linked_dids
441 .iter()
442 .enumerate()
443 .filter_map(|(i, entry)| match entry {
444 LinkedDid::Jwt(jwt) => Some((i, jwt)),
445 _ => None,
446 })
447 }
448
449 /// The credentials in the JSON Web Token proof format, with their positions, each read
450 /// **without verifying it** ([`UnverifiedLinkage::from_jwt`]), or why it does not read.
451 /// Linked Data Proof entries are not among them.
452 ///
453 /// For the controller of the origin, maintaining the configuration it publishes; a
454 /// verifier validates with [`DomainLinkageValidator`].
455 pub fn entries(
456 &self,
457 ) -> impl Iterator<Item = (usize, Result<UnverifiedLinkage, DomainLinkageError>)> + '_ {
458 self.jwts()
459 .map(|(index, jwt)| (index, UnverifiedLinkage::from_jwt(jwt)))
460 }
461
462 /// The earliest expiry among the JWT credentials that read ([`entries`](Self::entries)),
463 /// or `None` when none reads or none expires: when the configuration needs renewing at
464 /// the latest.
465 pub fn earliest_expiry(&self) -> Option<DateTime<Utc>> {
466 self.entries()
467 .filter_map(|(_, entry)| entry.ok()?.expires)
468 .min()
469 }
470
471 /// Removes the JWT credentials linking `origin`, compared normalized
472 /// ([`normalize_origin`]), and returns how many it removed. Credentials that do not
473 /// read ([`entries`](Self::entries)) and Linked Data Proof entries are kept; remove them
474 /// from [`linked_dids`](Self::linked_dids) directly.
475 ///
476 /// # Errors
477 ///
478 /// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
479 pub fn remove_origin(&mut self, origin: &Url) -> Result<usize, DomainLinkageError> {
480 let origin = normalize_origin(origin.as_str())?;
481 let before = self.linked_dids.len();
482 self.linked_dids.retain(|entry| match entry {
483 LinkedDid::Jwt(jwt) => {
484 UnverifiedLinkage::from_jwt(jwt).map_or(true, |linkage| linkage.origin != origin)
485 }
486 LinkedDid::LinkedDataProof(_) => true,
487 });
488 Ok(before - self.linked_dids.len())
489 }
490
491 /// Checks that `@context` is [`CONTEXT`].
492 ///
493 /// # Errors
494 ///
495 /// [`DomainLinkageError::InvalidContext`].
496 pub fn check(&self) -> Result<(), DomainLinkageError> {
497 if self.context != CONTEXT {
498 return Err(DomainLinkageError::InvalidContext(self.context.clone()));
499 }
500 Ok(())
501 }
502}
503
504impl<'de> Deserialize<'de> for DomainLinkageConfiguration {
505 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
506 #[derive(Deserialize)]
507 #[serde(deny_unknown_fields)]
508 struct Raw {
509 #[serde(rename = "@context")]
510 context: String,
511 linked_dids: Vec<LinkedDid>,
512 }
513 let raw = Raw::deserialize(deserializer)?;
514 let configuration = Self {
515 context: raw.context,
516 linked_dids: raw.linked_dids,
517 };
518 configuration.check().map_err(serde::de::Error::custom)?;
519 Ok(configuration)
520 }
521}
522
523/// A domain linkage credential in the JSON Web Token proof format as its JWT says, read
524/// **without verifying it**: neither the signature nor the format rules
525/// [`DomainLinkageValidator`] enforces are checked, only what these fields need.
526///
527/// For the controller of an origin reading back credentials it signed itself, to renew them
528/// before they expire or to drop an origin's; never for deciding whether a DID and an origin
529/// are linked.
530///
531/// # Examples
532///
533/// ```
534/// use chrono::{TimeZone as _, Utc};
535/// use identity_kit::domain_linkage::UnverifiedLinkage;
536/// use identity_kit::vc::Jwt;
537///
538/// // The example JWT of the specification, whose `origin` has no scheme.
539/// # let jwt: Jwt = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/vectors/did_configuration/jwt.txt")).trim().parse()?;
540/// let linkage = UnverifiedLinkage::from_jwt(&jwt)?;
541/// assert_eq!(linkage.did.as_str(), "did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM");
542/// assert_eq!(linkage.origin, "https://identity.foundation");
543/// assert_eq!(linkage.expires, Some(Utc.with_ymd_and_hms(2025, 12, 4, 20, 12, 19).unwrap()));
544/// # Ok::<(), Box<dyn std::error::Error>>(())
545/// ```
546#[derive(Debug, Clone, PartialEq, Eq)]
547#[non_exhaustive]
548pub struct UnverifiedLinkage {
549 /// The DID the credential links: its `credentialSubject.id`.
550 pub did: Did,
551 /// The origin it links: its `credentialSubject.origin`, normalized
552 /// ([`normalize_origin`]).
553 pub origin: String,
554 /// The issuer it names, which the format requires to be [`did`](Self::did).
555 pub issuer: Uri,
556 /// When it becomes valid: `nbf`, or `issuanceDate` without one.
557 pub not_before: DateTime<Utc>,
558 /// When it expires: `exp`, or `expirationDate` without one, when either is present.
559 pub expires: Option<DateTime<Utc>>,
560}
561
562impl UnverifiedLinkage {
563 /// Reads `jwt` without verifying it.
564 ///
565 /// # Errors
566 ///
567 /// [`DomainLinkageError::Validation`] when `jwt` does not decode as a credential JWT,
568 /// [`DomainLinkageError::SubjectNotDid`] when its one subject is not a DID, and
569 /// [`DomainLinkageError::InvalidOriginProperty`] or [`DomainLinkageError::InvalidOrigin`]
570 /// when its `origin` is not one origin.
571 pub fn from_jwt(jwt: &Jwt) -> Result<Self, DomainLinkageError> {
572 let credential = decode_credential(jwt)?.credential;
573 let subject = credential
574 .subject()
575 .ok_or(DomainLinkageError::SubjectNotDid(None))?;
576 Ok(Self {
577 did: subject_did(subject)?,
578 origin: subject_origin(subject)?,
579 issuer: credential.issuer.id().clone(),
580 not_before: credential.issuance_date,
581 expires: credential.expiration_date,
582 })
583 }
584}
585
586/// Builds a domain linkage credential in the JSON Web Token proof format: a VCDM 1.1
587/// credential of type `DomainLinkageCredential` issued by `did` about `did` and `origin`,
588/// carried in the `vc` claim with `iss`, `sub`, `nbf` and `exp` and nothing else.
589///
590/// # Examples
591///
592/// ```
593/// use chrono::{TimeZone as _, Utc};
594/// use identity_kit::did::Did;
595/// use identity_kit::domain_linkage::DomainLinkageCredentialBuilder;
596/// use url::Url;
597///
598/// let did = Did::parse("did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM")?;
599/// let builder = DomainLinkageCredentialBuilder::new(
600/// did.clone(),
601/// &Url::parse("https://identity.foundation")?,
602/// Utc.with_ymd_and_hms(2020, 12, 4, 20, 12, 19).unwrap(),
603/// Utc.with_ymd_and_hms(2025, 12, 4, 20, 12, 19).unwrap(),
604/// )?;
605/// let claims = builder.jwt_claims();
606/// assert_eq!(claims.iss.as_deref(), Some(did.as_str()));
607/// assert_eq!(claims.sub, claims.iss);
608/// assert_eq!(claims.nbf, Some(1607112739));
609/// assert_eq!(claims.exp, Some(1764879139));
610/// assert_eq!(claims.additional["vc"]["credentialSubject"]["origin"], "https://identity.foundation");
611/// # Ok::<(), Box<dyn std::error::Error>>(())
612/// ```
613#[derive(Debug, Clone, PartialEq, Eq)]
614#[non_exhaustive]
615pub struct DomainLinkageCredentialBuilder {
616 /// The DID: issuer and subject of the credential.
617 pub did: Did,
618 /// The origin, normalized.
619 pub origin: String,
620 /// `issuanceDate` and `nbf`.
621 pub issuance_date: DateTime<Utc>,
622 /// `expirationDate` and `exp`.
623 pub expiration_date: DateTime<Utc>,
624}
625
626impl DomainLinkageCredentialBuilder {
627 /// A credential linking `did` and `origin`, valid from `issuance_date` to
628 /// `expiration_date`.
629 ///
630 /// # Errors
631 ///
632 /// [`DomainLinkageError::InvalidOrigin`] when `origin` has no host.
633 pub fn new(
634 did: Did,
635 origin: &Url,
636 issuance_date: DateTime<Utc>,
637 expiration_date: DateTime<Utc>,
638 ) -> Result<Self, DomainLinkageError> {
639 Ok(Self {
640 did,
641 origin: normalize_origin(origin.as_str())?,
642 issuance_date,
643 expiration_date,
644 })
645 }
646
647 /// The credential.
648 pub fn credential(&self) -> Credential {
649 let subject =
650 Subject::new(Uri::from(self.did.clone())).with_property("origin", self.origin.clone());
651 Credential::new(self.did.clone(), self.issuance_date, subject)
652 .with_context(CONTEXT)
653 .with_type(CREDENTIAL_TYPE)
654 .with_expiration_date(self.expiration_date)
655 }
656
657 /// The JWT claims: `iss` and `sub` the DID, `nbf` and `exp` the validity period, `vc` the
658 /// whole credential.
659 ///
660 /// # Panics
661 ///
662 /// Never: the credential this builder makes always serializes to JSON.
663 pub fn jwt_claims(&self) -> JwtClaims {
664 let mut claims = JwtClaims::new()
665 .with_nbf(self.issuance_date)
666 .with_exp(self.expiration_date);
667 claims.iss = Some(self.did.as_str().to_owned());
668 claims.sub = Some(self.did.as_str().to_owned());
669 claims.additional.insert(
670 "vc".to_owned(),
671 serde_json::to_value(self.credential()).expect("a credential serializes"),
672 );
673 claims
674 }
675
676 /// The credential signed with `signer` under a header of `alg` and `kid` only, `kid`
677 /// naming the verification method of the DID.
678 ///
679 /// # Errors
680 ///
681 /// [`DomainLinkageError::Validation`] with [`ValidationError::Jws`] when signing fails.
682 pub async fn sign(&self, kid: &DidUrl, signer: &dyn Signer) -> Result<Jwt, DomainLinkageError> {
683 let header = JwsHeader::new(signer.alg()).with_kid(kid.as_str());
684 let jwt = Jwt::sign(&header, &self.jwt_claims(), signer)
685 .await
686 .map_err(ValidationError::from)?;
687 Ok(jwt)
688 }
689}
690
691/// Why a domain linkage credential or configuration did not validate.
692#[derive(Debug, thiserror::Error)]
693#[non_exhaustive]
694pub enum DomainLinkageError {
695 /// An origin is not `scheme://host[:port]`. The source is the URL or URI error, when
696 /// there is one.
697 #[error("invalid origin {origin:?}: {reason}{}", cause(.source.as_ref()))]
698 InvalidOrigin {
699 /// The origin, as given.
700 origin: String,
701 /// What is wrong with it.
702 reason: String,
703 /// The underlying error.
704 source: Option<Box<dyn Error + Send + Sync>>,
705 },
706 /// The configuration's `@context` is not [`CONTEXT`].
707 #[error("`@context` is {0:?}, not `{CONTEXT}`")]
708 InvalidContext(String),
709 /// The JWT header has a `typ`, which the format forbids.
710 #[error("header `typ` {0:?} must not be present")]
711 TypPresent(String),
712 /// The JWT header has a parameter other than `alg` and `kid`.
713 #[error("header parameter `{0}` must not be present")]
714 UnexpectedHeaderParameter(String),
715 /// The JWT payload has a claim other than `iss`, `sub`, `nbf`, `exp` and `vc`.
716 #[error("claim `{0}` must not be present")]
717 UnexpectedClaim(String),
718 /// A required claim is missing: `iss`, `sub`, `exp` or `nbf`.
719 #[error("claim `{0}` is required")]
720 MissingClaim(&'static str),
721 /// The credential has an `id`, which the format forbids.
722 #[error("the credential must not have an `id`")]
723 IdPresent,
724 /// The credential's context does not include [`CONTEXT`].
725 #[error("the credential's `@context` does not include `{CONTEXT}`")]
726 MissingContext,
727 /// The credential's type does not include [`CREDENTIAL_TYPE`].
728 #[error("the credential's `type` does not include `{CREDENTIAL_TYPE}`")]
729 MissingType,
730 /// The credential has no `expirationDate`.
731 #[error("the credential must have an `expirationDate`")]
732 MissingExpirationDate,
733 /// `credentialSubject.id` is missing or not a DID.
734 #[error("`credentialSubject.id` {0:?} is not a DID")]
735 SubjectNotDid(Option<String>),
736 /// The issuer, `iss` or `sub` differs from `credentialSubject.id`.
737 #[error("{role} `{found}` is not the subject `{subject}`")]
738 SubjectMismatch {
739 /// `issuer`, `iss` or `sub`.
740 role: &'static str,
741 /// The value found.
742 found: String,
743 /// `credentialSubject.id`.
744 subject: Did,
745 },
746 /// `credentialSubject.origin` is missing or not one string.
747 #[error("`credentialSubject.origin` {0:?} is not one origin")]
748 InvalidOriginProperty(Option<Value>),
749 /// `credentialSubject.origin` is not the origin the resource was fetched from.
750 #[error("origin {found:?} is not {expected:?}")]
751 OriginMismatch {
752 /// The normalized origin expected.
753 expected: String,
754 /// The credential's origin, normalized.
755 found: String,
756 },
757 /// The credential did not decode or validate as a credential JWT.
758 #[error(transparent)]
759 Validation(#[from] ValidationError),
760 /// The configuration holds no JWT credential for the DID.
761 #[error("the configuration holds no JWT credential for `{0}`")]
762 NoCredentialFor(Did),
763 /// No credential of the configuration validated; the error of each JWT entry by its
764 /// position.
765 #[error("no domain linkage credential validated: {}", format_errors(.0))]
766 NoValidCredential(BTreeMap<usize, DomainLinkageError>),
767 /// A `LinkedDomains` service does not list one or more origins. The source is the
768 /// [`InvalidOrigin`](Self::InvalidOrigin) error of an origin, or the URI or URL error, when
769 /// there is one.
770 #[error("service `{id}`: {reason}{}", cause(.source.as_ref()))]
771 InvalidService {
772 /// The id of the service.
773 id: DidUrl,
774 /// What is wrong with it.
775 reason: String,
776 /// The underlying error.
777 source: Option<Box<dyn Error + Send + Sync>>,
778 },
779 /// The origin is not `https`, the only scheme the resource is fetched over.
780 #[error("`{0}` is not an HTTPS origin")]
781 InsecureOrigin(String),
782 /// The [`HttpClient`] got no response.
783 #[error("fetching `{url}` failed: {source}")]
784 Http {
785 /// The URL of the resource.
786 url: Url,
787 /// Why there was no response.
788 #[source]
789 source: HttpError,
790 },
791 /// The origin publishes no DID configuration: the server answered `404` or `410`. A
792 /// legitimate state of an origin rather than a failure; see
793 /// [`is_not_published`](Self::is_not_published).
794 #[error("`{url}` publishes no DID configuration (HTTP {status})")]
795 NotPublished {
796 /// The URL of the resource.
797 url: Url,
798 /// `404` or `410`.
799 status: u16,
800 },
801 /// The server answered with a status other than `2xx`, `404` and `410`.
802 #[error("fetching `{url}` returned HTTP {status}")]
803 HttpStatus {
804 /// The URL of the resource.
805 url: Url,
806 /// The status code.
807 status: u16,
808 },
809 /// The body is not a DID configuration resource: not JSON, not of its shape, or of
810 /// another `@context`.
811 #[error("`{url}` is not a DID configuration resource: {source}")]
812 InvalidConfiguration {
813 /// The URL of the resource.
814 url: Url,
815 /// Why the body did not deserialize.
816 #[source]
817 source: serde_json::Error,
818 },
819 /// The DID did not resolve.
820 #[error("resolving the DID failed: {0}")]
821 Resolution(#[from] ResolutionError),
822 /// The origin was not fetched: the DID document lists more origins than the client's
823 /// limit, given here.
824 #[error("the DID document lists more than {0} origins")]
825 TooManyOrigins(usize),
826 /// The entry was not validated: the configuration holds more JWT entries, or more naming
827 /// the DID, than the validator's limit, given here.
828 #[error("the configuration holds more than {0} credentials to validate")]
829 TooManyCredentials(usize),
830}
831
832impl DomainLinkageError {
833 /// Whether the origin publishes no DID configuration ([`NotPublished`](Self::NotPublished)),
834 /// as opposed to a configuration that could not be fetched, read or validated.
835 pub fn is_not_published(&self) -> bool {
836 matches!(self, Self::NotPublished { .. })
837 }
838}
839
840impl From<CredentialError> for DomainLinkageError {
841 fn from(error: CredentialError) -> Self {
842 Self::Validation(error.into())
843 }
844}
845
846/// `errors` as `index: error; …`.
847fn format_errors(errors: &BTreeMap<usize, DomainLinkageError>) -> String {
848 errors
849 .iter()
850 .map(|(index, error)| format!("{index}: {error}"))
851 .collect::<Vec<_>>()
852 .join("; ")
853}
854
855/// A validated DID configuration resource: which entries linked which DIDs, and which failed.
856#[derive(Debug)]
857#[non_exhaustive]
858pub struct DomainLinkageReport {
859 /// The entries that validated, by position, with the DID each links.
860 pub linked: Vec<(usize, Did, DecodedCredential)>,
861 /// The JWT entries that failed, by position.
862 pub failed: BTreeMap<usize, DomainLinkageError>,
863}
864
865impl DomainLinkageReport {
866 /// The DIDs linked to the origin, in the order of the resource.
867 pub fn dids(&self) -> impl Iterator<Item = &Did> {
868 self.linked.iter().map(|(_, did, _)| did)
869 }
870}
871
872/// Validates domain linkage credentials and DID configuration resources (DID Configuration
873/// Resource Verification): the subject is a DID equal to the issuer, `iss` and `sub`, the
874/// one `origin` is the requested origin, the credential has an expiration date, no `id`, no
875/// extra claims or header parameters, and its signature verifies under a key of the DID
876/// listed under `assertionMethod`.
877///
878/// A configuration is written by whoever controls the origin, and every entry validated
879/// resolves its issuer's DID, so [`validate_configuration`](Self::validate_configuration) and
880/// [`validate_linkage`](Self::validate_linkage) validate at most
881/// [`max_credentials`](Self::max_credentials) entries of one configuration.
882///
883/// # Examples
884///
885/// ```
886/// # #[cfg(feature = "did-key")]
887/// # {
888/// use identity_kit::domain_linkage::DomainLinkageValidator;
889/// use identity_kit::jose::DefaultVerifier;
890/// use identity_kit::methods::DidKey;
891/// use identity_kit::resolver::Resolver;
892///
893/// let resolver = Resolver::builder().with(DidKey::default()).build();
894/// let validator = DomainLinkageValidator::new(resolver, DefaultVerifier);
895/// assert!(!validator.credential_validator().has_status_check());
896/// # }
897/// ```
898#[derive(Debug)]
899pub struct DomainLinkageValidator<R, V = DefaultVerifier> {
900 credentials: JwtCredentialValidator<R, V>,
901 max_credentials: usize,
902}
903
904impl<R, V> DomainLinkageValidator<R, V> {
905 /// The most entries of one configuration [`new`](Self::new) validates: 32.
906 pub const DEFAULT_MAX_CREDENTIALS: usize = 32;
907}
908
909impl<R: KeyResolver, V: JwsVerifier> DomainLinkageValidator<R, V> {
910 /// A validator resolving keys with `resolver` and verifying signatures with `verifier`,
911 /// validating at most [`DEFAULT_MAX_CREDENTIALS`](Self::DEFAULT_MAX_CREDENTIALS) entries
912 /// of a configuration.
913 pub fn new(resolver: R, verifier: V) -> Self {
914 Self::from_credential_validator(JwtCredentialValidator::new(resolver, verifier))
915 }
916
917 /// A validator over `credentials`, validating at most
918 /// [`DEFAULT_MAX_CREDENTIALS`](Self::DEFAULT_MAX_CREDENTIALS) entries of a configuration.
919 pub fn from_credential_validator(credentials: JwtCredentialValidator<R, V>) -> Self {
920 Self {
921 credentials,
922 max_credentials: Self::DEFAULT_MAX_CREDENTIALS,
923 }
924 }
925
926 /// This validator validating at most `max_credentials` entries of a configuration.
927 #[must_use]
928 pub fn with_max_credentials(mut self, max_credentials: usize) -> Self {
929 self.max_credentials = max_credentials;
930 self
931 }
932
933 /// The most entries of one configuration [`validate_configuration`](Self::validate_configuration)
934 /// and [`validate_linkage`](Self::validate_linkage) validate.
935 pub fn max_credentials(&self) -> usize {
936 self.max_credentials
937 }
938
939 /// The credential validator.
940 pub fn credential_validator(&self) -> &JwtCredentialValidator<R, V> {
941 &self.credentials
942 }
943
944 /// Validates `jwt` as a domain linkage credential for `origin`.
945 ///
946 /// # Errors
947 ///
948 /// The first [`DomainLinkageError`] found: the format checks first, then the signature,
949 /// issuer binding and validity period of [`JwtCredentialValidator::validate`].
950 pub async fn validate_credential(
951 &self,
952 jwt: &Jwt,
953 origin: &Url,
954 options: &CredentialValidationOptions,
955 ) -> Result<DecodedCredential, DomainLinkageError> {
956 let expected = normalize_origin(origin.as_str())?;
957 let decoded = decode_credential(jwt)?;
958 check_format(&decoded, &expected)?;
959 self.credentials.validate(jwt, options).await?;
960 Ok(decoded)
961 }
962
963 /// Validates the credential of `configuration` linking `did` to `origin`: the first JWT
964 /// entry issued by `did` that validates. Entries naming `did` past the first
965 /// [`max_credentials`](Self::max_credentials) are not validated.
966 ///
967 /// # Errors
968 ///
969 /// [`DomainLinkageError::NoCredentialFor`] when no JWT entry names `did` as issuer,
970 /// [`DomainLinkageError::NoValidCredential`] with the error of each such entry otherwise,
971 /// [`DomainLinkageError::TooManyCredentials`] for those past the limit.
972 pub async fn validate_linkage(
973 &self,
974 configuration: &DomainLinkageConfiguration,
975 did: &Did,
976 origin: &Url,
977 options: &CredentialValidationOptions,
978 ) -> Result<DecodedCredential, DomainLinkageError> {
979 configuration.check()?;
980 let mut errors = BTreeMap::new();
981 let mut validated = 0;
982 for (index, jwt) in configuration.jwts() {
983 if issuer_of(jwt).as_ref() != Some(did) {
984 continue;
985 }
986 if validated == self.max_credentials {
987 errors.insert(
988 index,
989 DomainLinkageError::TooManyCredentials(self.max_credentials),
990 );
991 continue;
992 }
993 validated += 1;
994 match self.validate_credential(jwt, origin, options).await {
995 Ok(decoded) => return Ok(decoded),
996 Err(error) => {
997 errors.insert(index, error);
998 }
999 }
1000 }
1001 if errors.is_empty() {
1002 return Err(DomainLinkageError::NoCredentialFor(did.clone()));
1003 }
1004 Err(DomainLinkageError::NoValidCredential(errors))
1005 }
1006
1007 /// Validates every JWT entry of `configuration` for `origin`: succeeds when at least one
1008 /// does, reporting the rest as errors. Entries past the first
1009 /// [`max_credentials`](Self::max_credentials) are not validated and report
1010 /// [`DomainLinkageError::TooManyCredentials`].
1011 ///
1012 /// # Errors
1013 ///
1014 /// [`DomainLinkageError::InvalidContext`], or [`DomainLinkageError::NoValidCredential`]
1015 /// with the error of every JWT entry.
1016 ///
1017 /// # Panics
1018 ///
1019 /// Never: a credential that validates has a DID as its issuer.
1020 pub async fn validate_configuration(
1021 &self,
1022 configuration: &DomainLinkageConfiguration,
1023 origin: &Url,
1024 options: &CredentialValidationOptions,
1025 ) -> Result<DomainLinkageReport, DomainLinkageError> {
1026 configuration.check()?;
1027 let mut linked = Vec::new();
1028 let mut failed = BTreeMap::new();
1029 for (position, (index, jwt)) in configuration.jwts().enumerate() {
1030 if position >= self.max_credentials {
1031 failed.insert(
1032 index,
1033 DomainLinkageError::TooManyCredentials(self.max_credentials),
1034 );
1035 continue;
1036 }
1037 match self.validate_credential(jwt, origin, options).await {
1038 Ok(decoded) => {
1039 let did = Did::parse(decoded.credential.issuer.id().as_str())
1040 .expect("the format check accepted the issuer as a DID");
1041 linked.push((index, did, decoded));
1042 }
1043 Err(error) => {
1044 failed.insert(index, error);
1045 }
1046 }
1047 }
1048 if linked.is_empty() {
1049 return Err(DomainLinkageError::NoValidCredential(failed));
1050 }
1051 Ok(DomainLinkageReport { linked, failed })
1052 }
1053}
1054
1055/// Fetches the DID configuration resource of `origin` with `http`: a `GET` of
1056/// [`configuration_url`] over HTTPS, the body deserialized as a
1057/// [`DomainLinkageConfiguration`], so its `@context` is checked and Linked Data Proof entries
1058/// are carried as data. Nothing is validated.
1059///
1060/// # Errors
1061///
1062/// - [`DomainLinkageError::InvalidOrigin`] when `origin` has no host, and
1063/// [`DomainLinkageError::InsecureOrigin`] when it is not `https`, both without fetching;
1064/// - [`DomainLinkageError::NotPublished`] on `404` or `410`: the origin publishes no
1065/// configuration;
1066/// - [`DomainLinkageError::Http`] when there is no response, and
1067/// [`DomainLinkageError::HttpStatus`] for any other status that is not `2xx`;
1068/// - [`DomainLinkageError::InvalidConfiguration`] when the body is not a configuration.
1069pub async fn fetch_configuration<C: HttpClient + ?Sized>(
1070 http: &C,
1071 origin: &Url,
1072) -> Result<DomainLinkageConfiguration, DomainLinkageError> {
1073 let url = configuration_url(origin)?;
1074 if url.scheme() != "https" {
1075 return Err(DomainLinkageError::InsecureOrigin(
1076 url.origin().ascii_serialization(),
1077 ));
1078 }
1079 let response = match http.get(&url).await {
1080 Ok(response) => response,
1081 Err(source) => return Err(DomainLinkageError::Http { url, source }),
1082 };
1083 match response.status {
1084 200..=299 => {}
1085 status @ (404 | 410) => return Err(DomainLinkageError::NotPublished { url, status }),
1086 status => return Err(DomainLinkageError::HttpStatus { url, status }),
1087 }
1088 serde_json::from_slice(&response.body)
1089 .map_err(|source| DomainLinkageError::InvalidConfiguration { url, source })
1090}
1091
1092/// What became of one origin of a `LinkedDomains` service.
1093#[derive(Debug)]
1094#[non_exhaustive]
1095pub struct LinkedDomainReport {
1096 /// The id of the service.
1097 pub service: DidUrl,
1098 /// The origin, normalized; `None` when the service entry itself is invalid.
1099 pub origin: Option<Url>,
1100 /// The domain linkage credential of the DID at that origin, validated, or why there is
1101 /// none. [`DomainLinkageError::is_not_published`] tells an origin without a configuration
1102 /// apart.
1103 pub result: Result<DecodedCredential, DomainLinkageError>,
1104}
1105
1106/// Fetches DID configuration resources with an [`HttpClient`] and validates them with a
1107/// [`DomainLinkageValidator`]: for an origin, for one DID at an origin, or for every origin a
1108/// DID document lists.
1109///
1110/// Origins come from callers and from DID documents, so the client fetches whatever a DID
1111/// controller names, and validating a whole configuration resolves every DID it names:
1112/// `http`, and the `did:web` client of the resolver, must enforce the protections
1113/// `ReqwestClient` (feature `reqwest`) does (HTTPS only, no redirects, no non-public
1114/// addresses after name resolution, a body limit and a timeout), or fetching becomes a
1115/// request forgery primitive. A DID document can also list any number of origins, so
1116/// [`validate_document`](Self::validate_document) fetches at most
1117/// [`max_origins`](Self::max_origins) of them.
1118#[derive(Debug)]
1119pub struct DomainLinkageClient<C, R, V = DefaultVerifier> {
1120 http: C,
1121 validator: DomainLinkageValidator<R, V>,
1122 max_origins: usize,
1123}
1124
1125impl<C, R, V> DomainLinkageClient<C, R, V> {
1126 /// The most origins [`new`](Self::new) fetches for one DID document: 32.
1127 pub const DEFAULT_MAX_ORIGINS: usize = 32;
1128}
1129
1130impl<C: HttpClient, R: KeyResolver, V: JwsVerifier> DomainLinkageClient<C, R, V> {
1131 /// A client fetching with `http` and validating with `validator`, fetching at most
1132 /// [`DEFAULT_MAX_ORIGINS`](Self::DEFAULT_MAX_ORIGINS) origins of a DID document.
1133 pub fn new(http: C, validator: DomainLinkageValidator<R, V>) -> Self {
1134 Self {
1135 http,
1136 validator,
1137 max_origins: Self::DEFAULT_MAX_ORIGINS,
1138 }
1139 }
1140
1141 /// This client fetching at most `max_origins` origins of a DID document.
1142 #[must_use]
1143 pub fn with_max_origins(mut self, max_origins: usize) -> Self {
1144 self.max_origins = max_origins;
1145 self
1146 }
1147
1148 /// The most origins [`validate_document`](Self::validate_document) fetches for one
1149 /// document.
1150 pub fn max_origins(&self) -> usize {
1151 self.max_origins
1152 }
1153
1154 /// The HTTP client.
1155 pub fn http(&self) -> &C {
1156 &self.http
1157 }
1158
1159 /// The validator.
1160 pub fn validator(&self) -> &DomainLinkageValidator<R, V> {
1161 &self.validator
1162 }
1163
1164 /// The DID configuration resource of `origin`, unvalidated.
1165 ///
1166 /// # Errors
1167 ///
1168 /// As [`fetch_configuration`].
1169 pub async fn fetch_configuration(
1170 &self,
1171 origin: &Url,
1172 ) -> Result<DomainLinkageConfiguration, DomainLinkageError> {
1173 fetch_configuration(&self.http, origin).await
1174 }
1175
1176 /// Fetches the configuration of `origin` and validates every JWT entry, as
1177 /// [`DomainLinkageValidator::validate_configuration`].
1178 ///
1179 /// # Errors
1180 ///
1181 /// As [`fetch_configuration`] and
1182 /// [`validate_configuration`](DomainLinkageValidator::validate_configuration).
1183 pub async fn validate_origin(
1184 &self,
1185 origin: &Url,
1186 options: &CredentialValidationOptions,
1187 ) -> Result<DomainLinkageReport, DomainLinkageError> {
1188 let configuration = self.fetch_configuration(origin).await?;
1189 self.validator
1190 .validate_configuration(&configuration, origin, options)
1191 .await
1192 }
1193
1194 /// Fetches the configuration of `origin` and validates the credential linking `did`, as
1195 /// [`DomainLinkageValidator::validate_linkage`]. Only `did` is resolved.
1196 ///
1197 /// # Errors
1198 ///
1199 /// As [`fetch_configuration`] and
1200 /// [`validate_linkage`](DomainLinkageValidator::validate_linkage).
1201 pub async fn validate_linkage(
1202 &self,
1203 did: &Did,
1204 origin: &Url,
1205 options: &CredentialValidationOptions,
1206 ) -> Result<DecodedCredential, DomainLinkageError> {
1207 let configuration = self.fetch_configuration(origin).await?;
1208 self.validator
1209 .validate_linkage(&configuration, did, origin, options)
1210 .await
1211 }
1212
1213 /// Validates the linkage of the DID of `document` to every origin of its `LinkedDomains`
1214 /// services: one report per origin in document order, and one for each invalid service
1215 /// entry. Origins past the first [`max_origins`](Self::max_origins) are not fetched and
1216 /// report [`DomainLinkageError::TooManyOrigins`].
1217 pub async fn validate_document(
1218 &self,
1219 document: &DidDocument,
1220 options: &CredentialValidationOptions,
1221 ) -> Vec<LinkedDomainReport> {
1222 let mut reports = Vec::new();
1223 let mut fetched = 0;
1224 for service in document
1225 .service
1226 .iter()
1227 .filter(|service| service.has_type(SERVICE_TYPE))
1228 {
1229 let linked = match linked_domains_service(service) {
1230 Ok(linked) => linked,
1231 Err(error) => {
1232 reports.push(LinkedDomainReport {
1233 service: service.id.clone(),
1234 origin: None,
1235 result: Err(error),
1236 });
1237 continue;
1238 }
1239 };
1240 for origin in linked.origins {
1241 let result = if fetched < self.max_origins {
1242 fetched += 1;
1243 self.validate_linkage(&document.id, &origin, options).await
1244 } else {
1245 Err(DomainLinkageError::TooManyOrigins(self.max_origins))
1246 };
1247 reports.push(LinkedDomainReport {
1248 service: service.id.clone(),
1249 origin: Some(origin),
1250 result,
1251 });
1252 }
1253 }
1254 reports
1255 }
1256}
1257
1258impl<C: HttpClient, V: JwsVerifier> DomainLinkageClient<C, Resolver, V> {
1259 /// Resolves `did` with the validator's resolver, then validates its linkage to every
1260 /// origin its document lists, as [`validate_document`](Self::validate_document).
1261 ///
1262 /// # Errors
1263 ///
1264 /// [`DomainLinkageError::Resolution`] when `did` does not resolve to a document.
1265 pub async fn validate_did(
1266 &self,
1267 did: &Did,
1268 options: &CredentialValidationOptions,
1269 ) -> Result<Vec<LinkedDomainReport>, DomainLinkageError> {
1270 let document = self
1271 .validator
1272 .credential_validator()
1273 .resolver()
1274 .resolve_document(did)
1275 .await?;
1276 Ok(self.validate_document(&document, options).await)
1277 }
1278}
1279
1280/// The issuer DID of `jwt`, read without verifying; `None` when it has none or it is not a
1281/// DID.
1282fn issuer_of(jwt: &Jwt) -> Option<Did> {
1283 crate::vc::validation::extract_issuer(jwt).ok()
1284}
1285
1286/// The checks of the JSON Web Token proof format and of DID Configuration Resource
1287/// Verification that need no key.
1288fn check_format(
1289 decoded: &DecodedCredential,
1290 expected_origin: &str,
1291) -> Result<(), DomainLinkageError> {
1292 let header = &decoded.header;
1293 if let Some(typ) = &header.typ {
1294 return Err(DomainLinkageError::TypPresent(typ.clone()));
1295 }
1296 if header.kid.is_none() {
1297 return Err(ValidationError::KeyReference(KeyReferenceError::Missing).into());
1298 }
1299 for (name, present) in [
1300 ("cty", header.cty.is_some()),
1301 ("jwk", header.jwk.is_some()),
1302 ("crit", header.crit.is_some()),
1303 ] {
1304 if present {
1305 return Err(DomainLinkageError::UnexpectedHeaderParameter(
1306 name.to_owned(),
1307 ));
1308 }
1309 }
1310 if let Some(name) = header.additional.keys().next() {
1311 return Err(DomainLinkageError::UnexpectedHeaderParameter(name.clone()));
1312 }
1313
1314 let claims = &decoded.claims;
1315 for (name, present) in [
1316 ("aud", claims.aud.is_some()),
1317 ("iat", claims.iat.is_some()),
1318 ("jti", claims.jti.is_some()),
1319 ] {
1320 if present {
1321 return Err(DomainLinkageError::UnexpectedClaim(name.to_owned()));
1322 }
1323 }
1324 if let Some(name) = claims.additional.keys().find(|name| *name != "vc") {
1325 return Err(DomainLinkageError::UnexpectedClaim(name.clone()));
1326 }
1327 let iss = claims
1328 .iss
1329 .as_deref()
1330 .ok_or(DomainLinkageError::MissingClaim("iss"))?;
1331 let sub = claims
1332 .sub
1333 .as_deref()
1334 .ok_or(DomainLinkageError::MissingClaim("sub"))?;
1335 if claims.nbf.is_none() {
1336 return Err(DomainLinkageError::MissingClaim("nbf"));
1337 }
1338 if claims.exp.is_none() {
1339 return Err(DomainLinkageError::MissingClaim("exp"));
1340 }
1341
1342 let credential = &decoded.credential;
1343 if credential.id.is_some() {
1344 return Err(DomainLinkageError::IdPresent);
1345 }
1346 if !credential.context.contains(CONTEXT) {
1347 return Err(DomainLinkageError::MissingContext);
1348 }
1349 if !credential.has_type(CREDENTIAL_TYPE) {
1350 return Err(DomainLinkageError::MissingType);
1351 }
1352 if credential.expiration_date.is_none() {
1353 return Err(DomainLinkageError::MissingExpirationDate);
1354 }
1355 // A JWT with `sub` and several subjects does not decode as a credential, so one subject
1356 // is all that gets here.
1357 let subject = credential
1358 .subject()
1359 .ok_or(DomainLinkageError::SubjectNotDid(None))?;
1360 let subject_did = subject_did(subject)?;
1361 for (role, found) in [
1362 ("issuer", credential.issuer.id().as_str()),
1363 ("iss", iss),
1364 ("sub", sub),
1365 ] {
1366 if found != subject_did.as_str() {
1367 return Err(DomainLinkageError::SubjectMismatch {
1368 role,
1369 found: found.to_owned(),
1370 subject: subject_did,
1371 });
1372 }
1373 }
1374 let found = subject_origin(subject)?;
1375 if found != expected_origin {
1376 return Err(DomainLinkageError::OriginMismatch {
1377 expected: expected_origin.to_owned(),
1378 found,
1379 });
1380 }
1381 Ok(())
1382}
1383
1384/// The DID `credentialSubject.id` names.
1385fn subject_did(subject: &Subject) -> Result<Did, DomainLinkageError> {
1386 let id = subject.id.as_ref().map(Uri::as_str);
1387 id.and_then(|id| Did::parse(id).ok())
1388 .ok_or_else(|| DomainLinkageError::SubjectNotDid(id.map(str::to_owned)))
1389}
1390
1391/// `credentialSubject.origin`, normalized.
1392fn subject_origin(subject: &Subject) -> Result<String, DomainLinkageError> {
1393 let origin = subject.properties.get("origin");
1394 let Some(Value::String(origin)) = origin else {
1395 return Err(DomainLinkageError::InvalidOriginProperty(origin.cloned()));
1396 };
1397 normalize_origin(origin)
1398}
1399
1400impl fmt::Display for DomainLinkageConfiguration {
1401 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1402 let json = serde_json::to_string(self).map_err(|_| fmt::Error)?;
1403 f.write_str(&json)
1404 }
1405}