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 let authority = Url::parse(&format!("https://{host}"))?;
42 if authority.host_str().is_none()
43 || !authority.username().is_empty()
44 || authority.password().is_some()
45 || authority.path() != "/"
46 || authority.query().is_some()
47 || authority.fragment().is_some()
48 {
49 return Err(CoreError::Invalid(format!("invalid did:web host: {host}")));
50 }
51 let scheme = if options.allow_insecure_loopback
52 && authority
53 .host_str()
54 .is_some_and(crate::openapi::is_loopback_host)
55 {
56 "http"
57 } else {
58 "https"
59 };
60 let decoded_path = parts
61 .map(|part| decode_component(part, "did:web path"))
62 .collect::<Result<Vec<_>, _>>()?;
63 let path = if decoded_path.is_empty() {
64 "/.well-known/did.json".to_owned()
65 } else {
66 format!("/{}/did.json", decoded_path.join("/"))
67 };
68 Url::parse(&format!("{scheme}://{host}{path}")).map_err(CoreError::from)
69}
70
71pub async fn resolve_did_web_public_key(
72 options: ResolveDidWebPublicKeyOptions<'_>,
73) -> Result<ClientAssertionVerifyingKey, CoreError> {
74 if options.key_id.is_empty() {
75 return Err(CoreError::Invalid(
76 "AEP did:web key ID is required".to_owned(),
77 ));
78 }
79 let key_did = options
80 .key_id
81 .split_once('#')
82 .map_or(options.key_id, |part| part.0);
83 if key_did != options.did {
84 return Err(CoreError::Invalid(
85 "AEP did:web key ID does not identify the assertion issuer".to_owned(),
86 ));
87 }
88 let document_url = did_web_document_url_with_options(
89 options.did,
90 DidWebDocumentUrlOptions {
91 allow_insecure_loopback: options.allow_insecure_loopback,
92 },
93 )?;
94 let mut headers = HeaderMap::new();
95 headers.insert(
96 http::header::ACCEPT,
97 HeaderValue::from_static("application/did+json, application/json"),
98 );
99 let response = options
100 .transport
101 .send(HttpRequest {
102 method: Method::GET,
103 url: document_url.clone(),
104 headers,
105 body: Vec::new(),
106 })
107 .await?;
108 if response.final_url != document_url {
109 return Err(CoreError::Invalid(
110 "did:web document redirects are not allowed".to_owned(),
111 ));
112 }
113 if !response.status.is_success() {
114 return Err(CoreError::Invalid(format!(
115 "fetch did:web document: HTTP {}",
116 response.status.as_u16()
117 )));
118 }
119 if response.body.len() > MAX_DID_DOCUMENT_BYTES {
120 return Err(CoreError::Invalid(
121 "did:web document exceeds the 1 MiB limit".to_owned(),
122 ));
123 }
124 let document = serde_json::from_slice::<DidDocument>(&response.body)?;
125 let method = document
126 .verification_method
127 .into_iter()
128 .find(|method| method.id == options.key_id)
129 .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
130 let key_value = method
131 .public_key_jwk
132 .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
133 validate_jwk_metadata(&key_value, options.key_id, &options.algorithm)?;
134 let key = serde_json::from_value::<jwt_compact::jwk::JsonWebKey<'static>>(key_value)?;
135 ClientAssertionVerifyingKey::from_jwk(&key, &options.algorithm)
136}
137
138fn validate_jwk_metadata(
139 value: &Value,
140 expected_key_id: &str,
141 expected_algorithm: &SigningAlgorithm,
142) -> Result<(), CoreError> {
143 let object = value.as_object().ok_or_else(|| {
144 CoreError::Invalid("AEP did:web publicKeyJwk must be an object".to_owned())
145 })?;
146 if let Some(algorithm) = object.get("alg")
147 && algorithm.as_str() != Some(expected_algorithm.as_str())
148 {
149 return Err(CoreError::Invalid(
150 "AEP did:web publicKeyJwk alg does not match the assertion".to_owned(),
151 ));
152 }
153 if let Some(key_id) = object.get("kid")
154 && key_id.as_str() != Some(expected_key_id)
155 {
156 return Err(CoreError::Invalid(
157 "AEP did:web publicKeyJwk kid does not match the verification method".to_owned(),
158 ));
159 }
160 if object.get("d").is_some() {
161 return Err(CoreError::Invalid(
162 "AEP did:web publicKeyJwk must not expose private key material".to_owned(),
163 ));
164 }
165 if let Some(key_use) = object.get("use")
166 && key_use.as_str() != Some("sig")
167 {
168 return Err(CoreError::Invalid(
169 "AEP did:web publicKeyJwk use must be sig".to_owned(),
170 ));
171 }
172 if let Some(key_operations) = object.get("key_ops")
173 && !key_operations
174 .as_array()
175 .is_some_and(|operations| operations.iter().any(|operation| operation == "verify"))
176 {
177 return Err(CoreError::Invalid(
178 "AEP did:web publicKeyJwk key_ops must allow verify".to_owned(),
179 ));
180 }
181 Ok(())
182}
183
184fn decode_component(value: &str, label: &str) -> Result<String, CoreError> {
185 percent_decode_str(value)
186 .decode_utf8()
187 .map(String::from)
188 .map_err(|error| CoreError::Invalid(format!("decode {label}: {error}")))
189}
190
191#[derive(Deserialize)]
192struct DidDocument {
193 #[serde(default, rename = "verificationMethod")]
194 verification_method: Vec<VerificationMethod>,
195}
196
197#[derive(Deserialize)]
198struct VerificationMethod {
199 id: String,
200 #[serde(rename = "publicKeyJwk")]
201 public_key_jwk: Option<Value>,
202}
203
204#[cfg(test)]
205mod tests {
206 use async_trait::async_trait;
207 use futures::executor::block_on;
208 use http::StatusCode;
209
210 use super::*;
211
212 struct StubTransport;
213
214 struct RedirectTransport;
215
216 #[async_trait]
217 impl HttpTransport for StubTransport {
218 async fn send(
219 &self,
220 request: HttpRequest,
221 ) -> Result<crate::HttpResponse, crate::TransportError> {
222 Ok(crate::HttpResponse {
223 status: StatusCode::OK,
224 final_url: request.url,
225 headers: HeaderMap::new(),
226 body: br#"{
227 "verificationMethod": [{
228 "id": "did:web:agent.example#key-1",
229 "publicKeyJwk": {
230 "alg": "EdDSA",
231 "crv": "Ed25519",
232 "kid": "did:web:agent.example#key-1",
233 "kty": "OKP",
234 "use": "sig",
235 "x": "2-Jj2UvNCvQiUPNYRgSi0cJSPiJI6Rs6D0UTeEpQVj8"
236 }
237 }]
238 }"#
239 .to_vec(),
240 })
241 }
242 }
243
244 #[async_trait]
245 impl HttpTransport for RedirectTransport {
246 async fn send(
247 &self,
248 mut request: HttpRequest,
249 ) -> Result<crate::HttpResponse, crate::TransportError> {
250 request.url.set_path("/redirected/did.json");
251 Ok(crate::HttpResponse {
252 status: StatusCode::OK,
253 final_url: request.url,
254 headers: HeaderMap::new(),
255 body: br#"{"verificationMethod":[]}"#.to_vec(),
256 })
257 }
258 }
259
260 #[test]
261 fn maps_root_and_path_dids() {
262 assert_eq!(
263 did_web_document_url("did:web:api.example.com")
264 .expect("root DID")
265 .as_str(),
266 "https://api.example.com/.well-known/did.json"
267 );
268 assert_eq!(
269 did_web_document_url("did:web:127.0.0.1%3A4100:agents:one")
270 .expect("path DID")
271 .as_str(),
272 "https://127.0.0.1:4100/agents/one/did.json"
273 );
274 }
275
276 #[test]
277 fn resolves_the_selected_public_key() {
278 let key = block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
279 algorithm: SigningAlgorithm::EdDsa,
280 allow_insecure_loopback: false,
281 did: "did:web:agent.example",
282 key_id: "did:web:agent.example#key-1",
283 transport: &StubTransport,
284 }))
285 .expect("resolved key");
286 assert_eq!(key.algorithm(), SigningAlgorithm::EdDsa);
287 }
288
289 #[test]
290 fn rejects_invalid_identifiers_and_redirects() {
291 assert!(did_web_document_url("did:key:one").is_err());
292 assert!(did_web_document_url("did:web:").is_err());
293 assert!(
294 block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
295 algorithm: SigningAlgorithm::EdDsa,
296 allow_insecure_loopback: false,
297 did: "did:web:agent.example",
298 key_id: "did:web:other.example#key-1",
299 transport: &StubTransport,
300 },))
301 .is_err()
302 );
303 assert!(
304 block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
305 algorithm: SigningAlgorithm::EdDsa,
306 allow_insecure_loopback: false,
307 did: "did:web:agent.example",
308 key_id: "did:web:agent.example#key-1",
309 transport: &RedirectTransport,
310 },))
311 .is_err()
312 );
313 }
314}