did_web/
lib.rs

1use http::header;
2use ssi_dids_core::{
3    document::representation::MediaType,
4    resolution::{self, DIDMethodResolver, Error, Output},
5    DIDMethod,
6};
7
8pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
9
10// For testing, enable handling requests at localhost.
11#[cfg(test)]
12use std::cell::RefCell;
13
14#[cfg(test)]
15thread_local! {
16  static PROXY: RefCell<Option<String>> = RefCell::new(None);
17}
18
19#[derive(Debug, thiserror::Error)]
20pub enum InternalError {
21    #[error("Error building HTTP client: {0}")]
22    Client(reqwest::Error),
23
24    #[error("Error sending HTTP request ({0}): {1}")]
25    Request(String, reqwest::Error),
26
27    #[error("Server error: {0}")]
28    Server(String),
29
30    #[error("Error reading HTTP response: {0}")]
31    Response(reqwest::Error),
32}
33
34/// did:web Method
35///
36/// [Specification](https://w3c-ccg.github.io/did-method-web/)
37pub struct DIDWeb;
38
39fn did_web_url(id: &str) -> Result<String, Error> {
40    let mut parts = id.split(':').peekable();
41    let domain_name = parts
42        .next()
43        .ok_or_else(|| Error::InvalidMethodSpecificId(id.to_owned()))?;
44
45    // TODO:
46    // - Validate domain name: alphanumeric, hyphen, dot. no IP address.
47    // - Ensure domain name matches TLS certificate common name
48    // - Support punycode?
49    // - Support query strings?
50    let path = match parts.peek() {
51        Some(_) => parts.collect::<Vec<&str>>().join("/"),
52        None => ".well-known".to_string(),
53    };
54
55    // Use http for localhost, for testing purposes.
56    let proto = if domain_name.starts_with("localhost") {
57        "http"
58    } else {
59        "https"
60    };
61
62    #[allow(unused_mut)]
63    let mut url = format!(
64        "{proto}://{}/{path}/did.json",
65        domain_name.replacen("%3A", ":", 1)
66    );
67
68    #[cfg(test)]
69    PROXY.with(|proxy| {
70        if let Some(ref proxy) = *proxy.borrow() {
71            url = proxy.clone() + &url;
72        }
73    });
74
75    Ok(url)
76}
77
78impl DIDMethod for DIDWeb {
79    const DID_METHOD_NAME: &'static str = "web";
80}
81
82/// <https://w3c-ccg.github.io/did-method-web/#read-resolve>
83impl DIDMethodResolver for DIDWeb {
84    async fn resolve_method_representation<'a>(
85        &'a self,
86        method_specific_id: &'a str,
87        options: resolution::Options,
88    ) -> Result<Output<Vec<u8>>, Error> {
89        // let did = DIDBuf::new(format!("did:web:{method_specific_id}")).unwrap();
90
91        let url = did_web_url(method_specific_id)?;
92        // TODO: https://w3c-ccg.github.io/did-method-web/#in-transit-security
93
94        let mut headers = reqwest::header::HeaderMap::new();
95
96        headers.insert(
97            "User-Agent",
98            reqwest::header::HeaderValue::from_static(USER_AGENT),
99        );
100
101        let client = reqwest::Client::builder()
102            .default_headers(headers)
103            .build()
104            .map_err(|e| Error::internal(InternalError::Client(e)))?;
105
106        let accept = options.accept.unwrap_or(MediaType::Json);
107
108        let resp = client
109            .get(&url)
110            .header(header::ACCEPT, accept.to_string())
111            .send()
112            .await
113            .map_err(|e| Error::internal(InternalError::Request(url.to_owned(), e)))?;
114
115        resp.error_for_status_ref().map_err(|err| {
116            if err.status() == Some(reqwest::StatusCode::NOT_FOUND) {
117                Error::NotFound
118            } else {
119                Error::internal(InternalError::Server(err.to_string()))
120            }
121        })?;
122
123        let media_type = resp
124            .headers()
125            .get(header::CONTENT_TYPE)
126            .map(|value| match value.as_bytes() {
127                b"application/json" => Ok(MediaType::Json),
128                b"application/json; charset=utf-8" => Ok(MediaType::Json),
129                other => MediaType::from_bytes(other),
130            })
131            .transpose()?
132            .unwrap_or(MediaType::Json);
133
134        let document = resp
135            .bytes()
136            .await
137            .map_err(|e| Error::internal(InternalError::Response(e)))?;
138
139        // TODO: set document created/updated metadata from HTTP headers?
140        Ok(Output {
141            document: document.into(),
142            document_metadata: ssi_dids_core::document::Metadata::default(),
143            metadata: resolution::Metadata::from_content_type(Some(media_type.to_string())),
144        })
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use ssi_claims::{
151        data_integrity::{AnySuite, CryptographicSuite, ProofOptions},
152        vc::{syntax::NonEmptyVec, v1::JsonCredential},
153        VerificationParameters,
154    };
155    use ssi_dids_core::{did, DIDResolver, Document, VerificationMethodDIDResolver};
156    use ssi_jwk::JWK;
157    use ssi_verification_methods_core::{ProofPurpose, SingleSecretSigner};
158    use static_iref::{iri, uri};
159
160    use super::*;
161
162    #[tokio::test]
163    async fn parse_did_web() {
164        // https://w3c-ccg.github.io/did-method-web/#example-3-creating-the-did
165        assert_eq!(
166            did_web_url(did!("did:web:w3c-ccg.github.io").method_specific_id()).unwrap(),
167            "https://w3c-ccg.github.io/.well-known/did.json"
168        );
169        // https://w3c-ccg.github.io/did-method-web/#example-4-creating-the-did-with-optional-path
170        assert_eq!(
171            did_web_url(did!("did:web:w3c-ccg.github.io:user:alice").method_specific_id()).unwrap(),
172            "https://w3c-ccg.github.io/user/alice/did.json"
173        );
174        // https://w3c-ccg.github.io/did-method-web/#optional-path-considerations
175        assert_eq!(
176            did_web_url(did!("did:web:example.com:u:bob").method_specific_id()).unwrap(),
177            "https://example.com/u/bob/did.json"
178        );
179        // https://w3c-ccg.github.io/did-method-web/#example-creating-the-did-with-optional-path-and-port
180        assert_eq!(
181            did_web_url(did!("did:web:example.com%3A443:u:bob").method_specific_id()).unwrap(),
182            "https://example.com:443/u/bob/did.json"
183        );
184    }
185
186    const DID_URL: &str = "http://localhost/.well-known/did.json";
187    const DID_JSON: &str = r#"{
188      "@context": "https://www.w3.org/ns/did/v1",
189      "id": "did:web:localhost",
190      "verificationMethod": [{
191         "id": "did:web:localhost#key1",
192         "type": "Ed25519VerificationKey2018",
193         "controller": "did:web:localhost",
194         "publicKeyBase58": "2sXRz2VfrpySNEL6xmXJWQg6iY94qwNp1qrJJFBuPWmH"
195      }],
196      "assertionMethod": ["did:web:localhost#key1"]
197    }"#;
198
199    // localhost web server for serving did:web DID documents.
200    // TODO: pass arguments here instead of using const
201    fn web_server() -> Result<(String, impl FnOnce() -> Result<(), ()>), hyper::Error> {
202        use http::header::{HeaderValue, CONTENT_TYPE};
203        use hyper::service::{make_service_fn, service_fn};
204        use hyper::{Body, Response, Server};
205        let addr = ([127, 0, 0, 1], 0).into();
206        let make_svc = make_service_fn(|_| async move {
207            Ok::<_, hyper::Error>(service_fn(|req| async move {
208                let uri = req.uri();
209                // Skip leading slash
210                let proxied_url: String = uri.path().chars().skip(1).collect();
211                if proxied_url == DID_URL {
212                    let body = Body::from(DID_JSON);
213                    let mut response = Response::new(body);
214                    response
215                        .headers_mut()
216                        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
217                    return Ok::<_, hyper::Error>(response);
218                }
219
220                let (mut parts, body) = Response::<Body>::default().into_parts();
221                parts.status = hyper::StatusCode::NOT_FOUND;
222                let response = Response::from_parts(parts, body);
223                Ok::<_, hyper::Error>(response)
224            }))
225        });
226        let server = Server::try_bind(&addr)?.serve(make_svc);
227        let url = "http://".to_string() + &server.local_addr().to_string() + "/";
228        let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel();
229        let graceful = server.with_graceful_shutdown(async {
230            shutdown_rx.await.ok();
231        });
232        tokio::task::spawn(async move {
233            graceful.await.ok();
234        });
235        let shutdown = || shutdown_tx.send(());
236        Ok((url, shutdown))
237    }
238
239    #[tokio::test]
240    async fn from_did_key() {
241        let (url, shutdown) = web_server().unwrap();
242        PROXY.with(|proxy| {
243            proxy.replace(Some(url));
244        });
245        let doc = DIDWeb.resolve(did!("did:web:localhost")).await.unwrap();
246        let doc_expected = Document::from_bytes(MediaType::Json, DID_JSON.as_bytes()).unwrap();
247        assert_eq!(doc.document.document(), doc_expected.document());
248        PROXY.with(|proxy| {
249            proxy.replace(None);
250        });
251        shutdown().ok();
252    }
253
254    #[tokio::test]
255    async fn credential_prove_verify_did_web() {
256        let didweb = VerificationMethodDIDResolver::new(DIDWeb);
257        let params = VerificationParameters::from_resolver(&didweb);
258
259        let (url, shutdown) = web_server().unwrap();
260        PROXY.with(|proxy| {
261            proxy.replace(Some(url));
262        });
263
264        let cred = JsonCredential::new(
265            None,
266            did!("did:web:localhost").to_owned().into_uri().into(),
267            "2021-01-26T16:57:27Z".parse().unwrap(),
268            NonEmptyVec::new(json_syntax::json!({
269                "id": "did:web:localhost"
270            })),
271        );
272
273        let key: JWK = include_str!("../../../../../tests/ed25519-2020-10-18.json")
274            .parse()
275            .unwrap();
276        let verification_method = iri!("did:web:localhost#key1").to_owned().into();
277        let suite = AnySuite::pick(&key, Some(&verification_method)).unwrap();
278        let issue_options = ProofOptions::new(
279            "2021-01-26T16:57:27Z".parse().unwrap(),
280            verification_method,
281            ProofPurpose::Assertion,
282            Default::default(),
283        );
284        let signer = SingleSecretSigner::new(key).into_local();
285        let vc = suite
286            .sign(cred, &didweb, &signer, issue_options)
287            .await
288            .unwrap();
289
290        println!(
291            "proof: {}",
292            serde_json::to_string_pretty(&vc.proofs).unwrap()
293        );
294        assert_eq!(vc.proofs.first().unwrap().signature.as_ref(), "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..BCvVb4jz-yVaTeoP24Wz0cOtiHKXCdPcmFQD_pxgsMU6aCAj1AIu3cqHyoViU93nPmzqMLswOAqZUlMyVnmzDw");
295        assert!(vc.verify(&params).await.unwrap().is_ok());
296
297        // test that issuer property is used for verification
298        let mut vc_bad_issuer = vc.clone();
299        vc_bad_issuer.issuer = uri!("did:pkh:example:bad").to_owned().into();
300        // It should fail.
301        assert!(vc_bad_issuer.verify(params).await.unwrap().is_err());
302
303        PROXY.with(|proxy| {
304            proxy.replace(None);
305        });
306        shutdown().ok();
307    }
308}