From 244b8b3a5e69258f4609c0bae7f90599c7a5ce3d Mon Sep 17 00:00:00 2001 From: Miroslav Kovar Date: Wed, 10 May 2023 17:45:01 +0200 Subject: [PATCH] Basic implementation of did:web resolver (#828) Signed-off-by: Miroslav Kovar --- .github/workflows/main.yml | 2 +- Cargo.lock | 49 ++++++++ Cargo.toml | 3 +- did_resolver_web/Cargo.toml | 19 ++++ did_resolver_web/src/error/mod.rs | 25 ++++ did_resolver_web/src/error/parsing.rs | 23 ++++ did_resolver_web/src/lib.rs | 2 + did_resolver_web/src/resolution/mod.rs | 1 + did_resolver_web/src/resolution/resolver.rs | 109 ++++++++++++++++++ did_resolver_web/tests/resolution.rs | 120 ++++++++++++++++++++ 10 files changed, 351 insertions(+), 2 deletions(-) create mode 100644 did_resolver_web/Cargo.toml create mode 100644 did_resolver_web/src/error/mod.rs create mode 100644 did_resolver_web/src/error/parsing.rs create mode 100644 did_resolver_web/src/lib.rs create mode 100644 did_resolver_web/src/resolution/mod.rs create mode 100644 did_resolver_web/src/resolution/resolver.rs create mode 100644 did_resolver_web/tests/resolution.rs diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 1872efdb31..7942cb88b6 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -444,7 +444,7 @@ jobs: uses: ./.github/actions/setup-testing-rust - name: "Run resolver tests" run: | - RUST_TEST_THREADS=1 cargo test -p did_doc_builder -p did_parser -p did_resolver -p did_resolver_registry -p did_resolver_sov --test "*" + RUST_TEST_THREADS=1 cargo test -p did_doc_builder -p did_parser -p did_resolver -p did_resolver_registry -p did_resolver_sov -p did_resolver_web --test "*" test-node-wrapper: needs: workflow-setup diff --git a/Cargo.lock b/Cargo.lock index d012221908..73b6837af0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -411,6 +411,28 @@ dependencies = [ "wasm-bindgen-futures", ] +[[package]] +name = "async-stream" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd56dd203fef61ac097dd65721a419ddccb106b2d2b70ba60a6b529f03961a51" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16e62a023e7c117e27523144c5d2459f4397fcc3cab0085af8e2224f643a0193" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "async-task" version = "4.4.0" @@ -1398,6 +1420,20 @@ dependencies = [ "uuid 1.3.1", ] +[[package]] +name = "did_resolver_web" +version = "0.1.0" +dependencies = [ + "async-trait", + "did_resolver", + "hyper", + "hyper-tls", + "serde_json", + "thiserror", + "tokio", + "tokio-test", +] + [[package]] name = "diddoc" version = "0.55.0" @@ -4562,6 +4598,19 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-test" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53474327ae5e166530d17f2d956afcb4f8a004de581b3cae10f12006bc8163e3" +dependencies = [ + "async-stream", + "bytes", + "futures-core", + "tokio", + "tokio-stream", +] + [[package]] name = "tokio-util" version = "0.7.8" diff --git a/Cargo.toml b/Cargo.toml index 884d1a1bd6..f58606a560 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,7 +17,8 @@ members = [ "did_parser", "did_resolver", "did_resolver_registry", - "did_resolver_sov" + "did_resolver_sov", + "did_resolver_web" ] [workspace.package] diff --git a/did_resolver_web/Cargo.toml b/did_resolver_web/Cargo.toml new file mode 100644 index 0000000000..1941632479 --- /dev/null +++ b/did_resolver_web/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "did_resolver_web" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +did_resolver = { path = "../did_resolver" } +async-trait = "0.1.68" +serde_json = "1.0.96" +thiserror = "1.0.40" +hyper = { version = "0.14.26", features = ["client", "http2"] } +hyper-tls = "0.5.0" + +[dev-dependencies] +hyper = { version = "0.14.26", features = ["server"] } +tokio = { version = "1.27.0", default-features = false, features = ["macros", "rt"] } +tokio-test = "0.4.2" diff --git a/did_resolver_web/src/error/mod.rs b/did_resolver_web/src/error/mod.rs new file mode 100644 index 0000000000..be189d6ed3 --- /dev/null +++ b/did_resolver_web/src/error/mod.rs @@ -0,0 +1,25 @@ +pub mod parsing; + +use hyper::StatusCode; +use thiserror::Error; + +use self::parsing::ParsingErrorSource; + +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum DidWebError { + #[error("DID method not supported: {0}")] + MethodNotSupported(String), + #[error("Representation not supported: {0}")] + RepresentationNotSupported(String), + #[error("Invalid DID: {0}")] + InvalidDid(String), + #[error("Parsing error: {0}")] + ParsingError(#[from] ParsingErrorSource), + #[error("URL parsing error: {0}")] + HttpError(#[from] hyper::Error), + #[error("Non-success server response: {0}")] + NonSuccessResponse(StatusCode), + #[error(transparent)] + Other(#[from] Box), +} diff --git a/did_resolver_web/src/error/parsing.rs b/did_resolver_web/src/error/parsing.rs new file mode 100644 index 0000000000..e6ebbe716e --- /dev/null +++ b/did_resolver_web/src/error/parsing.rs @@ -0,0 +1,23 @@ +use thiserror::Error; + +use super::DidWebError; + +#[derive(Error, Debug)] +pub enum ParsingErrorSource { + #[error("JSON parsing error: {0}")] + JsonError(#[from] serde_json::Error), + #[error("Invalid encoding: {0}")] + Utf8Error(#[from] std::string::FromUtf8Error), +} + +impl From for DidWebError { + fn from(error: serde_json::Error) -> Self { + DidWebError::ParsingError(ParsingErrorSource::JsonError(error)) + } +} + +impl From for DidWebError { + fn from(error: std::string::FromUtf8Error) -> Self { + DidWebError::ParsingError(ParsingErrorSource::Utf8Error(error)) + } +} diff --git a/did_resolver_web/src/lib.rs b/did_resolver_web/src/lib.rs new file mode 100644 index 0000000000..bd1d464699 --- /dev/null +++ b/did_resolver_web/src/lib.rs @@ -0,0 +1,2 @@ +pub mod error; +pub mod resolution; diff --git a/did_resolver_web/src/resolution/mod.rs b/did_resolver_web/src/resolution/mod.rs new file mode 100644 index 0000000000..e755804162 --- /dev/null +++ b/did_resolver_web/src/resolution/mod.rs @@ -0,0 +1 @@ +pub mod resolver; diff --git a/did_resolver_web/src/resolution/resolver.rs b/did_resolver_web/src/resolution/resolver.rs new file mode 100644 index 0000000000..6e5b4e75bf --- /dev/null +++ b/did_resolver_web/src/resolution/resolver.rs @@ -0,0 +1,109 @@ +use async_trait::async_trait; +use did_resolver::{ + did_parser::Did, + error::GenericError, + shared_types::{did_document_metadata::DidDocumentMetadata, media_type::MediaType}, + traits::resolvable::{ + resolution_metadata::DidResolutionMetadata, resolution_options::DidResolutionOptions, + resolution_output::DidResolutionOutput, DidResolvable, + }, +}; +use hyper::{ + client::{connect::Connect, HttpConnector}, + http::uri::{self, Scheme}, + Body, Client, Uri, +}; +use hyper_tls::HttpsConnector; + +use crate::error::DidWebError; + +pub struct DidWebResolver +where + C: Connect + Send + Sync + Clone + 'static, +{ + client: Client, + scheme: Scheme, +} + +impl DidWebResolver { + pub fn http() -> DidWebResolver { + DidWebResolver { + client: Client::builder().build::<_, Body>(HttpConnector::new()), + scheme: Scheme::HTTP, + } + } +} + +impl DidWebResolver> { + pub fn https() -> DidWebResolver> { + DidWebResolver { + client: Client::builder().build::<_, Body>(HttpsConnector::new()), + scheme: Scheme::HTTPS, + } + } +} + +impl DidWebResolver +where + C: Connect + Send + Sync + Clone + 'static, +{ + async fn fetch_did_document(&self, url: Uri) -> Result { + let res = self.client.get(url).await?; + + if !res.status().is_success() { + return Err(DidWebError::NonSuccessResponse(res.status())); + } + + let body = hyper::body::to_bytes(res.into_body()).await?; + + String::from_utf8(body.to_vec()).map_err(|err| err.into()) + } +} + +#[async_trait] +impl DidResolvable for DidWebResolver +where + C: Connect + Send + Sync + Clone + 'static, +{ + async fn resolve(&self, did: &Did, options: &DidResolutionOptions) -> Result { + if did.method() != "web" { + return Err(Box::new(DidWebError::MethodNotSupported(did.method().to_string()))); + } + + if let Some(accept) = options.accept() { + if accept != &MediaType::DidJson { + return Err(Box::new(DidWebError::RepresentationNotSupported(accept.to_string()))); + } + } + + let did_parts: Vec<&str> = did.id().split(':').collect(); + + if did_parts.is_empty() { + return Err(Box::new(DidWebError::InvalidDid(did.id().to_string()))); + } + + let domain = did_parts[0].replace("%3A", ":"); + + let path_parts = &did_parts[1..]; + let path_and_query = if path_parts.is_empty() { + "/.well-known/did.json".to_string() + } else { + let path = path_parts.join("/"); + format!("/{}/did.json", path) + }; + let url = uri::Builder::new() + .scheme(self.scheme.clone()) + .authority(domain.as_str()) + .path_and_query(path_and_query.as_str()) + .build()?; + + let did_document = serde_json::from_str(&self.fetch_did_document(url).await?)?; + + let did_resolution_output = DidResolutionOutput::builder(did_document) + .did_resolution_metadata(DidResolutionMetadata::default()) + .did_document_metadata(DidDocumentMetadata::default()) + .build(); + + Ok(did_resolution_output) + } +} diff --git a/did_resolver_web/tests/resolution.rs b/did_resolver_web/tests/resolution.rs new file mode 100644 index 0000000000..4a0eb9083c --- /dev/null +++ b/did_resolver_web/tests/resolution.rs @@ -0,0 +1,120 @@ +use did_resolver::did_doc_builder::schema::did_doc::DidDocument; +use did_resolver::did_parser::Did; +use did_resolver::traits::resolvable::{resolution_options::DidResolutionOptions, DidResolvable}; +use did_resolver_web::resolution::resolver::DidWebResolver; +use hyper::{ + service::{make_service_fn, service_fn}, + Body, Request, Response, Server, +}; +use std::convert::Infallible; +use std::net::SocketAddr; +use tokio_test::assert_ok; + +const DID_DOCUMENT: &'static str = r##" +{ + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://w3id.org/security/suites/jws-2020/v1" + ], + "id": "did:web:example.com", + "verificationMethod": [ + { + "id": "did:web:example.com#key-0", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "OKP", + "crv": "Ed25519", + "x": "0-e2i2_Ua1S5HbTYnVB0lj2Z2ytXu2-tYmDFf8f5NjU" + } + }, + { + "id": "did:web:example.com#key-1", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "OKP", + "crv": "X25519", + "x": "9GXjPGGvmRq9F6Ng5dQQ_s31mfhxrcNZxRGONrmH30k" + } + }, + { + "id": "did:web:example.com#key-2", + "type": "JsonWebKey2020", + "controller": "did:web:example.com", + "publicKeyJwk": { + "kty": "EC", + "crv": "P-256", + "x": "38M1FDts7Oea7urmseiugGW7tWc3mLpJh6rKe7xINZ8", + "y": "nDQW6XZ7b_u2Sy9slofYLlG03sOEoug3I0aAPQ0exs4" + } + } + ], + "authentication": [ + "did:web:example.com#key-0", + "did:web:example.com#key-2" + ], + "assertionMethod": [ + "did:web:example.com#key-0", + "did:web:example.com#key-2" + ], + "keyAgreement": [ + "did:web:example.com#key-1", + "did:web:example.com#key-2" + ] +}"##; + +async fn mock_server_handler(req: Request) -> Result, Infallible> { + let response = match req.uri().path() { + "/.well-known/did.json" | "/user/alice/did.json" => Response::new(Body::from(DID_DOCUMENT)), + _ => Response::builder().status(404).body(Body::from("Not Found")).unwrap(), + }; + + Ok(response) +} + +async fn create_mock_server(port: u16) -> String { + let make_svc = make_service_fn(|_conn| async { Ok::<_, Infallible>(service_fn(mock_server_handler)) }); + + let addr = SocketAddr::from(([127, 0, 0, 1], port)); + let server = Server::bind(&addr).serve(make_svc); + + tokio::spawn(async move { + server.await.unwrap(); + }); + + "localhost".to_string() +} + +#[tokio::test] +async fn test_did_web_resolver() { + fn verify_did_document(did_document: &DidDocument) { + assert_eq!(did_document.id().to_string(), "did:web:example.com".to_string()); + assert_eq!(did_document.verification_method().len(), 3); + assert_eq!(did_document.authentication().len(), 2); + assert_eq!(did_document.assertion_method().len(), 2); + assert_eq!(did_document.key_agreement().len(), 2); + } + + let port = 3000; + let host = create_mock_server(port).await; + + let did_web_resolver = DidWebResolver::http(); + + let did_example_1 = Did::parse(format!("did:web:{}%3A{}", host, port)).unwrap(); + let did_example_2 = Did::parse(format!("did:web:{}%3A{}:user:alice", host, port)).unwrap(); + + let result_1 = assert_ok!( + did_web_resolver + .resolve(&did_example_1, &DidResolutionOptions::default()) + .await + ); + verify_did_document(result_1.did_document()); + + let result_2 = assert_ok!( + did_web_resolver + .resolve(&did_example_2, &DidResolutionOptions::default()) + .await + ); + verify_did_document(result_2.did_document()); +}