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