baseid_did/methods/
web.rs1use baseid_core::error::DidError;
9
10use crate::document::DidDocument;
11use crate::resolution::{DidResolver, ResolutionMetadata, ResolutionResult};
12use crate::url::DidUrl;
13
14pub struct DidWebResolver {
16 http_client: reqwest::Client,
17}
18
19impl DidWebResolver {
20 pub fn new() -> Self {
21 Self {
22 http_client: reqwest::Client::new(),
23 }
24 }
25
26 pub fn with_client(client: reqwest::Client) -> Self {
30 Self {
31 http_client: client,
32 }
33 }
34}
35
36impl Default for DidWebResolver {
37 fn default() -> Self {
38 Self::new()
39 }
40}
41
42impl DidResolver for DidWebResolver {
43 fn method(&self) -> &str {
44 "web"
45 }
46
47 async fn resolve(&self, did: &str) -> baseid_core::Result<ResolutionResult> {
48 let url = did_to_url(did)?;
49
50 let response = self
51 .http_client
52 .get(&url)
53 .header("Accept", "application/did+ld+json, application/json")
54 .send()
55 .await
56 .map_err(|_| DidError::ResolutionFailed)?;
57
58 if !response.status().is_success() {
59 if response.status().as_u16() == 404 {
60 return Err(DidError::NotFound.into());
61 }
62 return Err(DidError::ResolutionFailed.into());
63 }
64
65 if let Some(len) = response.content_length() {
67 if len > MAX_RESPONSE_SIZE {
68 return Err(DidError::ResolutionFailed.into());
69 }
70 }
71
72 let document: DidDocument = response
73 .json()
74 .await
75 .map_err(|_| DidError::ResolutionFailed)?;
76
77 let parsed = DidUrl::parse(did)?;
79 if document.id != parsed.did {
80 return Err(DidError::ResolutionFailed.into());
81 }
82
83 Ok(ResolutionResult {
84 document: Some(document),
85 metadata: ResolutionMetadata {
86 content_type: Some("application/did+ld+json".to_string()),
87 error: None,
88 },
89 })
90 }
91}
92
93const MAX_RESPONSE_SIZE: u64 = 1024 * 1024;
95
96pub fn did_to_url(did: &str) -> baseid_core::Result<String> {
109 let parsed = DidUrl::parse(did)?;
110 if parsed.method != "web" {
111 return Err(DidError::UnsupportedMethod.into());
112 }
113
114 let segments: Vec<&str> = parsed.method_id.split(':').collect();
116 if segments.is_empty() || segments[0].is_empty() {
117 return Err(DidError::InvalidDid.into());
118 }
119
120 let domain = percent_decode(segments[0])?;
122
123 if is_private_host(&domain) {
125 return Err(DidError::ResolutionFailed.into());
126 }
127
128 if segments.len() == 1 {
129 Ok(format!("https://{domain}/.well-known/did.json"))
130 } else {
131 let mut path_parts = Vec::with_capacity(segments.len() - 1);
132 for seg in &segments[1..] {
133 let decoded = percent_decode(seg)?;
134 if decoded.contains("..") || decoded.starts_with('/') {
136 return Err(DidError::InvalidDid.into());
137 }
138 path_parts.push(decoded);
139 }
140 Ok(format!(
141 "https://{domain}/{}/did.json",
142 path_parts.join("/")
143 ))
144 }
145}
146
147fn percent_decode(input: &str) -> baseid_core::Result<String> {
149 let mut bytes = Vec::with_capacity(input.len());
150 let src = input.as_bytes();
151 let mut i = 0;
152 while i < src.len() {
153 if src[i] == b'%' && i + 2 < src.len() {
154 if let Ok(byte) =
155 u8::from_str_radix(std::str::from_utf8(&src[i + 1..i + 3]).unwrap_or(""), 16)
156 {
157 bytes.push(byte);
158 i += 3;
159 continue;
160 }
161 }
162 bytes.push(src[i]);
163 i += 1;
164 }
165 String::from_utf8(bytes).map_err(|_| DidError::InvalidDid.into())
166}
167
168fn is_private_host(host: &str) -> bool {
174 let host_lower = host.to_ascii_lowercase();
175
176 let hostname = if host_lower.starts_with('[') {
179 host_lower
181 .split(']')
182 .next()
183 .map(|s| format!("{s}]"))
184 .unwrap_or(host_lower.clone())
185 } else {
186 match host_lower.rfind(':') {
189 Some(pos) if host_lower[pos + 1..].chars().all(|c| c.is_ascii_digit()) => {
190 host_lower[..pos].to_string()
191 }
192 _ => host_lower.clone(),
193 }
194 };
195
196 if hostname == "localhost" {
197 return true;
198 }
199
200 if let Ok(ip) = hostname.parse::<std::net::Ipv4Addr>() {
202 return ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_broadcast() || ip.is_unspecified() || ip.octets()[0] == 0; }
209
210 let v6_str = hostname
212 .strip_prefix('[')
213 .and_then(|s| s.strip_suffix(']'))
214 .unwrap_or(&hostname);
215 if let Ok(ip) = v6_str.parse::<std::net::Ipv6Addr>() {
216 return ip.is_loopback() || ip.is_unspecified() || ip.to_ipv4_mapped().is_some_and(|v4| {
219 v4.is_loopback() || v4.is_private() || v4.is_link_local() || v4.is_unspecified()
220 });
221 }
222
223 false
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 #[test]
231 fn url_simple_domain() {
232 let url = did_to_url("did:web:example.com").unwrap();
233 assert_eq!(url, "https://example.com/.well-known/did.json");
234 }
235
236 #[test]
237 fn url_domain_with_path() {
238 let url = did_to_url("did:web:example.com:path:to:resource").unwrap();
239 assert_eq!(url, "https://example.com/path/to/resource/did.json");
240 }
241
242 #[test]
243 fn url_domain_with_port() {
244 let url = did_to_url("did:web:example.com%3A3000").unwrap();
245 assert_eq!(url, "https://example.com:3000/.well-known/did.json");
246 }
247
248 #[test]
249 fn url_domain_with_port_and_path() {
250 let url = did_to_url("did:web:example.com%3A3000:user:alice").unwrap();
251 assert_eq!(url, "https://example.com:3000/user/alice/did.json");
252 }
253
254 #[test]
255 fn url_subdomain() {
256 let url = did_to_url("did:web:w3c-ccg.github.io").unwrap();
257 assert_eq!(url, "https://w3c-ccg.github.io/.well-known/did.json");
258 }
259
260 #[test]
261 fn url_subdomain_with_path() {
262 let url = did_to_url("did:web:w3c-ccg.github.io:user:alice").unwrap();
263 assert_eq!(url, "https://w3c-ccg.github.io/user/alice/did.json");
264 }
265
266 #[test]
267 fn url_wrong_method_fails() {
268 assert!(did_to_url("did:key:z6Mk...").is_err());
269 }
270
271 #[test]
272 fn url_invalid_did_fails() {
273 assert!(did_to_url("not-a-did").is_err());
274 }
275
276 #[test]
277 fn percent_decode_basic() {
278 assert_eq!(
279 percent_decode("example.com%3A3000").unwrap(),
280 "example.com:3000"
281 );
282 assert_eq!(percent_decode("hello%20world").unwrap(), "hello world");
283 assert_eq!(percent_decode("nopercent").unwrap(), "nopercent");
284 }
285
286 #[test]
287 fn percent_decode_invalid_utf8_rejected() {
288 assert!(percent_decode("bad%FF%FE").is_err());
290 }
291
292 #[test]
293 fn ssrf_localhost_rejected() {
294 assert!(did_to_url("did:web:localhost").is_err());
295 assert!(did_to_url("did:web:localhost%3A8080").is_err());
296 }
297
298 #[test]
299 fn ssrf_private_ip_rejected() {
300 assert!(did_to_url("did:web:127.0.0.1").is_err());
301 assert!(did_to_url("did:web:10.0.0.1").is_err());
302 assert!(did_to_url("did:web:192.168.1.1").is_err());
303 assert!(did_to_url("did:web:172.16.0.1").is_err());
304 assert!(did_to_url("did:web:169.254.1.1").is_err());
305 assert!(did_to_url("did:web:0.0.0.0").is_err());
306 }
307
308 #[test]
309 fn ssrf_ipv6_loopback_rejected() {
310 assert!(did_to_url("did:web:%5B%3A%3A1%5D").is_err());
312 }
313
314 #[test]
315 fn is_private_host_unit() {
316 assert!(is_private_host("localhost"));
317 assert!(is_private_host("127.0.0.1"));
318 assert!(is_private_host("10.0.0.1"));
319 assert!(is_private_host("192.168.1.1"));
320 assert!(is_private_host("[::1]"));
321 assert!(is_private_host("localhost:8080"));
322 assert!(!is_private_host("example.com"));
323 assert!(!is_private_host("8.8.8.8"));
324 }
325
326 #[test]
327 fn path_traversal_rejected() {
328 assert!(did_to_url("did:web:example.com:..").is_err());
329 assert!(did_to_url("did:web:example.com:path:..%2F..%2Fetc").is_err());
330 }
331
332 #[test]
333 fn absolute_path_segment_rejected() {
334 assert!(did_to_url("did:web:example.com:%2Fetc:passwd").is_err());
336 }
337
338 #[test]
339 fn public_domain_accepted() {
340 assert!(did_to_url("did:web:example.com").is_ok());
341 assert!(did_to_url("did:web:baseid.ca").is_ok());
342 assert!(did_to_url("did:web:gov.bc.ca:identity").is_ok());
343 }
344
345 #[test]
346 fn resolver_wrong_method_fails() {
347 let resolver = DidWebResolver::new();
348 let result = tokio::runtime::Runtime::new()
349 .unwrap()
350 .block_on(resolver.resolve("did:key:z6Mk..."));
351 assert!(result.is_err());
352 }
353}