aep-core 0.1.1

Transport-independent protocol primitives for the Agent Enrollment Protocol.
Documentation
use http::{HeaderMap, HeaderValue, Method};
use percent_encoding::percent_decode_str;
use serde::Deserialize;
use serde_json::Value;
use url::Url;

use crate::{ClientAssertionVerifyingKey, CoreError, HttpRequest, HttpTransport, SigningAlgorithm};

const MAX_DID_DOCUMENT_BYTES: usize = 1 << 20;

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct DidWebDocumentUrlOptions {
    pub allow_insecure_loopback: bool,
}

pub struct ResolveDidWebPublicKeyOptions<'a> {
    pub algorithm: SigningAlgorithm,
    pub allow_insecure_loopback: bool,
    pub did: &'a str,
    pub key_id: &'a str,
    pub transport: &'a dyn HttpTransport,
}

pub fn did_web_document_url(did: &str) -> Result<Url, CoreError> {
    did_web_document_url_with_options(did, DidWebDocumentUrlOptions::default())
}

pub fn did_web_document_url_with_options(
    did: &str,
    options: DidWebDocumentUrlOptions,
) -> Result<Url, CoreError> {
    let Some(identifier) = did.strip_prefix("did:web:") else {
        return Err(CoreError::Invalid(format!("unsupported DID method: {did}")));
    };
    let mut parts = identifier.split(':');
    let encoded_host = parts
        .next()
        .filter(|host| !host.is_empty())
        .ok_or_else(|| CoreError::Invalid(format!("invalid did:web identifier: {did}")))?;
    let host = decode_component(encoded_host, "did:web host")?;
    let authority = Url::parse(&format!("https://{host}"))?;
    if authority.host_str().is_none()
        || !authority.username().is_empty()
        || authority.password().is_some()
        || authority.path() != "/"
        || authority.query().is_some()
        || authority.fragment().is_some()
    {
        return Err(CoreError::Invalid(format!("invalid did:web host: {host}")));
    }
    let scheme = if options.allow_insecure_loopback
        && authority
            .host_str()
            .is_some_and(crate::openapi::is_loopback_host)
    {
        "http"
    } else {
        "https"
    };
    let decoded_path = parts
        .map(|part| decode_component(part, "did:web path"))
        .collect::<Result<Vec<_>, _>>()?;
    let path = if decoded_path.is_empty() {
        "/.well-known/did.json".to_owned()
    } else {
        format!("/{}/did.json", decoded_path.join("/"))
    };
    Url::parse(&format!("{scheme}://{host}{path}")).map_err(CoreError::from)
}

pub async fn resolve_did_web_public_key(
    options: ResolveDidWebPublicKeyOptions<'_>,
) -> Result<ClientAssertionVerifyingKey, CoreError> {
    if options.key_id.is_empty() {
        return Err(CoreError::Invalid(
            "AEP did:web key ID is required".to_owned(),
        ));
    }
    let key_did = options
        .key_id
        .split_once('#')
        .map_or(options.key_id, |part| part.0);
    if key_did != options.did {
        return Err(CoreError::Invalid(
            "AEP did:web key ID does not identify the assertion issuer".to_owned(),
        ));
    }
    let document_url = did_web_document_url_with_options(
        options.did,
        DidWebDocumentUrlOptions {
            allow_insecure_loopback: options.allow_insecure_loopback,
        },
    )?;
    let mut headers = HeaderMap::new();
    headers.insert(
        http::header::ACCEPT,
        HeaderValue::from_static("application/did+json, application/json"),
    );
    let response = options
        .transport
        .send(HttpRequest {
            method: Method::GET,
            url: document_url.clone(),
            headers,
            body: Vec::new(),
        })
        .await?;
    if response.final_url != document_url {
        return Err(CoreError::Invalid(
            "did:web document redirects are not allowed".to_owned(),
        ));
    }
    if !response.status.is_success() {
        return Err(CoreError::Invalid(format!(
            "fetch did:web document: HTTP {}",
            response.status.as_u16()
        )));
    }
    if response.body.len() > MAX_DID_DOCUMENT_BYTES {
        return Err(CoreError::Invalid(
            "did:web document exceeds the 1 MiB limit".to_owned(),
        ));
    }
    let document = serde_json::from_slice::<DidDocument>(&response.body)?;
    let method = document
        .verification_method
        .into_iter()
        .find(|method| method.id == options.key_id)
        .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
    let key_value = method
        .public_key_jwk
        .ok_or_else(|| CoreError::Invalid(format!("no public JWK found for {}", options.key_id)))?;
    validate_jwk_metadata(&key_value, options.key_id, &options.algorithm)?;
    let key = serde_json::from_value::<jwt_compact::jwk::JsonWebKey<'static>>(key_value)?;
    ClientAssertionVerifyingKey::from_jwk(&key, &options.algorithm)
}

