1use http::{HeaderMap, HeaderValue, Method};
2use percent_encoding::percent_decode_str;
3use serde::Deserialize;
4use serde_json::Value;
5use url::Url;
6
7use crate::{ClientAssertionVerifyingKey, CoreError, HttpRequest, HttpTransport, SigningAlgorithm};
8
9const MAX_DID_DOCUMENT_BYTES: usize = 1 << 20;
10
11#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
12pub struct DidWebDocumentUrlOptions {
13 pub allow_insecure_loopback: bool,
14}
15
16pub struct ResolveDidWebPublicKeyOptions<'a> {
17 pub algorithm: SigningAlgorithm,
18 pub allow_insecure_loopback: bool,
19 pub did: &'a str,
20 pub key_id: &'a str,
21 pub transport: &'a dyn HttpTransport,
22}
23
24pub fn did_web_document_url(did: &str) -> Result<Url, CoreError> {
25 did_web_document_url_with_options(did, DidWebDocumentUrlOptions::default())
26}
27
28pub fn did_web_document_url_with_options(
29 did: &str,
30 options: DidWebDocumentUrlOptions,
31) -> Result<Url, CoreError> {
32 let Some(identifier) = did.strip_prefix("did:web:") else {
33 return Err(CoreError::Invalid(format!("unsupported DID method: {did}")));
34 };
35 let mut parts = identifier.split(':');
36 let encoded_host = parts
37 .next()
38 .filter(|host| !host.is_empty())
39 .ok_or_else(|| CoreError::Invalid(format!("invalid did:web identifier: {did}")))?;
40 let host = decode_component(encoded_host, "did:web host")?;
41 if !is_did_web_host(&host) {
45 return Err(CoreError::Invalid(format!("invalid did:web host: {host}")));
46 }
47 let authority = Url::parse(&format!("https://{host}"))?;
50 let scheme = if options.allow_insecure_loopback
51 && authority
52 .host_str()
53 .is_some_and(crate::openapi::is_loopback_host)
54 {
55 "http"
56 } else {
57 "https"
58 };
59 let path_parts = parts
60 .map(|part| {
61 let segment = decode_component(part, "did:web path")?;
62 if !is_did_web_path_segment(&segment) {
66 return Err(CoreError::Invalid(format!(
67 "invalid did:web path segment: {did}"
68 )));
69 }
70 Ok(part)
72 })
73 .collect::<Result<Vec<_>, _>>()?;
74 let path = if path_parts.is_empty() {
75 "/.well-known/did.json".to_owned()
76 } else {
77 format!("/{}/did.json", path_parts.join("/"))
78 };
79 Url::parse(&format!("{scheme}://{host}{path}")).map_err(CoreError::from)
80}
81
82pub async fn resolve_did_web_public_key(
83 options: ResolveDidWebPublicKeyOptions<'_>,
84) -> Result<ClientAssertionVerifyingKey, CoreError> {
85 if options.key_id.is_empty() {
86 return Err(CoreError::Invalid(
87 "AEP did:web key ID is required".to_owned(),
88 ));
89 }
90 let key_did = options
91 .key_id
92 .split_once('#')
93 .map_or(options.key_id, |part| part.0);
94 if key_did != options.did {
95 return Err(CoreError::Invalid(
96 "AEP did:web key ID does not identify the assertion issuer".to_owned(),
97 ));
98 }
99 let document_url = did_web_document_url_with_options(
100 options.did,
101 DidWebDocumentUrlOptions {
102 allow_insecure_loopback: options.allow_insecure_loopback,
103 },
104 )?;
105 let mut headers = HeaderMap::new();
106 headers.insert(
107 http::header::ACCEPT,
108 HeaderValue::from_static("application/did+json, application/json"),
109 );
110 let response = options
111 .transport
112 .send(HttpRequest {
113 method: Method::GET,
114 url: document_url.clone(),
115 headers,
116 body: Vec::new(),
117 })
118 .await?;
119 if response.final_url != document_url {
120 return Err(CoreError::Invalid(
121 "did:web document redirects are not allowed".to_owned(),
122 ));
123 }
124 if !response.status.is_success() {
125 return Err(CoreError::Invalid(format!(
126 "fetch did:web document: HTTP {}",
127 response.status.as_u16()
128 )));
129 }
130 if response.body.len() > MAX_DID_DOCUMENT_BYTES {
131 return Err(CoreError::Invalid(
132 "did:web document exceeds the 1 MiB limit".to_owned(),
133 ));
134 }
135 let document = serde_json::from_slice::<DidDocument>(&response.body)?;
136 if document.id != options.did {
137 return Err(CoreError::Invalid(
138 "did:web document does not identify the requested DID".to_owned(),
139 ));
140 }
141 let method = document
142 .verification_method
143 .into_iter()
144 .find(|method| method.id == options.key_id)
145 .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
146 let key_value = method
147 .public_key_jwk
148 .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
149 validate_jwk_metadata(&key_value, options.key_id, &options.algorithm)?;
150 let key = serde_json::from_value::<jwt_compact::jwk::JsonWebKey<'static>>(key_value)?;
151 ClientAssertionVerifyingKey::from_jwk(&key, &options.algorithm)
152}
153
154fn validate_jwk_metadata(
155 value: &Value,
156 expected_key_id: &str,
157 expected_algorithm: &SigningAlgorithm,
158) -> Result<(), CoreError> {
159 let object = value.as_object().ok_or_else(|| {
160 CoreError::Invalid("AEP did:web publicKeyJwk must be an object".to_owned())
161 })?;
162 if let Some(algorithm) = object.get("alg")
163 && algorithm.as_str() != Some(expected_algorithm.as_str())
164 {
165 return Err(CoreError::Invalid(
166 "AEP did:web publicKeyJwk alg does not match the assertion".to_owned(),
167 ));
168 }
169 if let Some(key_id) = object.get("kid")
170 && key_id.as_str() != Some(expected_key_id)
171 {
172 return Err(CoreError::Invalid(
173 "AEP did:web publicKeyJwk kid does not match the verification method".to_owned(),
174 ));
175 }
176 if object.get("d").is_some() {
177 return Err(CoreError::Invalid(
178 "AEP did:web publicKeyJwk must not expose private key material".to_owned(),
179 ));
180 }
181 if let Some(key_use) = object.get("use")
182 && key_use.as_str() != Some("sig")
183 {
184 return Err(CoreError::Invalid(
185 "AEP did:web publicKeyJwk use must be sig".to_owned(),
186 ));
187 }
188 if let Some(key_operations) = object.get("key_ops")
189 && !key_operations
190 .as_array()
191 .is_some_and(|operations| operations.iter().any(|operation| operation == "verify"))
192 {
193 return Err(CoreError::Invalid(
194 "AEP did:web publicKeyJwk key_ops must allow verify".to_owned(),
195 ));
196 }
197 Ok(())
198}
199
200fn is_did_web_host(value: &str) -> bool {
202 let (name, port) = if value.starts_with('[') {
203 match value.find(']') {
206 Some(end) => match &value[end + 1..] {
207 "" => (&value[..=end], None),
208 rest => match rest.strip_prefix(':') {
209 Some(port) => (&value[..=end], Some(port)),
210 None => return false,
211 },
212 },
213 None => return false,
214 }
215 } else {
216 match value.rsplit_once(':') {
217 Some((name, port)) => (name, Some(port)),
218 None => (value, None),
219 }
220 };
221 if let Some(port) = port
222 && (port.is_empty() || port.len() > 5 || !port.bytes().all(|byte| byte.is_ascii_digit()))
223 {
224 return false;
225 }
226 if let Some(literal) = name
227 .strip_prefix('[')
228 .and_then(|value| value.strip_suffix(']'))
229 {
230 return !literal.is_empty()
231 && literal
232 .bytes()
233 .all(|byte| byte.is_ascii_hexdigit() || byte == b':' || byte == b'.');
234 }
235 !name.is_empty()
236 && name.split('.').all(|label| {
237 !label.is_empty()
238 && label
239 .bytes()
240 .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
241 })
242}
243
244fn is_did_web_path_segment(value: &str) -> bool {
246 const EXTRA: &[u8] = b"-._~!$&'()*+,;=:@%";
247 !value.is_empty()
248 && value != "."
249 && value != ".."
250 && value
251 .bytes()
252 .all(|byte| byte.is_ascii_alphanumeric() || EXTRA.contains(&byte))
253}
254
255fn decode_component(value: &str, label: &str) -> Result<String, CoreError> {
256 let bytes = value.as_bytes();
257 for (index, byte) in bytes.iter().enumerate() {
258 if *byte == b'%'
259 && (!bytes.get(index + 1).is_some_and(u8::is_ascii_hexdigit)
260 || !bytes.get(index + 2).is_some_and(u8::is_ascii_hexdigit))
261 {
262 return Err(CoreError::Invalid(format!(
263 "invalid percent escape in {label}"
264 )));
265 }
266 }
267 percent_decode_str(value)
268 .decode_utf8()
269 .map(String::from)
270 .map_err(|error| CoreError::Invalid(format!("decode {label}: {error}")))
271}
272
273#[derive(Deserialize)]
274struct DidDocument {
275 id: String,
276 #[serde(default, rename = "verificationMethod")]
277 verification_method: Vec<VerificationMethod>,
278}
279
280#[derive(Deserialize)]
281struct VerificationMethod {
282 id: String,
283 #[serde(rename = "publicKeyJwk")]
284 public_key_jwk: Option<Value>,
285}
286
287#[cfg(test)]
288mod tests {
289 use async_trait::async_trait;
290 use futures::executor::block_on;
291 use http::StatusCode;
292
293 use super::*;
294
295 struct StubTransport;
296
297 struct RedirectTransport;
298
299 #[async_trait]
300 impl HttpTransport for StubTransport {
301 async fn send(
302 &self,
303 request: HttpRequest,
304 ) -> Result<crate::HttpResponse, crate::TransportError> {
305 Ok(crate::HttpResponse {
306 status: StatusCode::OK,
307 final_url: request.url,
308 headers: HeaderMap::new(),
309 body: br#"{
310 "id": "did:web:agent.example",
311 "verificationMethod": [{
312 "id": "did:web:agent.example#key-1",
313 "publicKeyJwk": {
314 "alg": "EdDSA",
315 "crv": "Ed25519",
316 "kid": "did:web:agent.example#key-1",
317 "kty": "OKP",
318 "use": "sig",
319 "x": "2-Jj2UvNCvQiUPNYRgSi0cJSPiJI6Rs6D0UTeEpQVj8"
320 }
321 }]
322 }"#
323 .to_vec(),
324 })
325 }
326 }
327
328 #[async_trait]
329 impl HttpTransport for RedirectTransport {
330 async fn send(
331 &self,
332 mut request: HttpRequest,
333 ) -> Result<crate::HttpResponse, crate::TransportError> {
334 request.url.set_path("/redirected/did.json");
335 Ok(crate::HttpResponse {
336 status: StatusCode::OK,
337 final_url: request.url,
338 headers: HeaderMap::new(),
339 body: br#"{"verificationMethod":[]}"#.to_vec(),
340 })
341 }
342 }
343
344 #[test]
345 fn maps_root_and_path_dids() {
346 assert_eq!(
347 did_web_document_url("did:web:api.example.com")
348 .expect("root DID")
349 .as_str(),
350 "https://api.example.com/.well-known/did.json"
351 );
352 assert_eq!(
353 did_web_document_url("did:web:127.0.0.1%3A4100:agents:one")
354 .expect("path DID")
355 .as_str(),
356 "https://127.0.0.1:4100/agents/one/did.json"
357 );
358 }
359
360 #[test]
361 fn resolves_the_selected_public_key() {
362 let key = block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
363 algorithm: SigningAlgorithm::EdDsa,
364 allow_insecure_loopback: false,
365 did: "did:web:agent.example",
366 key_id: "did:web:agent.example#key-1",
367 transport: &StubTransport,
368 }))
369 .expect("resolved key");
370 assert_eq!(key.algorithm(), SigningAlgorithm::EdDsa);
371 }
372
373 #[test]
374 fn rejects_invalid_identifiers_and_redirects() {
375 assert!(did_web_document_url("did:key:one").is_err());
376 assert!(did_web_document_url("did:web:").is_err());
377 assert!(
378 block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
379 algorithm: SigningAlgorithm::EdDsa,
380 allow_insecure_loopback: false,
381 did: "did:web:agent.example",
382 key_id: "did:web:other.example#key-1",
383 transport: &StubTransport,
384 },))
385 .is_err()
386 );
387 assert!(
388 block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
389 algorithm: SigningAlgorithm::EdDsa,
390 allow_insecure_loopback: false,
391 did: "did:web:agent.example",
392 key_id: "did:web:agent.example#key-1",
393 transport: &RedirectTransport,
394 },))
395 .is_err()
396 );
397 }
398}