identity_kit/methods/web/mod.rs
1//! `did:web`: a DID document served over HTTPS by a web origin.
2//!
3//! The [did:web method] maps a DID to the URL of its document: `did:web:example.com` is served
4//! at `https://example.com/.well-known/did.json`, and `did:web:example.com%3A8443:user:alice`
5//! at `https://example.com:8443/user/alice/did.json` (a port is percent-encoded as `%3A`, path
6//! segments are separated by `:`). [`document_url`] implements that mapping and
7//! [`did_from_url`] its inverse. The document is fetched through an [`HttpClient`] the caller
8//! injects; [`ReqwestClient`] (feature `reqwest`) is a hardened default that speaks HTTPS only,
9//! follows no redirect, refuses non-public addresses and caps the response size.
10//!
11//! The method is updated by republishing the document and deactivated by removing it; neither
12//! has an API, so [`DidWeb`] as a [`DidProducer`] builds the document to publish and nothing
13//! more.
14//!
15//! [did:web method]: https://w3c-ccg.github.io/did-method-web/
16
17use core::fmt;
18use std::error::Error;
19use std::sync::Arc;
20
21use url::{Host, Url};
22
23use crate::did::{
24 Context, Did, DidDocument, MethodType, ResolutionError, ResolutionErrorKind, ResolutionOptions,
25 ResolutionResult, VerificationMaterial, VerificationMethod, VerificationRelationship,
26};
27use crate::jose::Jwk;
28use crate::resolver::{DidProducer, DidResolver, Hosting, MethodCapabilities, ProduceError};
29
30use super::check_accept;
31
32#[cfg(feature = "reqwest")]
33mod client;
34
35#[cfg(feature = "reqwest")]
36#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
37pub use client::ReqwestClient;
38#[cfg(feature = "local-network")]
39#[cfg_attr(docsrs, doc(cfg(feature = "local-network")))]
40pub use client::{LocalNetwork, ReqwestClientBuilder};
41
42/// The method name, `web`.
43pub const METHOD: &str = "web";
44
45/// The `@context` of a document [`DidWeb`] produces.
46pub const CONTEXT: [&str; 2] = [
47 Context::DID_V1,
48 "https://w3id.org/security/suites/jws-2020/v1",
49];
50
51// ---------------------------------------------------------------------------------------------
52// HTTP
53// ---------------------------------------------------------------------------------------------
54
55/// What [`DidWeb`] needs from HTTP: a `GET` of a URL.
56///
57/// Implement it over the HTTP client of the application so `did:web` resolution shares its
58/// connection pool, proxy and TLS settings. The implementation decides the security policy:
59/// [`ReqwestClient`] is the one this crate ships.
60///
61/// Every URL a client is given comes from untrusted input (a DID, a credential's status list
62/// reference), so an implementation must refuse what [`ReqwestClient`] refuses, or weigh the
63/// consequence: plain `http`, redirects, hosts that resolve to loopback, private, link-local or
64/// other non-public addresses, unbounded bodies and unbounded waits.
65#[async_trait::async_trait]
66pub trait HttpClient: Send + Sync {
67 /// Fetches `url`, returning the status and the body of the final response.
68 ///
69 /// # Errors
70 ///
71 /// Any failure to get a response: a refused URL, a network or TLS error, a timeout, a body
72 /// over the client's limit. An HTTP error status is a response, not an error.
73 async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError>;
74}
75
76macro_rules! forward_http_client {
77 ($($t:ty),* $(,)?) => {$(
78 #[async_trait::async_trait]
79 impl<T: HttpClient + ?Sized> HttpClient for $t {
80 async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
81 (**self).get(url).await
82 }
83 }
84 )*};
85}
86
87forward_http_client!(&T, Box<T>, Arc<T>);
88
89/// An HTTP response: the status code and the body.
90#[derive(Debug, Clone, PartialEq, Eq)]
91#[non_exhaustive]
92pub struct HttpResponse {
93 /// The status code, `200` for a served document.
94 pub status: u16,
95 /// The body, which a `did:web` resolver parses as JSON.
96 pub body: Vec<u8>,
97}
98
99impl HttpResponse {
100 /// A response with `status` and `body`.
101 pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
102 Self {
103 status,
104 body: body.into(),
105 }
106 }
107}
108
109/// Why an [`HttpClient`] could not get a response.
110#[derive(Debug)]
111pub struct HttpError(Box<dyn Error + Send + Sync + 'static>);
112
113impl HttpError {
114 /// Wraps `error`.
115 pub fn new<E: Error + Send + Sync + 'static>(error: E) -> Self {
116 Self(Box::new(error))
117 }
118
119 /// An error carrying `message`.
120 pub fn msg<M: fmt::Display>(message: M) -> Self {
121 Self(message.to_string().into())
122 }
123
124 /// The wrapped error.
125 pub fn inner(&self) -> &(dyn Error + Send + Sync + 'static) {
126 self.0.as_ref()
127 }
128}
129
130impl fmt::Display for HttpError {
131 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132 fmt::Display::fmt(&self.0, f)
133 }
134}
135
136impl Error for HttpError {
137 fn source(&self) -> Option<&(dyn Error + 'static)> {
138 self.0.source()
139 }
140}
141
142impl From<Box<dyn Error + Send + Sync + 'static>> for HttpError {
143 fn from(error: Box<dyn Error + Send + Sync + 'static>) -> Self {
144 Self(error)
145 }
146}
147
148// ---------------------------------------------------------------------------------------------
149// DID <-> URL
150// ---------------------------------------------------------------------------------------------
151
152/// Why a URL has no `did:web`, or a `did:web` no document URL.
153#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
154#[non_exhaustive]
155pub enum WebError {
156 /// The URL is not an HTTPS location a `did:web` can name.
157 #[error("`{url}` cannot be a did:web: {reason}")]
158 InvalidUrl {
159 /// The URL.
160 url: Url,
161 /// What is wrong with it.
162 reason: &'static str,
163 },
164 /// The DID is not a well-formed `did:web`.
165 #[error("`{did}` is not a valid did:web: {reason}")]
166 InvalidDid {
167 /// The DID.
168 did: Did,
169 /// What is wrong with it.
170 reason: &'static str,
171 },
172}
173
174/// The directory of the document of a DID without path segments.
175const WELL_KNOWN: &str = ".well-known";
176
177/// The file name of the document.
178const DOCUMENT_FILE: &str = "did.json";
179
180/// The `did:web` whose document lives under `url`: `https://example.com` gives
181/// `did:web:example.com`, `https://example.com:8443/user/alice` gives
182/// `did:web:example.com%3A8443:user:alice`.
183///
184/// The URL must be `https`, name a domain (not an IP address) and carry no credentials, query
185/// or fragment. A trailing slash is ignored, as is the document file name that
186/// [`document_url`] appends (`/.well-known/did.json` or `/did.json`), so a document URL maps
187/// back to its DID; other path segments become `:`-separated id segments, percent-encoded where
188/// the DID syntax requires it.
189///
190/// # Errors
191///
192/// [`WebError::InvalidUrl`] when the URL breaks one of those rules.
193///
194/// # Examples
195///
196/// ```
197/// use identity_kit::methods::web::did_from_url;
198/// use url::Url;
199///
200/// let did = did_from_url(&Url::parse("https://example.com:3000/user/alice/")?)?;
201/// assert_eq!(did.as_str(), "did:web:example.com%3A3000:user:alice");
202/// let did = did_from_url(&Url::parse("https://example.com/.well-known/did.json")?)?;
203/// assert_eq!(did.as_str(), "did:web:example.com");
204/// assert!(did_from_url(&Url::parse("https://192.0.2.1/")?).is_err());
205/// # Ok::<(), Box<dyn std::error::Error>>(())
206/// ```
207pub fn did_from_url(url: &Url) -> Result<Did, WebError> {
208 let invalid = |reason| WebError::InvalidUrl {
209 url: url.clone(),
210 reason,
211 };
212 if url.scheme() != "https" {
213 return Err(invalid("the scheme must be `https`"));
214 }
215 let domain = match url.host() {
216 Some(Host::Domain(domain)) => domain,
217 Some(_) => return Err(invalid("the host must be a domain name, not an IP address")),
218 None => return Err(invalid("the URL has no host")),
219 };
220 if !domain
221 .bytes()
222 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
223 {
224 return Err(invalid("the host has characters a DID cannot carry"));
225 }
226 if !url.username().is_empty() || url.password().is_some() {
227 return Err(invalid("the URL must not carry credentials"));
228 }
229 if url.query().is_some() || url.fragment().is_some() {
230 return Err(invalid("the URL must not have a query or a fragment"));
231 }
232 let mut id = domain.to_owned();
233 if let Some(port) = url.port() {
234 id.push_str(&format!("%3A{port}"));
235 }
236 let mut segments: Vec<&str> = url
237 .path_segments()
238 .map(Iterator::collect)
239 .unwrap_or_default();
240 if segments.last() == Some(&"") {
241 segments.pop();
242 }
243 if segments.last() == Some(&DOCUMENT_FILE) {
244 segments.pop();
245 if segments.as_slice() == [WELL_KNOWN] {
246 segments.clear();
247 }
248 }
249 for segment in segments {
250 if segment.is_empty() {
251 return Err(invalid("the path has an empty segment"));
252 }
253 id.push(':');
254 id.push_str(&encode_segment(segment));
255 }
256 Did::parse(&format!("did:web:{id}")).map_err(|_| invalid("the URL does not map to a DID"))
257}
258
259/// The `did:web` of the origin of `url`: as [`did_from_url`], but the URL must have no path.
260///
261/// # Errors
262///
263/// [`WebError::InvalidUrl`] when the URL has a path, or breaks a rule of [`did_from_url`].
264pub fn did_from_origin(url: &Url) -> Result<Did, WebError> {
265 if !matches!(url.path(), "" | "/") {
266 return Err(WebError::InvalidUrl {
267 url: url.clone(),
268 reason: "an origin has no path",
269 });
270 }
271 did_from_url(url)
272}
273
274/// `segment` with every byte outside the DID `idchar` set percent-encoded.
275fn encode_segment(segment: &str) -> String {
276 let bytes = segment.as_bytes();
277 let mut out = String::with_capacity(segment.len());
278 let mut i = 0;
279 while i < bytes.len() {
280 let b = bytes[i];
281 if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
282 out.push(char::from(b));
283 } else if b == b'%'
284 && bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit)
285 && bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)
286 {
287 out.push_str(&segment[i..i + 3]);
288 i += 3;
289 continue;
290 } else {
291 out.push_str(&format!("%{b:02X}"));
292 }
293 i += 1;
294 }
295 out
296}
297
298/// The URL of the document of `did` (the Read operation's URL construction):
299/// `https://host[:port]/.well-known/did.json` for a bare domain, else
300/// `https://host[:port]/<segments>/did.json`.
301///
302/// # Errors
303///
304/// [`WebError::InvalidDid`] when `did` is not a `did:web`, names an IP address, has a
305/// malformed port, or has an empty, `.` or `..` path segment, percent-encoded or not.
306///
307/// # Examples
308///
309/// ```
310/// use identity_kit::did::Did;
311/// use identity_kit::methods::web::document_url;
312///
313/// let url = document_url(&Did::parse("did:web:example.com")?)?;
314/// assert_eq!(url.as_str(), "https://example.com/.well-known/did.json");
315///
316/// let url = document_url(&Did::parse("did:web:example.com%3A3000:user:alice")?)?;
317/// assert_eq!(url.as_str(), "https://example.com:3000/user/alice/did.json");
318/// # Ok::<(), Box<dyn std::error::Error>>(())
319/// ```
320pub fn document_url(did: &Did) -> Result<Url, WebError> {
321 let invalid = |reason| WebError::InvalidDid {
322 did: did.clone(),
323 reason,
324 };
325 if did.method() != METHOD {
326 return Err(invalid("the method is not `web`"));
327 }
328 let mut segments = did.method_specific_id().split(':');
329 let authority = percent_decode(segments.next().unwrap_or_default());
330 let (host, port) = match authority.rsplit_once(':') {
331 Some((host, port)) => (host, Some(port)),
332 None => (authority.as_str(), None),
333 };
334 if host.is_empty()
335 || !host
336 .bytes()
337 .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
338 {
339 return Err(invalid("the host is not a domain name"));
340 }
341 if let Some(port) = port
342 && (port.is_empty() || port.len() > 5 || !port.bytes().all(|b| b.is_ascii_digit()))
343 {
344 return Err(invalid("the port is not a number"));
345 }
346 let mut path = String::new();
347 let mut has_segments = false;
348 for segment in segments {
349 if is_dot_segment(segment) {
350 return Err(invalid("a path segment is empty, `.` or `..`"));
351 }
352 has_segments = true;
353 path.push('/');
354 path.push_str(segment);
355 }
356 if !has_segments {
357 path.push('/');
358 path.push_str(WELL_KNOWN);
359 }
360 let port = port.map(|port| format!(":{port}")).unwrap_or_default();
361 let url = Url::parse(&format!("https://{host}{port}{path}/{DOCUMENT_FILE}"))
362 .map_err(|_| invalid("the DID does not map to a URL"))?;
363 match url.host() {
364 Some(Host::Domain(_)) => Ok(url),
365 _ => Err(invalid("the host is an IP address")),
366 }
367}
368
369/// Whether `segment` is empty, or is `.` or `..` as the URL parser reads it: a segment of `.` and
370/// `%2E` in any combination and case is a dot segment, which the parser collapses into the path
371/// before it, so `did:web:example.com:a:%2E%2E:b` would name `https://example.com/b/did.json`.
372fn is_dot_segment(segment: &str) -> bool {
373 let mut dots = 0;
374 let mut i = 0;
375 let bytes = segment.as_bytes();
376 while i < bytes.len() {
377 if bytes[i] == b'.' {
378 i += 1;
379 } else if bytes[i..]
380 .get(..3)
381 .is_some_and(|b| b.eq_ignore_ascii_case(b"%2e"))
382 {
383 i += 3;
384 } else {
385 return false;
386 }
387 dots += 1;
388 }
389 dots <= 2
390}
391
392/// Decodes the percent-encoded octets of a DID segment, which the DID syntax has checked to be
393/// complete; non-UTF-8 octets become replacement characters and fail the host check.
394fn percent_decode(s: &str) -> String {
395 let bytes = s.as_bytes();
396 let mut out = Vec::with_capacity(bytes.len());
397 let mut i = 0;
398 while i < bytes.len() {
399 match (bytes[i], bytes.get(i + 1), bytes.get(i + 2)) {
400 (b'%', Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
401 let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8;
402 out.push(hex(*hi) << 4 | hex(*lo));
403 i += 3;
404 }
405 (b, _, _) => {
406 out.push(b);
407 i += 1;
408 }
409 }
410 }
411 String::from_utf8_lossy(&out).into_owned()
412}
413
414// ---------------------------------------------------------------------------------------------
415// The method
416// ---------------------------------------------------------------------------------------------
417
418/// The `did:web` method: a [`DidResolver`] fetching documents through an [`HttpClient`], and a
419/// [`DidProducer`] building the document to publish.
420///
421/// # Examples
422///
423/// ```
424/// use identity_kit::did::Did;
425/// use identity_kit::methods::web::{DidWeb, HttpClient, HttpError, HttpResponse};
426/// use identity_kit::resolver::Resolver;
427/// use url::Url;
428///
429/// /// Serves one document from memory.
430/// struct Static(String);
431///
432/// #[async_trait::async_trait]
433/// impl HttpClient for Static {
434/// async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
435/// Ok(if url.as_str() == "https://example.com/.well-known/did.json" {
436/// HttpResponse::new(200, self.0.as_bytes())
437/// } else {
438/// HttpResponse::new(404, Vec::new())
439/// })
440/// }
441/// }
442///
443/// # tokio::runtime::Builder::new_current_thread().build()?.block_on(async {
444/// let document = r#"{"id": "did:web:example.com"}"#.to_owned();
445/// let resolver = Resolver::builder().with(DidWeb::new(Static(document))).build();
446/// let did = Did::parse("did:web:example.com")?;
447/// assert_eq!(resolver.resolve_document(&did).await?.id, did);
448/// # Ok::<(), Box<dyn std::error::Error>>(())
449/// # })?;
450/// # Ok::<(), Box<dyn std::error::Error>>(())
451/// ```
452#[derive(Debug, Clone)]
453pub struct DidWeb<C> {
454 client: C,
455}
456
457impl<C: HttpClient> DidWeb<C> {
458 /// The method fetching documents with `client`.
459 pub const fn new(client: C) -> Self {
460 Self { client }
461 }
462
463 /// The HTTP client.
464 pub const fn client(&self) -> &C {
465 &self.client
466 }
467}
468
469#[async_trait::async_trait]
470impl<C: HttpClient> DidResolver for DidWeb<C> {
471 fn method(&self) -> &'static str {
472 METHOD
473 }
474
475 /// Fetches the document of `did` from [`document_url`] and checks that its `id` is `did`.
476 ///
477 /// # Errors
478 ///
479 /// A [`ResolutionError`] whose kind is:
480 ///
481 /// - [`InvalidDid`](ResolutionErrorKind::InvalidDid) when `did` has no document URL, caused
482 /// by the [`WebError`].
483 /// - [`NotFound`](ResolutionErrorKind::NotFound) on a `404` or `410` response.
484 /// - [`InternalError`](ResolutionErrorKind::InternalError) when the client fails, caused by
485 /// its [`HttpError`], or when it returns any other non-success status, which the detail
486 /// names.
487 /// - [`InvalidDidDocument`](ResolutionErrorKind::InvalidDidDocument) when the body is not a
488 /// DID document, caused by the [`serde_json::Error`], or is the document of another DID,
489 /// which the detail names.
490 /// - [`RepresentationNotSupported`](ResolutionErrorKind::RepresentationNotSupported) for an
491 /// `accept` that is not JSON.
492 async fn resolve(
493 &self,
494 did: &Did,
495 options: &ResolutionOptions,
496 ) -> Result<ResolutionResult, ResolutionError> {
497 check_accept(options)?;
498 let url = document_url(did).map_err(|error| {
499 ResolutionError::new(ResolutionErrorKind::InvalidDid).with_source(error)
500 })?;
501 let response = self.client.get(&url).await.map_err(|error| {
502 ResolutionError::new(ResolutionErrorKind::InternalError)
503 .with_detail(format!("fetching `{url}` failed"))
504 .with_source(error)
505 })?;
506 let kind = match response.status {
507 200..=299 => None,
508 404 | 410 => Some(ResolutionErrorKind::NotFound),
509 _ => Some(ResolutionErrorKind::InternalError),
510 };
511 if let Some(kind) = kind {
512 return Err(ResolutionError::new(kind).with_detail(format!(
513 "`{url}` responded with HTTP status {}",
514 response.status
515 )));
516 }
517 let document: DidDocument = serde_json::from_slice(&response.body).map_err(|error| {
518 ResolutionError::new(ResolutionErrorKind::InvalidDidDocument)
519 .with_detail(format!("the body of `{url}` is not a DID document"))
520 .with_source(error)
521 })?;
522 if document.id != *did {
523 return Err(
524 ResolutionError::new(ResolutionErrorKind::InvalidDidDocument).with_detail(format!(
525 "`{url}` holds the document of `{}`, not `{did}`",
526 document.id
527 )),
528 );
529 }
530 Ok(ResolutionResult::new(document))
531 }
532}
533
534#[async_trait::async_trait]
535impl<C: HttpClient> DidProducer for DidWeb<C> {
536 /// The HTTPS URL the document will be served under, as [`did_from_url`] reads it.
537 type Params = Url;
538
539 fn method(&self) -> &'static str {
540 METHOD
541 }
542
543 /// Hosted on the web, any number of keys, updated by republishing; fragments come from the
544 /// keys.
545 fn capabilities(&self) -> MethodCapabilities {
546 MethodCapabilities::new(Hosting::Web).with_updatable(true)
547 }
548
549 /// The document to publish at `url` for `keys`.
550 ///
551 /// Each key becomes a `JsonWebKey2020` verification method whose fragment is the key's
552 /// `kid`, or `key-<index>` when it has none. A key with `use` `enc`, or an X25519 key, is
553 /// referenced from `keyAgreement`; every other key from `authentication`,
554 /// `assertionMethod`, `capabilityInvocation` and `capabilityDelegation`. The document's
555 /// `@context` is [`CONTEXT`].
556 ///
557 /// # Errors
558 ///
559 /// - [`ProduceError::NoKeys`] without a key.
560 /// - [`ProduceError::InvalidParams`] when `url` has no `did:web` (see [`did_from_url`]).
561 /// - [`ProduceError::UnsupportedKey`] for a `kid` that is not a valid fragment.
562 /// - [`ProduceError::Document`] for two keys with the same `kid`.
563 async fn produce(&self, keys: &[Jwk], url: Url) -> Result<DidDocument, ProduceError> {
564 if keys.is_empty() {
565 return Err(ProduceError::NoKeys);
566 }
567 let did =
568 did_from_url(&url).map_err(|error| ProduceError::InvalidParams(error.to_string()))?;
569 let mut builder = DidDocument::builder(did.clone()).context(Context::uris(CONTEXT));
570 for (index, key) in keys.iter().enumerate() {
571 let id = match key.kid() {
572 Some(kid) => {
573 did.join(&format!("#{kid}"))
574 .map_err(|_| ProduceError::UnsupportedKey {
575 index,
576 reason: format!("`kid` {kid:?} is not a valid DID URL fragment"),
577 })?
578 }
579 None => did.to_url().with_fragment(&format!("key-{index}")),
580 };
581 let relationships: &[VerificationRelationship] =
582 if key.key_use() == Some("enc") || key.crv() == Some("X25519") {
583 &[VerificationRelationship::KeyAgreement]
584 } else {
585 &[
586 VerificationRelationship::Authentication,
587 VerificationRelationship::AssertionMethod,
588 VerificationRelationship::CapabilityInvocation,
589 VerificationRelationship::CapabilityDelegation,
590 ]
591 };
592 let method = VerificationMethod::new(
593 id,
594 MethodType::JSON_WEB_KEY_2020,
595 did.clone(),
596 VerificationMaterial::jwk(key),
597 );
598 builder = builder.verification_method(method, relationships.iter().copied());
599 }
600 Ok(builder.build()?)
601 }
602}