diff --git a/README.md b/README.md index 958d2ee..cf2cf5d 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,11 @@ request. Unlike the Python library, the caller's header map is never mutated. (plus `@query` when a query string is present). Body signing is opt-in via `additionalComponents(List.of("content-digest"))`. +> **HTTP client defaults:** every HTTP client the library constructs itself (JWKS/metadata +> fetching, token exchange) is pinned to **HTTP/1.1** — the JDK's default h2c upgrade breaks +> h11-based servers (uvicorn/FastAPI person servers reject requests or drop bodies). +> Clients you inject yourself are used as-is. + ## Signature verification ```java @@ -145,6 +150,9 @@ RequestVerifier.Result result = verifier.verifyRequest( if (result.valid()) { System.out.println("Agent: " + result.agentId() + ", Scopes: " + result.scopes()); } +// When a request carries both a Content-Digest header and a body, verifyRequest recomputes +// the RFC 9530 digest from the body and rejects mismatches ("content-digest mismatch") — +// stricter than the Python reference, which only verifies the header value. // Resource-side challenge building (401 responses) ChallengeBuilder challenges = new ChallengeBuilder( diff --git a/aauth/src/main/java/io/github/marcofanti/aauth/agent/TokenExchange.java b/aauth/src/main/java/io/github/marcofanti/aauth/agent/TokenExchange.java index 7be4684..032fbc2 100644 --- a/aauth/src/main/java/io/github/marcofanti/aauth/agent/TokenExchange.java +++ b/aauth/src/main/java/io/github/marcofanti/aauth/agent/TokenExchange.java @@ -53,7 +53,9 @@ public static String extractResourceToken(Map headers) { * @param resourceToken the resource token from the 401 challenge * @param keyPair the agent's signing key pair * @param agentJwt the agent token ({@code aa-agent+jwt}) for the Signature-Key header - * @param httpClient HTTP client; {@code null} uses a default with a 30s timeout + * @param httpClient HTTP client; {@code null} uses a default with a 30s timeout pinned to + * HTTP/1.1 (the JDK's h2c upgrade breaks h11-based person servers such as + * uvicorn/FastAPI). Caller-injected clients are used as-is. * @param onInteraction callback invoked with (interactionUrl, code) when the PS requires * human interaction * @param onClarification callback invoked with (pendingUrl, question); returns the answer @@ -76,6 +78,7 @@ public record Exchange( } httpClient = httpClient == null ? HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(30)) .build() : httpClient; diff --git a/aauth/src/main/java/io/github/marcofanti/aauth/keys/CachingJwksFetcher.java b/aauth/src/main/java/io/github/marcofanti/aauth/keys/CachingJwksFetcher.java index b941f48..72dfa73 100644 --- a/aauth/src/main/java/io/github/marcofanti/aauth/keys/CachingJwksFetcher.java +++ b/aauth/src/main/java/io/github/marcofanti/aauth/keys/CachingJwksFetcher.java @@ -27,7 +27,10 @@ public final class CachingJwksFetcher { private final Clock clock; private final ConcurrentHashMap lastFetchTimes = new ConcurrentHashMap<>(); - /** Creates a fetcher with the JDK HTTP client, 1-hour cache TTL, 60-second rate limit. */ + /** + * Creates a fetcher with the JDK HTTP client (pinned to HTTP/1.1 for h11-server + * compatibility — see {@link DefaultHttpClient}), 1-hour cache TTL, 60-second rate limit. + */ public CachingJwksFetcher() { this(new DefaultHttpClient(), new JwksCache(), 60, Clock.systemUTC()); } diff --git a/aauth/src/main/java/io/github/marcofanti/aauth/keys/DefaultHttpClient.java b/aauth/src/main/java/io/github/marcofanti/aauth/keys/DefaultHttpClient.java index e8c7fb4..7c741ac 100644 --- a/aauth/src/main/java/io/github/marcofanti/aauth/keys/DefaultHttpClient.java +++ b/aauth/src/main/java/io/github/marcofanti/aauth/keys/DefaultHttpClient.java @@ -11,7 +11,14 @@ import java.time.Duration; import java.util.Map; -/** JDK {@link HttpClient}-based JSON fetcher with a 10-second timeout. */ +/** + * JDK {@link HttpClient}-based JSON fetcher with a 10-second timeout. + * + *

