aauth 0.0.2

Rust implementation of the AAuth authorization protocol
Documentation
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use ed25519_dalek::{Signature, Verifier, VerifyingKey};
use http::HeaderMap;

use crate::error::{AAuthError, Result};
use crate::jwt::{VerifiedToken, jwk_thumbprint};

/// Result of verifying an incoming HTTP Signature on a request.
#[derive(Debug, Clone)]
pub struct VerifiedSignature {
    pub jwt: String,
    pub thumbprint: String,
}

pub fn build_signature_base(
    method: &str,
    authority: &str,
    path: &str,
    signature_key: &str,
    created: u64,
) -> String {
    format!(
        "\"@method\": {}\n\"@authority\": {}\n\"@path\": {}\n\"signature-key\": {}\n\"@signature-params\": (\"@method\" \"@authority\" \"@path\" \"signature-key\");created={created}",
        method.to_lowercase(),
        authority,
        path,
        signature_key,
    )
}

pub fn parse_signature_key_jwt(headers: &HeaderMap) -> Result<String> {
    let header = header_value(headers, "signature-key")
        .ok_or_else(|| AAuthError::Message("Missing signature-key header".into()))?;
    let start = header
        .find("jwt=\"")
        .ok_or_else(|| AAuthError::Message("signature-key missing jwt parameter".into()))?
        + 5;
    let rest = &header[start..];
    let end = rest
        .find('"')
        .ok_or_else(|| AAuthError::Message("signature-key jwt not quoted".into()))?;
    Ok(rest[..end].to_string())
}

pub fn parse_signature_created(signature_input: &str) -> Result<u64> {
    let created = signature_input
        .split("created=")
        .nth(1)
        .ok_or_else(|| AAuthError::Message("signature-input missing created".into()))?
        .split(';')
        .next()
        .unwrap_or_default()
        .trim();
    created
        .parse()
        .map_err(|e| AAuthError::Message(format!("invalid signature-input created: {e}")))
}

pub fn parse_signature_value(signature: &str) -> Result<Vec<u8>> {
    let value = signature
        .strip_prefix("sig=:")
        .and_then(|rest| rest.strip_suffix(':'))
        .ok_or_else(|| AAuthError::Message("invalid signature header format".into()))?;
    URL_SAFE_NO_PAD
        .decode(value)
        .map_err(|e| AAuthError::Message(e.to_string()))
}

pub fn verify_request_signature(
    method: &str,
    authority: &str,
    path: &str,
    headers: &HeaderMap,
) -> Result<VerifiedSignature> {
    let signature_key = header_value(headers, "signature-key")
        .ok_or_else(|| AAuthError::Message("Missing signature-key header".into()))?
        .to_string();
    let signature_input = header_value(headers, "signature-input")
        .ok_or_else(|| AAuthError::Message("Missing signature-input header".into()))?
        .to_string();
    let signature_header = header_value(headers, "signature")
        .ok_or_else(|| AAuthError::Message("Missing signature header".into()))?
        .to_string();

    let jwt = parse_signature_key_jwt(headers)?;
    let claims = VerifiedToken::decode_unverified(&jwt)?;
    let cnf_jwk = claims.cnf_jwk();
    let thumbprint = jwk_thumbprint(cnf_jwk)?;

    let created = parse_signature_created(&signature_input)?;
    let signature_base = build_signature_base(method, authority, path, &signature_key, created);
    let signature_bytes = parse_signature_value(&signature_header)?;
    let verifying_key = verifying_key_from_jwk(cnf_jwk)?;
    let signature =
        Signature::from_slice(&signature_bytes).map_err(|e| AAuthError::Message(e.to_string()))?;

    verifying_key
        .verify(signature_base.as_bytes(), &signature)
        .map_err(|_| AAuthError::Message("HTTP signature verification failed".into()))?;

    Ok(VerifiedSignature { jwt, thumbprint })
}

fn verifying_key_from_jwk(jwk: &crate::jwt::OkpJwk) -> Result<VerifyingKey> {
    let bytes = URL_SAFE_NO_PAD
        .decode(&jwk.x)
        .map_err(|e| AAuthError::Message(e.to_string()))?;
    let key_bytes: [u8; 32] = bytes
        .try_into()
        .map_err(|_| AAuthError::Message("invalid Ed25519 public key length".into()))?;
    VerifyingKey::from_bytes(&key_bytes).map_err(|e| AAuthError::Message(e.to_string()))
}

fn header_value<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
    headers.get(name).and_then(|v| v.to_str().ok()).or_else(|| {
        headers.iter().find_map(|(k, v)| {
            if k.as_str().eq_ignore_ascii_case(name) {
                v.to_str().ok()
            } else {
                None
            }
        })
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn signature_base_includes_components() {
        let base = build_signature_base(
            "GET",
            "resource.example",
            "/api/data",
            "sig=jwt;jwt=\"abc\"",
            1,
        );
        assert!(base.contains("@method"));
        assert!(base.contains("@authority"));
        assert!(base.contains("@path"));
        assert!(base.contains("signature-key"));
    }

    #[test]
    fn parse_signature_key_jwt_from_header() {
        let mut headers = HeaderMap::new();
        headers.insert(
            "signature-key",
            "sig=jwt;jwt=\"eyJhbGciOiJIUzI1NiJ9.test\"".parse().unwrap(),
        );
        let jwt = parse_signature_key_jwt(&headers).unwrap();
        assert_eq!(jwt, "eyJhbGciOiJIUzI1NiJ9.test");
    }

    #[cfg(feature = "client-reqwest")]
    #[tokio::test]
    async fn sign_request_verify_roundtrip() {
        use crate::client::reqwest::signed::sign_request;
        use crate::{create_key_provider, create_test_keys, mint_agent_jwt};

        let keys = create_test_keys();
        let agent_url = "http://127.0.0.1";
        let agent_jwt = mint_agent_jwt(&keys, agent_url, "aauth:test@example.com", None);
        let provider = create_key_provider(&keys, agent_jwt);
        let material = provider.key_material().await.unwrap();

        let url = format!("{agent_url}/api/data");
        let mut req = reqwest::Client::new().get(&url).build().unwrap();
        sign_request(&mut req, &material).unwrap();

        let headers = req.headers().clone();
        let verified = verify_request_signature(
            req.method().as_str(),
            req.url().authority(),
            req.url().path(),
            &headers,
        )
        .unwrap();

        assert_eq!(verified.thumbprint, keys.agent_ephemeral.thumbprint());
    }
}