did_web/
lib.rs

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