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}