Skip to main content

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}