Skip to main content

aep_core/
did_web.rs

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    // The host is checked before the URL parser sees it. The parser silently drops tabs and line
42    // breaks and reinterprets user information, a path, a query, or a fragment, so a value it
43    // rewrote would resolve to a document the identifier never named.
44    if !is_did_web_host(&host) {
45        return Err(CoreError::Invalid(format!("invalid did:web host: {host}")));
46    }
47    // Parsing the authority on its own normalises the host for the loopback comparison below and
48    // rejects a bracketed literal that is not a well-formed IPv6 address.
49    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            // DWB-RES-001 maps `did:web:<host>:<path>` onto `https://<host>/<path>/did.json`. A
63            // segment decoding to a separator, a query, a fragment, or a dot segment leaves that
64            // path, so the identifier would resolve to a different document than it names.
65            if !is_did_web_path_segment(&segment) {
66                return Err(CoreError::Invalid(format!(
67                    "invalid did:web path segment: {did}"
68                )));
69            }
70            // Preserve escapes so URL parsing cannot decode a second time into a dot segment.
71            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
200/// A host, optionally with a port, as a `did:web` identifier percent-decodes to.
201fn is_did_web_host(value: &str) -> bool {
202    let (name, port) = if value.starts_with('[') {
203        // An IPv6 literal carries colons of its own, so only what follows the closing bracket can
204        // be a port.
205        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
244/// One path segment of a `did:web` identifier after percent-decoding.
245fn 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}