use core::fmt;
use std::error::Error;
use std::sync::Arc;
use url::{Host, Url};
use crate::did::{
Context, Did, DidDocument, MethodType, ResolutionError, ResolutionOptions, ResolutionResult,
VerificationMaterial, VerificationMethod, VerificationRelationship,
};
use crate::jose::Jwk;
use crate::resolver::{DidProducer, DidResolver, Hosting, MethodCapabilities, ProduceError};
use super::check_accept;
#[cfg(feature = "reqwest")]
mod client;
#[cfg(feature = "reqwest")]
#[cfg_attr(docsrs, doc(cfg(feature = "reqwest")))]
pub use client::ReqwestClient;
pub const METHOD: &str = "web";
pub const CONTEXT: [&str; 2] = [
Context::DID_V1,
"https://w3id.org/security/suites/jws-2020/v1",
];
#[async_trait::async_trait]
pub trait HttpClient: Send + Sync {
async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError>;
}
macro_rules! forward_http_client {
($($t:ty),* $(,)?) => {$(
#[async_trait::async_trait]
impl<T: HttpClient + ?Sized> HttpClient for $t {
async fn get(&self, url: &Url) -> Result<HttpResponse, HttpError> {
(**self).get(url).await
}
}
)*};
}
forward_http_client!(&T, Box<T>, Arc<T>);
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub struct HttpResponse {
pub status: u16,
pub body: Vec<u8>,
}
impl HttpResponse {
pub fn new(status: u16, body: impl Into<Vec<u8>>) -> Self {
Self {
status,
body: body.into(),
}
}
}
#[derive(Debug)]
pub struct HttpError(Box<dyn Error + Send + Sync + 'static>);
impl HttpError {
pub fn new<E: Error + Send + Sync + 'static>(error: E) -> Self {
Self(Box::new(error))
}
pub fn msg<M: fmt::Display>(message: M) -> Self {
Self(message.to_string().into())
}
pub fn inner(&self) -> &(dyn Error + Send + Sync + 'static) {
self.0.as_ref()
}
}
impl fmt::Display for HttpError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
fmt::Display::fmt(&self.0, f)
}
}
impl Error for HttpError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
self.0.source()
}
}
impl From<Box<dyn Error + Send + Sync + 'static>> for HttpError {
fn from(error: Box<dyn Error + Send + Sync + 'static>) -> Self {
Self(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum WebError {
#[error("`{url}` cannot be a did:web: {reason}")]
InvalidUrl {
url: Url,
reason: &'static str,
},
#[error("`{did}` is not a valid did:web: {reason}")]
InvalidDid {
did: Did,
reason: &'static str,
},
}
const WELL_KNOWN: &str = ".well-known";
const DOCUMENT_FILE: &str = "did.json";
pub fn did_from_url(url: &Url) -> Result<Did, WebError> {
let invalid = |reason| WebError::InvalidUrl {
url: url.clone(),
reason,
};
if url.scheme() != "https" {
return Err(invalid("the scheme must be `https`"));
}
let domain = match url.host() {
Some(Host::Domain(domain)) => domain,
Some(_) => return Err(invalid("the host must be a domain name, not an IP address")),
None => return Err(invalid("the URL has no host")),
};
if !domain
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
{
return Err(invalid("the host has characters a DID cannot carry"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid("the URL must not carry credentials"));
}
if url.query().is_some() || url.fragment().is_some() {
return Err(invalid("the URL must not have a query or a fragment"));
}
let mut id = domain.to_owned();
if let Some(port) = url.port() {
id.push_str(&format!("%3A{port}"));
}
let mut segments: Vec<&str> = url
.path_segments()
.map(Iterator::collect)
.unwrap_or_default();
if segments.last() == Some(&"") {
segments.pop();
}
if segments.last() == Some(&DOCUMENT_FILE) {
segments.pop();
if segments.as_slice() == [WELL_KNOWN] {
segments.clear();
}
}
for segment in segments {
if segment.is_empty() {
return Err(invalid("the path has an empty segment"));
}
id.push(':');
id.push_str(&encode_segment(segment));
}
Did::parse(&format!("did:web:{id}")).map_err(|_| invalid("the URL does not map to a DID"))
}
pub fn did_from_origin(url: &Url) -> Result<Did, WebError> {
if !matches!(url.path(), "" | "/") {
return Err(WebError::InvalidUrl {
url: url.clone(),
reason: "an origin has no path",
});
}
did_from_url(url)
}
fn encode_segment(segment: &str) -> String {
let bytes = segment.as_bytes();
let mut out = String::with_capacity(segment.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_') {
out.push(char::from(b));
} else if b == b'%'
&& bytes.get(i + 1).is_some_and(u8::is_ascii_hexdigit)
&& bytes.get(i + 2).is_some_and(u8::is_ascii_hexdigit)
{
out.push_str(&segment[i..i + 3]);
i += 3;
continue;
} else {
out.push_str(&format!("%{b:02X}"));
}
i += 1;
}
out
}
pub fn document_url(did: &Did) -> Result<Url, WebError> {
let invalid = |reason| WebError::InvalidDid {
did: did.clone(),
reason,
};
if did.method() != METHOD {
return Err(invalid("the method is not `web`"));
}
let mut segments = did.method_specific_id().split(':');
let authority = percent_decode(segments.next().unwrap_or_default());
let (host, port) = match authority.rsplit_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority.as_str(), None),
};
if host.is_empty()
|| !host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'.')
{
return Err(invalid("the host is not a domain name"));
}
if let Some(port) = port
&& (port.is_empty() || port.len() > 5 || !port.bytes().all(|b| b.is_ascii_digit()))
{
return Err(invalid("the port is not a number"));
}
let mut path = String::new();
let mut has_segments = false;
for segment in segments {
if is_dot_segment(segment) {
return Err(invalid("a path segment is empty, `.` or `..`"));
}
has_segments = true;
path.push('/');
path.push_str(segment);
}
if !has_segments {
path.push('/');
path.push_str(WELL_KNOWN);
}
let port = port.map(|port| format!(":{port}")).unwrap_or_default();
let url = Url::parse(&format!("https://{host}{port}{path}/{DOCUMENT_FILE}"))
.map_err(|_| invalid("the DID does not map to a URL"))?;
match url.host() {
Some(Host::Domain(_)) => Ok(url),
_ => Err(invalid("the host is an IP address")),
}
}
fn is_dot_segment(segment: &str) -> bool {
let mut dots = 0;
let mut i = 0;
let bytes = segment.as_bytes();
while i < bytes.len() {
if bytes[i] == b'.' {
i += 1;
} else if bytes[i..]
.get(..3)
.is_some_and(|b| b.eq_ignore_ascii_case(b"%2e"))
{
i += 3;
} else {
return false;
}
dots += 1;
}
dots <= 2
}
fn percent_decode(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
match (bytes[i], bytes.get(i + 1), bytes.get(i + 2)) {
(b'%', Some(hi), Some(lo)) if hi.is_ascii_hexdigit() && lo.is_ascii_hexdigit() => {
let hex = |b: u8| (b as char).to_digit(16).unwrap_or(0) as u8;
out.push(hex(*hi) << 4 | hex(*lo));
i += 3;
}
(b, _, _) => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Debug, Clone)]
pub struct DidWeb<C> {
client: C,
}
impl<C: HttpClient> DidWeb<C> {
pub const fn new(client: C) -> Self {
Self { client }
}
pub const fn client(&self) -> &C {
&self.client
}
}
#[async_trait::async_trait]
impl<C: HttpClient> DidResolver for DidWeb<C> {
fn method(&self) -> &'static str {
METHOD
}
async fn resolve(
&self,
did: &Did,
options: &ResolutionOptions,
) -> Result<ResolutionResult, ResolutionError> {
check_accept(options)?;
let url = document_url(did).map_err(|_| ResolutionError::InvalidDid)?;
let response = self
.client
.get(&url)
.await
.map_err(|_| ResolutionError::InternalError)?;
match response.status {
200..=299 => {}
404 | 410 => return Err(ResolutionError::NotFound),
_ => return Err(ResolutionError::InternalError),
}
let document: DidDocument = serde_json::from_slice(&response.body)
.map_err(|_| ResolutionError::InvalidDidDocument)?;
if document.id != *did {
return Err(ResolutionError::InvalidDidDocument);
}
Ok(ResolutionResult::new(document))
}
}
#[async_trait::async_trait]
impl<C: HttpClient> DidProducer for DidWeb<C> {
type Params = Url;
fn method(&self) -> &'static str {
METHOD
}
fn capabilities(&self) -> MethodCapabilities {
MethodCapabilities::new(Hosting::Web).with_updatable(true)
}
async fn produce(&self, keys: &[Jwk], url: Url) -> Result<DidDocument, ProduceError> {
if keys.is_empty() {
return Err(ProduceError::NoKeys);
}
let did =
did_from_url(&url).map_err(|error| ProduceError::InvalidParams(error.to_string()))?;
let mut builder = DidDocument::builder(did.clone()).context(Context::uris(CONTEXT));
for (index, key) in keys.iter().enumerate() {
let id = match key.kid() {
Some(kid) => {
did.join(&format!("#{kid}"))
.map_err(|_| ProduceError::UnsupportedKey {
index,
reason: format!("`kid` {kid:?} is not a valid DID URL fragment"),
})?
}
None => did.to_url().with_fragment(&format!("key-{index}")),
};
let relationships: &[VerificationRelationship] =
if key.key_use() == Some("enc") || key.crv() == Some("X25519") {
&[VerificationRelationship::KeyAgreement]
} else {
&[
VerificationRelationship::Authentication,
VerificationRelationship::AssertionMethod,
VerificationRelationship::CapabilityInvocation,
VerificationRelationship::CapabilityDelegation,
]
};
let method = VerificationMethod::new(
id,
MethodType::JSON_WEB_KEY_2020,
did.clone(),
VerificationMaterial::jwk(key),
);
builder = builder.verification_method(method, relationships.iter().copied());
}
Ok(builder.build()?)
}
}