The default client is pinned to HTTP/1.1: the JDK's default HTTP/2 h2c upgrade breaks + * h11-based servers (uvicorn/FastAPI reject the request or silently drop the body), which AAuth + * person servers commonly run on. Inject a client via {@link #DefaultHttpClient(HttpClient)} to + * override. + */ public final class DefaultHttpClient implements JsonHttpClient { private static final ObjectMapper MAPPER = new ObjectMapper(); @@ -21,11 +28,17 @@ public final class DefaultHttpClient implements JsonHttpClient { public DefaultHttpClient() { this(HttpClient.newBuilder() + .version(HttpClient.Version.HTTP_1_1) .connectTimeout(Duration.ofSeconds(10)) .followRedirects(HttpClient.Redirect.NORMAL) .build()); } + /** The underlying client (exposed for configuration assertions). */ + HttpClient httpClient() { + return httpClient; + } + public DefaultHttpClient(HttpClient httpClient) { this.httpClient = httpClient; } diff --git a/aauth/src/main/java/io/github/marcofanti/aauth/metadata/Metadata.java b/aauth/src/main/java/io/github/marcofanti/aauth/metadata/Metadata.java index 039653c..9a43c67 100644 --- a/aauth/src/main/java/io/github/marcofanti/aauth/metadata/Metadata.java +++ b/aauth/src/main/java/io/github/marcofanti/aauth/metadata/Metadata.java @@ -99,6 +99,9 @@ public static Doc personServer(String personServer, String tokenEndpoint, String /** * Fetches a metadata document over HTTPS (HTTP allowed only for localhost development). * + *