fn validate_jwk_metadata(
    value: &Value,
    expected_key_id: &str,
    expected_algorithm: &SigningAlgorithm,
) -> Result<(), CoreError> {
    let object = value.as_object().ok_or_else(|| {
        CoreError::Invalid("AEP did:web publicKeyJwk must be an object".to_owned())
    })?;
    if let Some(algorithm) = object.get("alg")
        && algorithm.as_str() != Some(expected_algorithm.as_str())
    {
        return Err(CoreError::Invalid(
            "AEP did:web publicKeyJwk alg does not match the assertion".to_owned(),
        ));
    }
    if let Some(key_id) = object.get("kid")
        && key_id.as_str() != Some(expected_key_id)
    {
        return Err(CoreError::Invalid(
            "AEP did:web publicKeyJwk kid does not match the verification method".to_owned(),
        ));
    }
    if object.get("d").is_some() {
        return Err(CoreError::Invalid(
            "AEP did:web publicKeyJwk must not expose private key material".to_owned(),
        ));
    }
    if let Some(key_use) = object.get("use")
        && key_use.as_str() != Some("sig")
    {
        return Err(CoreError::Invalid(
            "AEP did:web publicKeyJwk use must be sig".to_owned(),
        ));
    }
    if let Some(key_operations) = object.get("key_ops")
        && !key_operations
            .as_array()
            .is_some_and(|operations| operations.iter().any(|operation| operation == "verify"))
    {
        return Err(CoreError::Invalid(
            "AEP did:web publicKeyJwk key_ops must allow verify".to_owned(),
        ));
    }
    Ok(())
}

fn decode_component(value: &str, label: &str) -> Result<String, CoreError> {
    percent_decode_str(value)
        .decode_utf8()
        .map(String::from)
        .map_err(|error| CoreError::Invalid(format!("decode {label}: {error}")))
}

#[derive(Deserialize)]
struct DidDocument {
    #[serde(default, rename = "verificationMethod")]
    verification_method: Vec<VerificationMethod>,
}

#[derive(Deserialize)]
struct VerificationMethod {
    id: String,
    #[serde(rename = "publicKeyJwk")]
    public_key_jwk: Option<Value>,
}

#[cfg(test)]
mod tests {
    use async_trait::async_trait;
    use futures::executor::block_on;
    use http::StatusCode;

    use super::*;

    struct StubTransport;

    struct RedirectTransport;

    #[async_trait]
    impl HttpTransport for StubTransport {
        async fn send(
            &self,
            request: HttpRequest,
        ) -> Result<crate::HttpResponse, crate::TransportError> {
            Ok(crate::HttpResponse {
                status: StatusCode::OK,
                final_url: request.url,
                headers: HeaderMap::new(),
                body: br#"{
                    "verificationMethod": [{
                        "id": "did:web:agent.example#key-1",
                        "publicKeyJwk": {
                            "alg": "EdDSA",
                            "crv": "Ed25519",
                            "kid": "did:web:agent.example#key-1",
                            "kty": "OKP",
                            "use": "sig",
                            "x": "2-Jj2UvNCvQiUPNYRgSi0cJSPiJI6Rs6D0UTeEpQVj8"
                        }
                    }]
                }"#
                .to_vec(),
            })
        }
    }

    #[async_trait]
    impl HttpTransport for RedirectTransport {
        async fn send(
            &self,
            mut request: HttpRequest,
        ) -> Result<crate::HttpResponse, crate::TransportError> {
            request.url.set_path("/redirected/did.json");
            Ok(crate::HttpResponse {
                status: StatusCode::OK,
                final_url: request.url,
                headers: HeaderMap::new(),
                body: br#"{"verificationMethod":[]}"#.to_vec(),
            })
        }
    }

    #[test]
    fn maps_root_and_path_dids() {
        assert_eq!(
            did_web_document_url("did:web:api.example.com")
                .expect("root DID")
                .as_str(),
            "https://api.example.com/.well-known/did.json"
        );
        assert_eq!(
            did_web_document_url("did:web:127.0.0.1%3A4100:agents:one")
                .expect("path DID")
                .as_str(),
            "https://127.0.0.1:4100/agents/one/did.json"
        );
    }

    #[test]
    fn resolves_the_selected_public_key() {
        let key = block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
            algorithm: SigningAlgorithm::EdDsa,
            allow_insecure_loopback: false,
            did: "did:web:agent.example",
            key_id: "did:web:agent.example#key-1",
            transport: &StubTransport,
        }))
        .expect("resolved key");
        assert_eq!(key.algorithm(), SigningAlgorithm::EdDsa);
    }

    #[test]
    fn rejects_invalid_identifiers_and_redirects() {
        assert!(did_web_document_url("did:key:one").is_err());
        assert!(did_web_document_url("did:web:").is_err());
        assert!(
            block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
                algorithm: SigningAlgorithm::EdDsa,
                allow_insecure_loopback: false,
                did: "did:web:agent.example",
                key_id: "did:web:other.example#key-1",
                transport: &StubTransport,
            },))
            .is_err()
        );
        assert!(
            block_on(resolve_did_web_public_key(ResolveDidWebPublicKeyOptions {
                algorithm: SigningAlgorithm::EdDsa,
                allow_insecure_loopback: false,
                did: "did:web:agent.example",
                key_id: "did:web:agent.example#key-1",
                transport: &RedirectTransport,
            },))
            .is_err()
        );
    }
}