diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml index 86b44bb..489f15c 100644 --- a/.github/workflows/containers.yml +++ b/.github/workflows/containers.yml @@ -107,7 +107,7 @@ jobs: # --continue so a failing container test doesn't stop loomTest from running: # every suite should report every run, whatever the others did. run: > - ./gradlew containers:test containers:loomTest containers:hostileTest --continue + ./gradlew containers:test containers:loomTest containers:hostileTest containers:postQuantumTest --continue -PtestJavaVersion=${{ matrix.javaVersion }} -Porg.gradle.java.installations.fromEnv=JAVA_HOME_${{ matrix.javaVersion }}_X64,JAVA_HOME_21_X64 ${{ matrix.okhttpVersion && format('-PokhttpVersion={0}', matrix.okhttpVersion) || '' }} diff --git a/README.md b/README.md index 3c18314..e3dac8f 100644 --- a/README.md +++ b/README.md @@ -793,6 +793,12 @@ Whatever the count turns out to be, it is a fact about OkHttp's retry policy rat here, so it is recorded. Its sibling `HostileResponseTest` stays fatal, because "a truncated body must not read as a complete one" is an invariant rather than a policy. +`PostQuantumTest` points an ordinary public-API client at this repository's TLS 1.3 listener +that accepts only X25519MLKEM768. The JDKs currently in the matrix do not offer that hybrid group, +and published OkHttp cannot select it yet, so the successful-handshake assertion is an expected +failure. It runs under `postQuantumTest`: the failed handshake remains visible until the capability +lands, at which point the same assertion turns green without updating the expectation in the test. + `EchTest` is the other of that kind. ECH takes two halves: OkHttp reads an ECH config list out of the DNS HTTPS record, and the TLS stack encrypts the client hello with it. OkHttp's half works on the JVM — the routes carry a config list, which is what the `echConfigList` assertions diff --git a/containers/build.gradle.kts b/containers/build.gradle.kts index 7ef33c8..ba47b46 100644 --- a/containers/build.gradle.kts +++ b/containers/build.gradle.kts @@ -90,6 +90,11 @@ val loomTestPattern = "**/BasicLoomTest.class" // malformed response fails at all, stays fatal. val hostileTestPattern = "**/HostileRetryTest.class" +// PostQuantumTest asserts the capability we want rather than today's expected failure. JDK 25 and +// earlier do not offer X25519MLKEM768, so keep the finding visible in JUnit without making the +// containers build red; it should turn green unchanged when the client/provider supports it. +val postQuantumTestPattern = "**/PostQuantumTest.class" + // MockServer is the one thing on this classpath that isn't Java 8 bytecode: the client's own // classes are compiled for 17, so on an older test JDK every suite that touches it dies during // class resolution — and it takes the whole task with it, because JUnit resolves the classes it @@ -121,7 +126,7 @@ tasks.test { systemProperty("testbed.tlspolicy.report", tlsPolicy.get().asFile.absolutePath) outputs.file(tlsPolicy) - exclude(loomTestPattern, hostileTestPattern) + exclude(loomTestPattern, hostileTestPattern, postQuantumTestPattern) if (!mockServerRuns) { exclude(mockServerTestPattern) doFirst { @@ -161,8 +166,21 @@ val hostileTest = ignoreFailures = true } +val postQuantumTest = + tasks.register("postQuantumTest") { + group = "verification" + description = "Reports whether OkHttp can connect using X25519MLKEM768. Records failures without failing the build." + + val testSourceSet = sourceSets.test.get() + testClassesDirs = testSourceSet.output.classesDirs + classpath = testSourceSet.runtimeClasspath + include(postQuantumTestPattern) + + ignoreFailures = true + } + tasks.check { - dependsOn(loomTest, hostileTest) + dependsOn(loomTest, hostileTest, postQuantumTest) } dependencies { diff --git a/containers/src/test/kotlin/okhttp/testbed/containers/PostQuantumTest.kt b/containers/src/test/kotlin/okhttp/testbed/containers/PostQuantumTest.kt new file mode 100644 index 0000000..329f5f9 --- /dev/null +++ b/containers/src/test/kotlin/okhttp/testbed/containers/PostQuantumTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2026 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package okhttp.testbed.containers + +import assertk.assertThat +import assertk.assertions.isEqualTo +import okhttp3.ConnectionSpec +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.TlsVersion +import okhttp3.tls.HandshakeCertificates +import okhttp3.tls.decodeCertificatePem +import org.junit.jupiter.api.BeforeAll +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.TestInstance +import org.testcontainers.containers.GenericContainer +import org.testcontainers.junit.jupiter.Container +import org.testcontainers.junit.jupiter.Testcontainers + +/** + * Reports whether an ordinary public-API OkHttp client can reach a TLS 1.3 server that permits only + * the X25519MLKEM768 post-quantum hybrid named group. + * + * This is expected to fail on providers that do not offer that group, including the JDKs currently + * in the testbed matrix. It runs in the non-gating `postQuantumTest` task so the failed handshake is + * recorded as the capability gap it is. When an OkHttp artifact and its TLS provider can configure + * or offer X25519MLKEM768, this same test turns green without changing the assertion. + * + * [lysine-dev/okhttp#9517](https://github.com/lysine-dev/okhttp/pull/9517) proposes the public + * `ConnectionSpec` selector for named groups. This test deliberately uses only APIs in published + * artifacts, so it can record the before-and-after result across the testbed's version matrix. + */ +@Testcontainers +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +class PostQuantumTest { + private lateinit var client: OkHttpClient + + @BeforeAll + fun buildClient() { + val caPem = + OkHttpClient() + .newCall( + Request( + "http://${server.host}:${server.getMappedPort(TestServer.PLAIN_PORT)}/ca.pem".toHttpUrl(), + ), + ).execute() + .use { response -> + check(response.code == 200) { "the fixture CA is not being served: HTTP ${response.code}" } + response.body.string() + } + + val certificates = + HandshakeCertificates + .Builder() + .addTrustedCertificate(caPem.decodeCertificatePem()) + .build() + + client = + OkHttpClient + .Builder() + .sslSocketFactory(certificates.sslSocketFactory(), certificates.trustManager) + .connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS)) + .build() + } + + @Test + fun connectsToPostQuantumOnlyServer() { + val response = + client + .newCall( + Request( + "https://${server.host}:${server.getMappedPort(TestServer.PQC_PORT)}/health".toHttpUrl(), + ), + ).execute() + + response.use { + assertThat(it.code).isEqualTo(200) + assertThat(checkNotNull(it.handshake).tlsVersion).isEqualTo(TlsVersion.TLS_1_3) + } + } + + companion object { + @Container + @JvmStatic + val server: GenericContainer<*> = TestServer.container() + } +} diff --git a/containers/src/test/kotlin/okhttp/testbed/containers/TestServer.kt b/containers/src/test/kotlin/okhttp/testbed/containers/TestServer.kt index 24aef7a..da9c80b 100644 --- a/containers/src/test/kotlin/okhttp/testbed/containers/TestServer.kt +++ b/containers/src/test/kotlin/okhttp/testbed/containers/TestServer.kt @@ -60,6 +60,9 @@ object TestServer { const val TLS12_PORT = 8412 const val TLS13_PORT = 8413 + /** TLS 1.3 restricted to the X25519MLKEM768 post-quantum hybrid named group. */ + const val PQC_PORT = 8414 + /** * Mutual TLS: a client certificate is required here and merely welcome everywhere else. * @@ -96,6 +99,7 @@ object TestServer { TLS11_PORT, TLS12_PORT, TLS13_PORT, + PQC_PORT, MTLS_PORT, EXPIRED_PORT, WRONG_HOST_PORT, diff --git a/site/tools/collect_results.py b/site/tools/collect_results.py index 87eb643..dfe85ce 100644 --- a/site/tools/collect_results.py +++ b/site/tools/collect_results.py @@ -35,7 +35,7 @@ is what this repository is for. The Gradle task a suite ran under decides whether its result gates. `test` failing means -this repository is red; `loomTest`, `hostileTest`, `echTest` and `networkTest` failing are +this repository is red; `loomTest`, `hostileTest`, `postQuantumTest`, `echTest` and `networkTest` failing are recorded findings — about OkHttp, about the platform, or about a server someone else operates — which is why the build stays green. See "Suites that report rather than gate" in the README. @@ -66,6 +66,7 @@ REPORTING_TASKS = { "loomTest", "hostileTest", + "postQuantumTest", "echTest", "echConscryptTest", "echPlatformTest", @@ -94,6 +95,7 @@ "EchConscryptTest", "EchClientHelloTest", "PublicEncryptedClientHelloTest", + "PostQuantumTest", } @@ -112,6 +114,13 @@ def severity_of(suite_name: str) -> str: # arrive as an unexpected failure and be looked at, not inherit an excuse written for its # neighbours. EXPECTED_FAILURES = { + "PostQuantumTest": { + "connectsToPostQuantumOnlyServer": ( + "The JDKs currently in the testbed matrix do not offer X25519MLKEM768, and the " + "published OkHttp API cannot select that named group yet. The PQC-only TLS 1.3 " + "listener therefore rejects the handshake; a green result means the capability landed." + ), + }, "EchTest": { f"{case}{platform_suffix}": ( "OkHttp's ConscryptPlatform takes the ECH config list and drops it, and no released " diff --git a/test-server/Dockerfile b/test-server/Dockerfile index 130fa56..3ed6b37 100644 --- a/test-server/Dockerfile +++ b/test-server/Dockerfile @@ -19,7 +19,7 @@ COPY --from=build /test-server /test-server # Plain HTTP, the raw echo listener, TLS, a port per TLS version, and a port per rejectable # chain. Every one of these is an env var away from being something else — see README.md. -EXPOSE 8080 8081 8443 8410 8411 8412 8413 8420 8421 8422 8423 8424 +EXPOSE 8080 8081 8443 8410 8411 8412 8413 8414 8420 8421 8422 8423 8424 HEALTHCHECK --interval=10s --timeout=3s --start-period=2s --retries=3 \ CMD curl -fsS http://127.0.0.1:8080/health || exit 1 diff --git a/test-server/README.md b/test-server/README.md index e2d0f8f..204f015 100644 --- a/test-server/README.md +++ b/test-server/README.md @@ -72,6 +72,7 @@ Listeners | `tls11` | `:8411` | TLS 1.1 only, obsolete suites, `http/1.1` | | `tls12` | `:8412` | TLS 1.2 only, `http/1.1` | | `tls13` | `:8413` | TLS 1.3 only, `http/1.1` | +| `pqc` | `:8414` | TLS 1.3 with X25519MLKEM768 only, `http/1.1` | | `mtls` | `:8425` | Requires a client certificate signed by the fixture CA | | `badchain-expired` | `:8420` | A leaf that expired yesterday | | `badchain-wrong-host` | `:8421` | A valid chain for a name it is not served on | @@ -179,6 +180,7 @@ turns that listener off. | `RAW_ADDR` | `:8081` | raw request-head echo | | `HTTPS_ADDR` | `:8443` | TLS, with `h2` | | `TLS10_ADDR` … `TLS13_ADDR` | `:8410` … `:8413` | one TLS version each | +| `PQC_ADDR` | `:8414` | TLS 1.3 restricted to X25519MLKEM768 | | `BADCHAIN_EXPIRED_ADDR` | `:8420` | the expired chain | | `BADCHAIN_WRONG_HOST_ADDR` | `:8421` | the wrong-host chain | | `BADCHAIN_SELF_SIGNED_ADDR` | `:8422` | the self-signed chain | diff --git a/test-server/docker-compose.yml b/test-server/docker-compose.yml index bf8d96a..9dd595a 100644 --- a/test-server/docker-compose.yml +++ b/test-server/docker-compose.yml @@ -21,6 +21,7 @@ services: - "${TLS11_PORT:-8411}:8411" - "${TLS12_PORT:-8412}:8412" - "${TLS13_PORT:-8413}:8413" + - "${PQC_PORT:-8414}:8414" # A port per chain that must be rejected — the local half of the badssl matrix. - "${BADCHAIN_EXPIRED_PORT:-8420}:8420" - "${BADCHAIN_WRONG_HOST_PORT:-8421}:8421" diff --git a/test-server/main.go b/test-server/main.go index 04309f2..350d259 100644 --- a/test-server/main.go +++ b/test-server/main.go @@ -15,6 +15,7 @@ package main import ( "context" + "crypto/tls" "errors" "fmt" "log" @@ -119,6 +120,20 @@ func main() { s.shutdownWith(ctx, srv) } + // TLS 1.3 with no classical key exchange fallback. A client reaches this listener only if its + // TLS provider offers the hybrid post-quantum group standardized by RFC 9794. + if addr := env("PQC_ADDR", ":8414"); addr != "" { + s.addListener(listener{ + Name: "pqc", Addr: addr, TLS: true, + MinVersion: "TLSv1.3", MaxVersion: "TLSv1.3", ALPN: "http/1.1", + Note: "requires the X25519MLKEM768 post-quantum hybrid named group", + }) + srv := s.tlsServer(addr, tlsVersions{min: tls.VersionTLS13, max: tls.VersionTLS13}, false) + srv.TLSConfig.CurvePreferences = []tls.CurveID{tls.X25519MLKEM768} + serve("pqc", func() error { return srv.ListenAndServeTLS("", "") }) + s.shutdownWith(ctx, srv) + } + // Mutual TLS. Only when the server minted its own CA: verifying a client certificate needs // a pool to verify it against, and a deployment holding a supplied certificate has no // signing key to have issued one with. diff --git a/test-server/server_test.go b/test-server/server_test.go index 53508e7..b88c2dc 100644 --- a/test-server/server_test.go +++ b/test-server/server_test.go @@ -377,6 +377,46 @@ func TestPerVersionListenersPinTheirVersion(t *testing.T) { } } +// The PQC listener must not accidentally retain Go's classical-group fallbacks. Otherwise an +// ordinary client could make PostQuantumTest green without negotiating the group it claims to test. +func TestPostQuantumListenerRequiresX25519MLKEM768(t *testing.T) { + s, plain := newTestServer(t) + if !s.certs.selfMade { + t.Skip("TLS_CERT_FILE is set; there is no fixture CA to trust") + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(caPEMOf(t, plain)) { + t.Fatal("the fixture CA did not parse") + } + + server := httptest.NewUnstartedServer(s.handler()) + server.TLS = s.tlsServer("", tlsVersions{min: tls.VersionTLS13, max: tls.VersionTLS13}, false).TLSConfig + server.TLS.CurvePreferences = []tls.CurveID{tls.X25519MLKEM768} + server.StartTLS() + defer server.Close() + + get := func(group tls.CurveID) (*http.Response, error) { + client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{ + RootCAs: pool, + MinVersion: tls.VersionTLS13, + MaxVersion: tls.VersionTLS13, + CurvePreferences: []tls.CurveID{group}, + }}} + return client.Get(server.URL + "/health") + } + + response, err := get(tls.X25519MLKEM768) + if err != nil { + t.Fatalf("a client offering X25519MLKEM768 was refused: %v", err) + } + _ = response.Body.Close() + + if response, err := get(tls.X25519); err == nil { + _ = response.Body.Close() + t.Fatal("the PQC listener accepted classical X25519") + } +} + // The local half of the badssl matrix. Every one of these must be refused by a client that // trusts the fixture CA — and refused for its own reason, not because the fixture is broken. //