When {@code httpClient} is {@code null}, the default client is pinned to HTTP/1.1 for + * h11-server compatibility — see {@link DefaultHttpClient}. + * * @throws IllegalArgumentException if the URL is non-HTTPS and not localhost * @throws MetadataException if the fetch fails */ diff --git a/aauth/src/main/java/io/github/marcofanti/aauth/resource/RequestVerifier.java b/aauth/src/main/java/io/github/marcofanti/aauth/resource/RequestVerifier.java index 46de8bf..6118b7e 100644 --- a/aauth/src/main/java/io/github/marcofanti/aauth/resource/RequestVerifier.java +++ b/aauth/src/main/java/io/github/marcofanti/aauth/resource/RequestVerifier.java @@ -3,6 +3,7 @@ import io.github.marcofanti.aauth.signing.HttpSignatureException; import io.github.marcofanti.aauth.signing.JwksFetcher; import io.github.marcofanti.aauth.signing.Jwts; +import io.github.marcofanti.aauth.signing.SignatureBase; import io.github.marcofanti.aauth.signing.SignatureKeyHeader; import io.github.marcofanti.aauth.signing.SignatureVerifier; import io.github.marcofanti.aauth.signing.VerifyRequest; @@ -42,6 +43,10 @@ static Result failure(String error) { /** * Verifies an incoming request's HTTP signature and extracts identity/authorization context. * + *

When the request carries both a {@code Content-Digest} header and a body, the digest is + * recomputed from the body per RFC 9530 and the request is rejected on mismatch — unlike the + * Python reference, which only signs/verifies the header value. + * * @param method HTTP method * @param targetUri target URI as received * @param headers request headers (must include the three signature headers) @@ -89,6 +94,17 @@ public Result verifyRequest( return Result.failure(e.getMessage()); } + // RFC 9530 body-digest enforcement: the signature base covers the Content-Digest + // *header*, not the body itself, so a tampered body with an intact header would pass + // signature verification. Recompute whenever both header and body are present. + // Intentional divergence from the Python reference, which trusts the header. + String contentDigest = header(headers, "Content-Digest"); + if (contentDigest != null && body != null && body.length > 0) { + if (!contentDigest.strip().equals(SignatureBase.contentDigest(body))) { + return Result.failure("content-digest mismatch"); + } + } + String agentId = null; Map act = null; String userSub = null; diff --git a/aauth/src/test/java/io/github/marcofanti/aauth/keys/Http11DefaultTest.java b/aauth/src/test/java/io/github/marcofanti/aauth/keys/Http11DefaultTest.java new file mode 100644 index 0000000..5e6e1ce --- /dev/null +++ b/aauth/src/test/java/io/github/marcofanti/aauth/keys/Http11DefaultTest.java @@ -0,0 +1,43 @@ +package io.github.marcofanti.aauth.keys; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.marcofanti.aauth.agent.TokenExchange; +import io.github.marcofanti.aauth.signing.keys.KeyPairs; +import java.net.http.HttpClient; +import org.junit.jupiter.api.Test; + +/** + * Library-constructed default HTTP clients must be pinned to HTTP/1.1: the JDK's h2c upgrade + * breaks h11-based servers (uvicorn/FastAPI person servers reject requests or drop bodies). + */ +class Http11DefaultTest { + + @Test + void defaultJsonHttpClientIsHttp11() { + assertThat(new DefaultHttpClient().httpClient().version()).isEqualTo(HttpClient.Version.HTTP_1_1); + } + + @Test + void tokenExchangeDefaultClientIsHttp11() { + TokenExchange.Exchange exchange = TokenExchange.Exchange.builder( + "a.b.c", KeyPairs.generateEd25519(), "agent.jwt") + .build(); + + assertThat(exchange.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_1_1); + } + + @Test + void callerInjectedClientIsUntouched() { + HttpClient http2Client = + HttpClient.newBuilder().version(HttpClient.Version.HTTP_2).build(); + + TokenExchange.Exchange exchange = TokenExchange.Exchange.builder( + "a.b.c", KeyPairs.generateEd25519(), "agent.jwt") + .httpClient(http2Client) + .build(); + + assertThat(exchange.httpClient()).isSameAs(http2Client); + assertThat(exchange.httpClient().version()).isEqualTo(HttpClient.Version.HTTP_2); + } +} diff --git a/aauth/src/test/java/io/github/marcofanti/aauth/resource/ResourceRoleTest.java b/aauth/src/test/java/io/github/marcofanti/aauth/resource/ResourceRoleTest.java index 606ed44..dc3cc70 100644 --- a/aauth/src/test/java/io/github/marcofanti/aauth/resource/ResourceRoleTest.java +++ b/aauth/src/test/java/io/github/marcofanti/aauth/resource/ResourceRoleTest.java @@ -157,6 +157,52 @@ void scopeSplittingCollapsesWhitespace() { assertThat(result.scopes()).containsExactly("data.read", "data.write"); } + @Test + void bodyWithMatchingContentDigestPasses() { + byte[] body = "{\"amount\": 100}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + // The low-level signer covers content-digest via additionalComponents. + Map headers = withSignatureHeaders(io.github.marcofanti.aauth.signing.RequestSigner.sign( + io.github.marcofanti.aauth.signing.SignRequest.builder("POST", TARGET) + .keyPair(agentKeys) + .scheme(new io.github.marcofanti.aauth.signing.SignatureScheme.Hwk()) + .body(body) + .additionalComponents(List.of("content-digest")) + .build())); + + RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, body, false, false); + + assertThat(result.valid()).isTrue(); + } + + @Test + void tamperedBodyWithIntactContentDigestIsRejected() { + byte[] body = "{\"amount\": 100}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map headers = withSignatureHeaders(io.github.marcofanti.aauth.signing.RequestSigner.sign( + io.github.marcofanti.aauth.signing.SignRequest.builder("POST", TARGET) + .keyPair(agentKeys) + .scheme(new io.github.marcofanti.aauth.signing.SignatureScheme.Hwk()) + .body(body) + .additionalComponents(List.of("content-digest")) + .build())); + byte[] tamperedBody = "{\"amount\": 999999}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + + RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, tamperedBody, false, false); + + assertThat(result.valid()).isFalse(); + assertThat(result.error()).isEqualTo("content-digest mismatch"); + } + + @Test + void bodyWithoutContentDigestHeaderIsUnaffected() { + AgentRequestSigner signer = AgentRequestSigner.builder(agentKeys).build(); + byte[] body = "{\"note\": \"no digest coverage\"}".getBytes(java.nio.charset.StandardCharsets.UTF_8); + Map headers = withSignatureHeaders(signer.signRequest("POST", TARGET, Map.of(), body, "hwk")); + + RequestVerifier.Result result = verifier.verifyRequest("POST", TARGET, headers, body, false, false); + + assertThat(result.valid()).isTrue(); + } + @Test void missingSignatureHeadersFailCleanly() { RequestVerifier.Result result = verifier.verifyRequest("GET", TARGET, Map.of(), null, false, false); diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 3960b25..8a3804d 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -68,6 +68,12 @@ reference with no findings. (sources/javadoc jars, GPG signing, central-publishing-maven-plugin). Manual steps (Portal signup, GPG key) documented in RELEASING.md. Normal builds unaffected. +- **HTTP/1.1 pinned for library-constructed clients (2026-08-02)**: downstream use against + uvicorn/h11-based servers (the AAuth Person Server) showed the JDK HttpClient's default + h2c upgrade makes h11 reject requests (400) or silently drop POST bodies. Both + library-constructed defaults (`DefaultHttpClient`, `TokenExchange.Exchange`) now set + `HttpClient.Version.HTTP_1_1`; caller-injected clients are untouched. + ## Test fixtures Per user request (2026-07-30), test fixtures and examples use the local UMA lab hostnames @@ -80,6 +86,13 @@ localhost-only HTTP carve-out itself. ## Deviations from the Python library +- **Content-Digest is enforced in the resource role (2026-08-02)**: both this library's + low-level `SignatureVerifier` and the Python reference only sign/verify the + `Content-Digest` *header*, so a tampered body with an intact header passes the HTTP + signature. `RequestVerifier.verifyRequest` now recomputes the RFC 9530 digest from the + body whenever both header and body are present and fails with + `content-digest mismatch` on divergence. The low-level `SignatureVerifier` is unchanged + for wire-format parity. - **Signature header base64 flavor preserved**: the Python library base64url-encodes the `Signature` header value (RFC 9421 §4.2 specifies sf-binary, i.e. standard base64). We mirror the Python behavior for interop; both parsers only accept the urlsafe alphabet.