Skip to content

AAuth: HTTP Message Signature verification (RFC 9421 + draft-hardt-httpbis-signature-key)#2276

Draft
christian-posta wants to merge 5 commits into
agentgateway:mainfrom
christian-posta:ceposta-aauth
Draft

AAuth: HTTP Message Signature verification (RFC 9421 + draft-hardt-httpbis-signature-key)#2276
christian-posta wants to merge 5 commits into
agentgateway:mainfrom
christian-posta:ceposta-aauth

Conversation

@christian-posta

Copy link
Copy Markdown
Contributor

Summary

Adds AAuth (HTTP Message Signing) verification to agentgateway as a route- and gateway-level
traffic policy.

This is the verifier half of the
AAuth protocol draft
inbound requests carrying RFC 9421 HTTP Message Signatures are verified against keys conveyed
via the Signature-Key draft.
Three schemes are supported:

Scheme Strength Key resolution
hwk Pseudonymous inline JWK in the Signature-Key header
jwks_uri Identified discover via {iss}/.well-known/{dwk}jwks_uri → JWKS
jwt Authorized aa-agent+jwt or aa-auth+jwt; signing key extracted from cnf.jwk

Verified claims (scheme, agent, agent_delegate, user, scope, thumbprint, jwt_claims) land in
request extensions and are exposed to CEL as aauth.* for downstream authorization.

Layout

  • crates/http-message-sig/ — new crate implementing RFC 9421 + RFC 9530 + the signature-key
    draft. Ed25519-only signing; JWK type accepts OKP/RSA/EC for use by higher layers.
  • crates/aauth/ — new crate validating aa-agent+jwt and aa-auth+jwt tokens; built on
    http-message-sig.
  • crates/agentgateway/src/http/aauth.rs (+ tests) — the policy module that ties everything
    together; implements RequestPolicyTrait.
  • Standard wiring: TrafficPolicy::AAuth variant, RoutePolicies/GatewayPolicies fields,
    OutboundCallSubtype::AAuth for JWKS-fetch telemetry, AAuthClaims plumbed into the CEL
    Executor/RequestSnapshot, aauth.scheme/aauth.agent log fields.
  • examples/aauth/ — minimal config + README that pairs with the Go reference's agent-client
    for end-to-end smoke tests.

Notable design choices

  • JWKS cache keyed by (id, dwk) so a single issuer publishing both aauth-agent.json and
    aauth-issuer.json doesn't alias the two key sets.
  • Single-flight on JWKS cache misses (per-key tokio::sync::Mutex<()>) so N concurrent
    cold requests fan out to one network fetch.
  • metadata.jwks_uri is HTTPS-only; under allowInsecureHttpIssuer, plaintext is
    loopback-only (localhost, 127.0.0.0/8, ::1). A compromised metadata document can
    otherwise inject http://attacker/jwks and downgrade transport for the actual signing keys.
  • created is required per RFC 9421 §4.1 — the parser rejects its absence rather than
    silently defaulting to 0, which would render the freshness check meaningless on misconfigured
    deployments.
  • @authority includes port on both sign and verify so https://example.com:8443/...
    round-trips correctly.
  • scheme=jwks is rejected; only the current jwks_uri is accepted.

