baseid_did/methods/
web.rs

1//! `did:web` method implementation.
2//!
3//! `did:web` resolves DID Documents by fetching them over HTTPS.
4//! Practical for organizations and easy to deploy.
5//!
6//! Reference: <https://w3c-ccg.github.io/did-method-web/>
7
8use baseid_core::error::DidError;
9
10use crate::document::DidDocument;
11use crate::resolution::{DidResolver, ResolutionMetadata, ResolutionResult};
12use crate::url::DidUrl;
13
14/// Resolver for `did:web` method.
15pub 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    /// Create a resolver with a custom HTTP client.
27    ///
28    /// Useful for testing or configuring timeouts, proxies, etc.
29    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        // Reject oversized responses to prevent memory exhaustion.
66        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        // Validate that the document id matches the requested DID.
78        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
93/// Maximum response body size for DID Document fetches (1 MiB).
94const MAX_RESPONSE_SIZE: u64 = 1024 * 1024;
95
96/// Convert a `did:web` DID to the HTTPS URL where the DID Document is hosted.
97///
98/// Rules (per spec):
99/// - `did:web:example.com` → `https://example.com/.well-known/did.json`
100/// - `did:web:example.com:path:to` → `https://example.com/path/to/did.json`
101/// - `did:web:example.com%3A3000` → `https://example.com:3000/.well-known/did.json`
102///
103/// Colons in the method-specific identifier are path separators.
104/// Percent-encoded characters (like `%3A` for `:`) are decoded.
105///
106/// Rejects domains that resolve to private/reserved IP ranges (SSRF protection)
107/// and path segments containing `..` (path traversal protection).
108pub 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    // The method_id contains the domain (and optional path), with colons as separators.
115    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    // Percent-decode the domain segment.
121    let domain = percent_decode(segments[0])?;
122
123    // SSRF protection: reject private/reserved domains
124    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            // Path traversal protection
135            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
147/// Percent-decode a DID segment, producing valid UTF-8.
148fn 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
168/// Check if a host string refers to a private or reserved IP address.
169///
170/// Rejects: localhost, 127.x, 10.x, 172.16-31.x, 192.168.x, 169.254.x,
171/// 0.x, [::1], and other reserved ranges. This prevents SSRF attacks
172/// when resolving did:web documents.
173fn is_private_host(host: &str) -> bool {
174    let host_lower = host.to_ascii_lowercase();
175
176    // Strip port: for bracketed IPv6 like "[::1]:8080", split after ']'
177    // For plain hosts like "localhost:3000", split on last ':'
178    let hostname = if host_lower.starts_with('[') {
179        // IPv6 bracket notation — hostname is everything up to and including ']'
180        host_lower
181            .split(']')
182            .next()
183            .map(|s| format!("{s}]"))
184            .unwrap_or(host_lower.clone())
185    } else {
186        // For IPv4/hostname, strip port by taking up to the last ':'
187        // But only if it looks like host:port (not an IPv6 without brackets)
188        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    // Try to parse as IPv4
201    if let Ok(ip) = hostname.parse::<std::net::Ipv4Addr>() {
202        return ip.is_loopback()       // 127.0.0.0/8
203            || ip.is_private()         // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16
204            || ip.is_link_local()      // 169.254.0.0/16
205            || ip.is_broadcast()       // 255.255.255.255
206            || ip.is_unspecified()     // 0.0.0.0
207            || ip.octets()[0] == 0; // 0.x.x.x
208    }
209
210    // Try to parse as IPv6 (with or without brackets)
211    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()        // ::1
217            || ip.is_unspecified()     // ::
218            || 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        // %FF%FE is not valid UTF-8
289        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        // IPv6 literal must be percent-encoded in did:web (colons are separators)
311        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        // Path segment starting with / after decode
335        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}