Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/containers.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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) || '' }}
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions containers/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -161,8 +166,21 @@ val hostileTest =
ignoreFailures = true
}

val postQuantumTest =
tasks.register<Test>("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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -96,6 +99,7 @@ object TestServer {
TLS11_PORT,
TLS12_PORT,
TLS13_PORT,
PQC_PORT,
MTLS_PORT,
EXPIRED_PORT,
WRONG_HOST_PORT,
Expand Down
11 changes: 10 additions & 1 deletion site/tools/collect_results.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -66,6 +66,7 @@
REPORTING_TASKS = {
"loomTest",
"hostileTest",
"postQuantumTest",
"echTest",
"echConscryptTest",
"echPlatformTest",
Expand Down Expand Up @@ -94,6 +95,7 @@
"EchConscryptTest",
"EchClientHelloTest",
"PublicEncryptedClientHelloTest",
"PostQuantumTest",
}


Expand All @@ -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 "
Expand Down
2 changes: 1 addition & 1 deletion test-server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions test-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions test-server/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions test-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ package main

import (
"context"
"crypto/tls"
"errors"
"fmt"
"log"
Expand Down Expand Up @@ -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.
Expand Down
40 changes: 40 additions & 0 deletions test-server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
//
Expand Down
Loading