Testing

  • 40 unit tests in http-message-sig + 11 cross-impl test vectors (lifted from the Go
    reference's vectors file).
  • 10 unit tests in aauth.
  • 20 integration tests in agentgateway::http::aauth::tests covering modes, scheme
    ordering, tamper rejection, deserialization, cache behaviors, jwks_uri validation.
  • End-to-end verified against the Go reference at
    https://github.com/christian-posta/extauth-aauth-resource: sign-request CLI for hwk,
    agent-client for jwks_uri and aa-agent+jwt, tamper cases, mode behaviors.

Limitations / follow-ups

  • Content-Digest body re-verification is not performed inside the gateway. The signed
    digest header is verified (signature covers it), but the request body itself is not
    re-hashed. Backends that care about body integrity should re-verify, or sit behind a
    buffer policy. Worth a separate PR to integrate with the existing buffer infrastructure.
  • Header-snapshot perf: apply_inner clones the entire HeaderMap into a
    HashMap<String, String> because the verifier API takes the latter. Real cost but
    changing verify_signature's signature ripples through the crate's public API; deferred.
  • Two JWK types in the workspace: http_message_sig::keys::jwk::JWK (custom) and
    jsonwebtoken::jwk::Jwk (used by http/jwt.rs). Consolidation is a larger reshape and
    deserves its own PR.

Test plan

  • cargo test -p http-message-sig -p aauth (60 tests)
  • cargo test -p agentgateway --lib http::aauth (20 tests)
  • cargo clippy --all-targets -- -D warnings (clean)
  • make generate-schema check-clean-repo (clean)
  • End-to-end against the Go reference (all three schemes + tamper cases)
  • Maintainer review

🤖 Generated with Claude Code

@christian-posta

Copy link
Copy Markdown
Contributor Author

@copilot review

christian-posta and others added 4 commits June 22, 2026 10:06
Adds the http-message-sig crate, a self-contained implementation of
RFC 9421 (HTTP Message Signatures), RFC 9530 (Content-Digest), and
draft-hardt-httpbis-signature-key (the Signature-Key header with
hwk, jwks_uri, and jwt schemes). Ed25519 only for the signing side;
the JWK type accepts OKP, RSA, and EC for use by higher layers that
consume the JWKS of an arbitrary issuer.

Key design points:
- The verifier rebuilds the @signature-params line byte-for-byte from
  the raw Signature-Input header via build_signature_base_raw. RFC
  9421 §2.5 requires the signature base be reproduced exactly;
  reconstructing from parsed fields silently reorders parameters and
  breaks Ed25519 verification against signers that emit them in a
  different order than ours.
- The signer's @authority includes the URL port when present, matching
  the verifier's authority construction so sign+verify agree on
  https://example.com:8443/...
- Unsupported signature-key schemes are rejected before key resolution
  to avoid masking the underlying rejection with a key-fetch error.
- created is required per RFC 9421 §4.1; absence is rejected rather
  than silently defaulting to 0.

Tested against cross-implementation vectors (lifted from the Go
reference at https://github.com/christian-posta/extauth-aauth-resource)
plus 39 unit tests covering signing/verification round-trips, parser
edge cases, and content-digest computation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds the aauth crate built on top of http-message-sig. Validates
aa-agent+jwt and aa-auth+jwt tokens per draft-hardt-oauth-aauth-
protocol: extracts and verifies the JWT, checks required claims, and
returns the embedded cnf.jwk for HTTP signature verification.

Key behaviors:
- The issuer URL check (is_acceptable_jwt_issuer_url) enforces
  host-only HTTPS for production. The dev-only http branch is
  loopback-restricted (localhost, 127.0.0.0/8, ::1) so the dev flag
  cannot be exploited to point at an external HTTP host.
- get_scopes() returns None when the scope claim is empty or
  whitespace-only, so the 'must have at least one of sub or scope'
  guard in validate_auth_token is not bypassed by Some(vec![]).
- validate_agent_token strictly requires aud when the caller passes
  Some(expected_audience) (mirroring auth-token semantics); the gateway
  passes None for agent tokens because they are identity assertions,
  not resource-scoped grants.
- jti is captured when present but not required — agentgateway does
  not maintain a replay cache, and reference implementations omit it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds the AAuth policy as a route- and gateway-level traffic policy.

Policy behavior:
- Three signature-key schemes accepted: hwk (pseudonymous, key inline),
  jwks_uri (identified, key discovered via well-known doc + JWKS), and
  jwt (authorized, key bound to an aa-agent+jwt or aa-auth+jwt via
  RFC 7800 cnf.jwk).
- Modes Strict / Optional / Permissive control how missing or invalid
  signatures are handled.
- requiredScheme enforces a minimum scheme strength (hwk < jwks_uri <
  jwt); stronger schemes always satisfy weaker requirements.
  Insufficient-scheme rejections include an aauth challenge response
  header.
- Verified claims (scheme, agent, agent_delegate, user, scope,
  thumbprint, jwt_claims) are injected into request extensions and
  exposed to CEL as aauth.* for downstream authorization rules.

JWKS cache:
- Keyed by (issuer_id, dwk) so a single issuer can legitimately publish
  multiple discovery documents (e.g. aauth-agent.json and
  aauth-issuer.json) without one's keys aliasing the other's.
- Single-flight via per-key tokio::sync::Mutex<()> so N concurrent
  misses on the same issuer fan out to one network fetch, not N.
- Lazy eviction on stale-read upgrades to a write lock and removes
  expired entries so the map doesn't grow unbounded across rotating
  issuers.

Network-egress safety:
- metadata.jwks_uri from the well-known doc is validated to be HTTPS
  before fetching; a compromised CDN or issuer can otherwise inject
  http://attacker/jwks and downgrade transport for signing keys.
- Under allowInsecureHttpIssuer, plaintext JWKS fetches are accepted
  only when the host is a loopback address.

Tests: 20 policy-level + 11 cross-impl test vectors. End-to-end verified
against the Go reference at github.com/christian-posta/extauth-aauth-
resource (sign-request CLI for hwk, agent-client for jwks_uri and
aa-agent+jwt, tamper-rejection cases, mode behaviors).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Posta <christian.posta@gmail.com>
Adds an example config under examples/aauth/ pairing with the Go
reference's agent-client for end-to-end smoke tests, and the
auto-regenerated schema/config.json and schema/config.md output
covering the new policy fields.

The Mode and RequiredScheme enums are renamed to AAuthMode and
AAuthRequiredScheme in the generated schema (via schemars(rename)) so
the JSON Schema $defs use descriptive names instead of schemars'
auto-deduplicated Mode2 suffix.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Signed-off-by: Christian Posta <christian.posta@gmail.com>
@christian-posta

Copy link
Copy Markdown
Contributor Author

Force-pushed with Signed-off-by on all commits and the lint/example fixes folded in. CI rerunning now. @copilot review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant