did_onion/
lib.rs

1use async_trait::async_trait;
2use std::default::Default;
3
4use ssi_dids::did_resolve::{
5    DIDResolver, DocumentMetadata, ResolutionInputMetadata, ResolutionMetadata, ERROR_INVALID_DID,
6    TYPE_DID_LD_JSON,
7};
8use ssi_dids::{DIDMethod, Document};
9pub const USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"));
10
11const TOR_SOCKS_PORT: usize = 9050;
12
13/// did:onion Method
14///
15/// [Specification](https://blockchaincommons.github.io/did-method-onion/)
16#[non_exhaustive]
17#[derive(Clone)]
18pub struct DIDOnion {
19    pub proxy_url: String,
20}
21
22impl DIDOnion {
23    fn with_port(port: usize) -> Self {
24        Self {
25            proxy_url: format!("socks5h://127.0.0.1:{}", port),
26        }
27    }
28}
29
30impl Default for DIDOnion {
31    fn default() -> Self {
32        Self::with_port(TOR_SOCKS_PORT)
33    }
34}
35
36fn did_onion_url(did: &str) -> Result<String, ResolutionMetadata> {
37    let mut parts = did.split(':').peekable();
38    let onion_address = match (parts.next(), parts.next(), parts.next()) {
39        (Some("did"), Some("onion"), Some(domain_name)) => domain_name,
40        _ => {
41            return Err(ResolutionMetadata::from_error(ERROR_INVALID_DID));
42        }
43    };
44    for c in onion_address.chars() {
45        // "The method specific identifier ... MUST NOT include IP addresses or port numbers"
46        if c == '.' || c == ':' {
47            return Err(ResolutionMetadata::from_error(ERROR_INVALID_DID));
48        }
49    }
50    let path = match parts.peek() {
51        Some(_) => parts.collect::<Vec<&str>>().join("/"),
52        None => ".well-known".to_string(),
53    };
54    let url = format!("http://{}.onion/{}/did.json", onion_address, path);
55    Ok(url)
56}
57
58#[cfg_attr(target_arch = "wasm32", async_trait(?Send))]
59#[cfg_attr(not(target_arch = "wasm32"), async_trait)]
60impl DIDResolver for DIDOnion {
61    async fn resolve(
62        &self,
63        did: &str,
64        input_metadata: &ResolutionInputMetadata,
65    ) -> (
66        ResolutionMetadata,
67        Option<Document>,
68        Option<DocumentMetadata>,
69    ) {
70        let (mut res_meta, doc_data, doc_meta_opt) =
71            self.resolve_representation(did, input_metadata).await;
72        let doc_opt = if doc_data.is_empty() {
73            None
74        } else {
75            match serde_json::from_slice(&doc_data) {
76                Ok(doc) => doc,
77                Err(err) => {
78                    return (
79                        ResolutionMetadata::from_error(
80                            &("JSON Error: ".to_string() + &err.to_string()),
81                        ),
82                        None,
83                        None,
84                    )
85                }
86            }
87        };
88        // https://www.w3.org/TR/did-core/#did-resolution-metadata
89        // contentType - "MUST NOT be present if the resolve function was called"
90        res_meta.content_type = None;
91        (res_meta, doc_opt, doc_meta_opt)
92    }
93
94    async fn resolve_representation(
95        &self,
96        did: &str,
97        input_metadata: &ResolutionInputMetadata,
98    ) -> (ResolutionMetadata, Vec<u8>, Option<DocumentMetadata>) {
99        let url = match did_onion_url(did) {
100            Err(meta) => return (meta, Vec::new(), None),
101            Ok(url) => url,
102        };
103
104        let mut headers = reqwest::header::HeaderMap::new();
105        headers.insert(
106            reqwest::header::USER_AGENT,
107            reqwest::header::HeaderValue::from_static(USER_AGENT),
108        );
109
110        let mut client_builder = reqwest::Client::builder().default_headers(headers);
111        #[cfg(not(target_arch = "wasm32"))]
112        match reqwest::Proxy::all(&self.proxy_url) {
113            Ok(proxy) => {
114                client_builder = client_builder.proxy(proxy);
115            }
116            Err(err) => {
117                return (
118                    ResolutionMetadata::from_error(&format!("Error constructing proxy: {}", err)),
119                    Vec::new(),
120                    None,
121                )
122            }
123        };
124        let client = match client_builder.build() {
125            Ok(c) => c,
126            Err(err) => {
127                return (
128                    ResolutionMetadata::from_error(&format!("Error building HTTP client: {}", err)),
129                    Vec::new(),
130                    None,
131                )
132            }
133        };
134        let accept = input_metadata
135            .accept
136            .clone()
137            .unwrap_or_else(|| "application/json".to_string());
138        let resp = match client.get(&url).header("Accept", accept).send().await {
139            Ok(req) => req,
140            Err(err) => {
141                return (
142                    ResolutionMetadata::from_error(&format!(
143                        "Error sending HTTP request : {}",
144                        err
145                    )),
146                    Vec::new(),
147                    None,
148                )
149            }
150        };
151        match resp.error_for_status_ref() {
152            Ok(_) => (),
153            Err(err) => {
154                return (
155                    ResolutionMetadata::from_error(&err.to_string()),
156                    Vec::new(),
157                    Some(DocumentMetadata::default()),
158                )
159            }
160        };
161        let doc_representation = match resp.bytes().await {
162            Ok(bytes) => bytes.to_vec(),
163            Err(err) => {
164                return (
165                    ResolutionMetadata::from_error(
166                        &("Error reading HTTP response: ".to_string() + &err.to_string()),
167                    ),
168                    Vec::new(),
169                    None,
170                )
171            }
172        };
173        // TODO: set document created/updated metadata from HTTP headers?
174        (
175            ResolutionMetadata {
176                error: None,
177                content_type: Some(TYPE_DID_LD_JSON.to_string()),
178                property_set: None,
179            },
180            doc_representation,
181            Some(DocumentMetadata::default()),
182        )
183    }
184}
185
186impl DIDMethod for DIDOnion {
187    fn name(&self) -> &'static str {
188        "onion"
189    }
190
191    fn to_resolver(&self) -> &dyn DIDResolver {
192        self
193    }
194}
195
196#[cfg(test)]
197mod tests {
198    use super::*;
199
200    #[async_std::test]
201    async fn parse_did_onion() {
202        assert_eq!(
203            did_onion_url("did:onion:fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid").unwrap(),
204            "http://fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid.onion/.well-known/did.json"
205        );
206        assert_eq!(
207            did_onion_url("did:onion:fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid:user:alice").unwrap(),
208            "http://fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid.onion/user/alice/did.json"
209        );
210        assert_eq!(
211            did_onion_url(
212                "did:onion:fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid:u:bob"
213            )
214            .unwrap(),
215            "http://fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid.onion/u/bob/did.json"
216        );
217    }
218
219    const TORGAP_DEMO_DID: &str =
220        "did:onion:fscst5exmlmr262byztwz4kzhggjlzumvc2ndvgytzoucr2tkgxf7mid";
221
222    #[tokio::test]
223    #[ignore]
224    async fn did_onion_resolve_live() {
225        let (res_meta, doc_opt, _doc_meta) = DIDOnion::default()
226            .resolve(TORGAP_DEMO_DID, &ResolutionInputMetadata::default())
227            .await;
228        assert_eq!(res_meta.error, None);
229        assert!(doc_opt.is_some());
230    }
231
232    /*
233     * TODO: test with local proxy and local web server
234     * https://github.com/spruceid/ssi/issues/162
235
236    // localhost web server for serving did:onion DID documents.
237    // Based on the one in did-web's tests.
238    use std::net::SocketAddr;
239    fn web_server() -> Result<(String, impl FnOnce() -> Result<(), ()>), hyper::Error> {
240        use http::header::{HeaderValue, CONTENT_TYPE};
241        use hyper::service::{make_service_fn, service_fn};
242        use hyper::{Body, Response, Server};
243        let addr = ([127, 0, 0, 1], 0).into();
244        let make_svc = make_service_fn(|_| async move {
245            Ok::<_, hyper::Error>(service_fn(|req| async move {
246                let uri = req.uri();
247                // Skip leading slash
248                let proxied_url: String = uri.path().chars().skip(1).collect();
249                if proxied_url == DID_URL {
250                    let body = Body::from(DID_JSON);
251                    let mut response = Response::new(body);
252                    response
253                        .headers_mut()
254                        .insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
255                    return Ok::<_, hyper::Error>(response);
256                }
257
258                let (mut parts, body) = Response::<Body>::default().into_parts();
259                parts.status = hyper::StatusCode::NOT_FOUND;
260                let response = Response::from_parts(parts, body);
261                return Ok::<_, hyper::Error>(response);
262            }))
263        });
264        let server = Server::try_bind(&addr)?.serve(make_svc);
265        let addr: SocketAddr = server.local_addr().parse()?;
266        let (shutdown_tx, shutdown_rx) = futures::channel::oneshot::channel();
267        let graceful = server.with_graceful_shutdown(async {
268            shutdown_rx.await.ok();
269        });
270        tokio::task::spawn(async move {
271            graceful.await.ok();
272        });
273        let shutdown = || shutdown_tx.send(());
274        Ok((addr, shutdown))
275    }
276
277    #[tokio::test]
278    async fn did_onion_resolve() {
279        use socks5_async::SocksServer;
280        let mut socks5 = SocksServer::new(
281            addr,
282            true,
283            Box::new(move |username, password| {
284                //
285            }),
286        )
287        .await;
288        socks5.server().await;
289
290        let (res_meta, doc_opt, _doc_meta) = DIDOnion::default()
291            .resolve(TORGAP_DEMO_DID, &ResolutionInputMetadata::default())
292            .await;
293        assert_eq!(res_meta.error, None);
294        assert!(doc_opt.is_some());
295    }
296    */
297}