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    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}