From b09c7a9fb32ed8d9454df19ae39be6f3cb6fe8c8 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 21:48:38 +0000
Subject: [PATCH 1/2] Test the case where ECH is not configured, which is the
one that breaks users
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A client that supports ECH sends the `encrypted_client_hello` extension even
with no configuration for the name, so that a handshake using ECH and one not
using it look alike on the wire. The failure that reaches users is therefore not
ECH breaking: it is a middlebox objecting to the extension and breaking a
handshake from a client that was never trying to use ECH at all.
`EchGreaseTest` asks that directly, and unglamorously: an ordinary
`OkHttpClient` — no ECH configuration, no DoH, nothing arranged — fetches from
each public server that speaks ECH, and each has to serve it. It runs on every
platform rather than only where ECH works, which is the point, since the JVM
cannot do ECH today and that must not stop it reaching servers that can. No
version gate and no Conscrypt: it asserts nothing that needs either.
Each case also requires the server to say ECH was *not* used — Cloudflare's
`sni=plaintext`, DEfO's `SSL_ECH_STATUS: not attempted`, tls-ech.dev's "You are
not using ECH". Without that a success would be ambiguous, and a run where ECH
had quietly started working would pass while testing something else.
The local half is the offer itself. `test-server`'s `/tls` now reports the
ClientHello's extension IDs in order, which is most of what a JA3 or JA4
fingerprint is computed from — the extensions' contents are parsed away by
crypto/tls and stay unreported, so this still answers what OkHttp offered rather
than exactly how a CDN fingerprints it. GREASE values (RFC 8701) are named as
such rather than left as sixteen mystery hex codes, and `0xfe0d` is called out
on its own.
`ClientHelloExtensionsTest` gates on the fixture's own consistency — the list is
recorded, it carries the two extensions no TLS 1.3 handshake can omit, and the
ECH flag agrees with the list it was derived from — and records what OkHttp
offered without asserting it. Today's JVM offers no ECH extension at all, and
pinning that would turn the feature arriving into a failed test.
Verified: `EchGreaseTest` passes live against all three servers on both the
pinned release and the snapshot. The Go tests cover the extension naming and
assert Go's own client does not offer ECH, so the flag's false means "not
offered" rather than "never looked". `ClientHelloExtensionsTest` needs Docker
and runs in CI; its parsing was checked against a real `/tls` body from the
server running locally.
Progresses #5. Closes #22.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LTGos7kiusobSSixhsD19N
---
README.md | 25 ++++
.../containers/ClientHelloExtensionsTest.kt | 132 ++++++++++++++++++
.../okhttp/testbed/network/EchGreaseTest.kt | 94 +++++++++++++
site/topics/ech.html | 25 ++++
test-server/README.md | 9 +-
test-server/server_test.go | 30 ++++
test-server/tls.go | 72 +++++++++-
7 files changed, 383 insertions(+), 4 deletions(-)
create mode 100644 containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
create mode 100644 network/src/test/kotlin/okhttp/testbed/network/EchGreaseTest.kt
diff --git a/README.md b/README.md
index 7e1a6a1..d0a4fdf 100644
--- a/README.md
+++ b/README.md
@@ -261,6 +261,31 @@ It calls the service once per scheduled run. The `network` workflow does not run
requests, which is what makes that true rather than aspirational: How's My SSL asks to be used
only for clients you control, and a daily request is that.
+What GREASE costs, and the extensions that carry it
+---------------------------------------------------
+
+A client that supports ECH is meant to send the `encrypted_client_hello` extension even when it
+has **no** configuration for the name, so that a handshake using ECH and one not using it look
+alike on the wire. The failure that reaches users is not ECH breaking: it is a middlebox
+objecting to the extension and breaking a handshake from a client that was never trying to use
+ECH in the first place.
+
+`EchGreaseTest` asks the unglamorous half of that. An ordinary `OkHttpClient` — no ECH
+configuration, no DoH, nothing arranged — fetches from each public server that speaks ECH, and
+each has to serve it. It runs on every platform rather than only where ECH works, which is the
+point: the JVM cannot do ECH today and that must not stop it talking to servers that can. Each
+case also requires the server to *say* ECH was not used — Cloudflare's `sni=plaintext`, DEfO's
+`SSL_ECH_STATUS: not attempted`, `tls-ech.dev`'s "You are not using ECH" — because a success
+where ECH had quietly started working would pass while testing something else entirely.
+
+The local half is the offer itself. `test-server`'s `/tls` now reports the ClientHello's
+extension IDs in order, which is most of what a JA3 or JA4 fingerprint is built from, with
+GREASE values (RFC 8701) named rather than left as mystery hex, and `0xfe0d` called out on its
+own. `ClientHelloExtensionsTest` asserts the fixture's own consistency — the list is recorded,
+it carries the two extensions no TLS 1.3 handshake can omit, and the ECH flag agrees with the
+list it came from — and records what OkHttp offered without asserting it. Today's JVM offers no
+ECH extension at all; pinning that would turn the feature arriving into a failure.
+
The resolver matrix
-------------------
diff --git a/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt b/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
new file mode 100644
index 0000000..ee7ac7f
--- /dev/null
+++ b/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
@@ -0,0 +1,132 @@
+/*
+ * 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.contains
+import assertk.assertions.isEqualTo
+import assertk.assertions.isNotEmpty
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+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
+
+/**
+ * The extensions OkHttp's ClientHello carried, as `test-server` saw them.
+ *
+ * How's My SSL answers with suites, groups and signature algorithms, which is most of a
+ * ClientHello but not the part a CDN keys on hardest: the extension list and its order is what a
+ * JA3 or JA4 fingerprint is largely computed from. `test-server` reports it from
+ * `crypto/tls`'s own view of the offer, so the record is available without a third party.
+ *
+ * The GREASE question is asked here too. A client that supports ECH is meant to send the
+ * `encrypted_client_hello` extension even when it has no configuration for the name, so that
+ * using ECH and not using it look the same on the wire — which means whether the extension was
+ * offered at all is visible, and whether it was real is deliberately not. That is recorded,
+ * never asserted: today's JVM has no ECH and offers nothing, and pinning that would turn the
+ * feature arriving into a failure.
+ *
+ * What *is* asserted is the fixture's own consistency, which is a fact about this repository
+ * rather than about the platform: the list is recorded at all, it carries the two extensions no
+ * TLS 1.3 handshake can omit, and the ECH flag agrees with the list it was derived from.
+ */
+@Testcontainers
+@TestInstance(TestInstance.Lifecycle.PER_CLASS)
+class ClientHelloExtensionsTest {
+ private lateinit var client: OkHttpClient
+
+ @BeforeAll
+ fun trustTheFixtureCA() {
+ val caPem =
+ OkHttpClient()
+ .newCall(Request.Builder().url(plainUrl("/ca.pem")).build())
+ .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)
+ .build()
+ }
+
+ @Test
+ fun theOfferCarriesItsExtensionList() {
+ val extensions = report().extensions()
+
+ assertThat(extensions, name = "extensions offered").isNotEmpty()
+
+ // Two a TLS 1.3 ClientHello cannot do without: the name being requested, and the version
+ // list, since 1.3 is negotiated through supported_versions rather than the record header.
+ // Anything beyond these is the platform's business and is recorded rather than required.
+ assertThat(extensions, name = "extensions offered").contains("server_name")
+ assertThat(extensions, name = "extensions offered").contains("supported_versions")
+ }
+
+ /**
+ * The flag is derived from the list, so the two disagreeing means the server's own reporting
+ * is wrong — the one thing here that would make the GREASE record untrustworthy without
+ * looking untrustworthy.
+ */
+ @Test
+ fun theEchFlagAgreesWithTheExtensionList() {
+ val body = report()
+
+ val offered = Regex("\"encryptedClientHelloOffered\"\\s*:\\s*(true|false)").find(body)?.groupValues?.get(1)
+
+ assertThat(offered, name = "encryptedClientHelloOffered")
+ .isEqualTo(body.extensions().contains("encrypted_client_hello").toString())
+ }
+
+ /** The `/tls` body, raw. Two fields do not justify a JSON dependency — as in `ClientHelloTest`. */
+ private fun report(): String =
+ client.newCall(Request.Builder().url(tlsUrl("/tls")).build()).execute().use { response ->
+ check(response.code == 200) { "/tls answered HTTP ${response.code}" }
+ response.body.string()
+ }
+
+ private fun String.extensions(): List {
+ val array = Regex("\"extensions\"\\s*:\\s*\\[([^\\]]*)]").find(this)?.groupValues?.get(1).orEmpty()
+ return Regex("\"([^\"]*)\"").findAll(array).map { it.groupValues[1] }.toList()
+ }
+
+ private fun plainUrl(path: String) = "http://${server.host}:${server.getMappedPort(TestServer.PLAIN_PORT)}$path".toHttpUrl()
+
+ private fun tlsUrl(path: String) = "https://${server.host}:${server.getMappedPort(TestServer.TLS_PORT)}$path".toHttpUrl()
+
+ companion object {
+ /** One container for the class: every case here reads, and none can affect the next. */
+ @Container
+ @JvmStatic
+ val server: GenericContainer<*> = TestServer.container()
+ }
+}
diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchGreaseTest.kt b/network/src/test/kotlin/okhttp/testbed/network/EchGreaseTest.kt
new file mode 100644
index 0000000..9cb53b5
--- /dev/null
+++ b/network/src/test/kotlin/okhttp/testbed/network/EchGreaseTest.kt
@@ -0,0 +1,94 @@
+/*
+ * 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.network
+
+import assertk.assertThat
+import assertk.assertions.contains
+import assertk.assertions.isEqualTo
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import org.junit.jupiter.api.Assumptions.assumeTrue
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.EnumSource
+
+/**
+ * An ordinary client, against servers that speak ECH.
+ *
+ * This is the case that breaks real users, and it has nothing to do with ECH working: a client
+ * that supports ECH but has no configuration for a name is meant to send a GREASE extension
+ * anyway, so that using ECH and not using it look the same on the wire. A middlebox that objects
+ * to the extension breaks every such handshake — and the client involved was not trying to use
+ * ECH at all.
+ *
+ * So the assertion is deliberately unglamorous: with nothing configured, the request succeeds.
+ * It runs on **every** platform rather than only where ECH works, which is the point — the JVM
+ * cannot do ECH today, and that must not stop it talking to servers that can.
+ *
+ * Each case also asks the server whether ECH was used, and requires the answer to be no. Without
+ * that, a success here would be ambiguous: a run where ECH quietly started working would pass
+ * while testing something else entirely.
+ */
+class EchGreaseTest {
+ @ParameterizedTest
+ @EnumSource(EchServer::class)
+ fun unconfiguredClientIsServed(server: EchServer) {
+ val result = Preflight.check(server.endpoint)
+ assumeTrue(result.up) { "${server.endpoint.server} is unavailable: ${result.detail}" }
+
+ // A default client: no ECH configuration, no DoH, nothing arranged. What an application
+ // that has never heard of ECH would send.
+ val response =
+ OkHttpClient()
+ .newCall(Request.Builder().url(server.url).build())
+ .execute()
+
+ val body = response.use { it.code to it.body.string() }
+
+ assertThat(body.first, name = "${server.endpoint.server} status").isEqualTo(200)
+ assertThat(body.second, name = "${server.endpoint.server} says ECH was not used")
+ .contains(server.notUsed)
+ }
+
+ /**
+ * The public servers that speak ECH, and how each says it did not.
+ *
+ * The marker is the server's own words rather than a header we impose, so a server that
+ * changed its wording fails loudly here instead of quietly asserting nothing.
+ */
+ enum class EchServer(
+ val endpoint: Endpoint,
+ val url: String,
+ val notUsed: String,
+ ) {
+ CLOUDFLARE(
+ endpoint = Endpoint.CLOUDFLARE_ECH,
+ url = "https://cloudflare-ech.com/cdn-cgi/trace",
+ notUsed = "sni=plaintext",
+ ),
+
+ TLS_ECH_DEV(
+ endpoint = Endpoint.TLS_ECH_DEV,
+ url = "https://tls-ech.dev/",
+ notUsed = "You are not using ECH",
+ ),
+
+ DEFO_IE(
+ endpoint = Endpoint.DEFO_IE,
+ url = "https://defo.ie/ech-check.php",
+ notUsed = "SSL_ECH_STATUS: not attempted",
+ ),
+ }
+}
diff --git a/site/topics/ech.html b/site/topics/ech.html
index cfd7749..7699e34 100644
--- a/site/topics/ech.html
+++ b/site/topics/ech.html
@@ -120,6 +120,31 @@ What runs today
gates, for the reason everything calling those servers does.
+ The GREASE case
+
+ The failure most likely to reach a real user is not ECH breaking. A client that supports ECH
+ sends the encrypted_client_hello extension even with no configuration for
+ the name, so that a handshake using ECH and one not using it look alike on the wire — and a
+ middlebox that objects to the extension then breaks handshakes from clients that were never
+ trying to use ECH at all.
+
+
+ EchGreaseTest asks the unglamorous half: an ordinary client, nothing configured,
+ fetching from each public server that speaks ECH. It runs on every platform rather than only
+ where ECH works — the JVM cannot do ECH today, and that must not stop it reaching servers that
+ can. Each case also requires the server to say ECH was not used
+ (sni=plaintext, SSL_ECH_STATUS: not attempted, "You are not using
+ ECH"), so a run where ECH quietly started working fails rather than passing while measuring
+ something else.
+
+
+ The local half is the offer itself: test-server's /tls reports the
+ ClientHello's extension IDs in order, names GREASE values rather than leaving them as hex, and
+ calls out 0xfe0d on its own. Whether OkHttp offered it is recorded and never
+ asserted — today's JVM offers nothing, and pinning that would turn the feature arriving into a
+ failed test.
+
+
What it would take on the JVM
network:echTest runs the same cases against the public servers on a JVM, and its
diff --git a/test-server/README.md b/test-server/README.md
index 5cfbd46..f5a7c7c 100644
--- a/test-server/README.md
+++ b/test-server/README.md
@@ -17,7 +17,14 @@ It covers three things nothing else here does:
a good one is *accepted* needs a CA nobody else can change.
- **The handshake, reported back.** `/tls` answers with the negotiated version, suite and
ALPN protocol, and with the offer they were chosen from — the client's supported versions,
- cipher suites, curves and signature schemes. That is issue #17's local half.
+ cipher suites, curves and signature schemes, and the IDs of the extensions it sent, in
+ order. That is issue #17's local half. The extension list is most of what a JA3 or JA4
+ fingerprint is computed from; the extensions' *contents* are parsed away by `crypto/tls`
+ and are not reported, so this answers what OkHttp offered rather than exactly how a CDN
+ would fingerprint it. GREASE values (RFC 8701) are named as such rather than left as
+ sixteen mystery hex codes, and `encryptedClientHelloOffered` calls out extension `0xfe0d`
+ specifically: a client with no ECH configuration is meant to send one anyway, so that
+ using ECH and not using it look alike, and whether it was real is deliberately invisible.
- **Responses that are wrong on purpose.** `/hostile/…` hijacks the connection and writes
resets, truncated bodies and invalid framing directly. `http.ResponseWriter` exists to
stop a handler emitting nonsense, so nothing above the socket can produce these.
diff --git a/test-server/server_test.go b/test-server/server_test.go
index bb60a26..53508e7 100644
--- a/test-server/server_test.go
+++ b/test-server/server_test.go
@@ -268,6 +268,36 @@ func TestTLSReportsTheHandshakeAndTheOffer(t *testing.T) {
if len(report.Offered.SupportedVersions) == 0 {
t.Error("no offered versions")
}
+ // The extension list is what the GREASE question is asked through, so an empty one would
+ // make that suite pass vacuously against a server that never recorded anything.
+ if len(report.Offered.Extensions) == 0 {
+ t.Error("no offered extensions")
+ }
+ // Go's own client has no ECH configuration here and does not GREASE, so this is the
+ // negative case: a false that means "not offered" rather than "never looked".
+ if report.Offered.EncryptedClientHelloOffered {
+ t.Error("Go's client offered encrypted_client_hello with nothing configured")
+ }
+}
+
+func TestExtensionNames(t *testing.T) {
+ for _, c := range []struct {
+ extension uint16
+ want string
+ }{
+ {0, "server_name"},
+ {extensionEncryptedClientHello, "encrypted_client_hello"},
+ // RFC 8701's sixteen reserved values, named rather than left as mystery hex.
+ {0x0a0a, "GREASE(0x0a0a)"},
+ {0xfafa, "GREASE(0xfafa)"},
+ // Not GREASE: the halves differ, so the pattern must not match on the low byte alone.
+ {0x1a2a, "0x1a2a"},
+ {0x1234, "0x1234"},
+ } {
+ if got := extensionName(c.extension); got != c.want {
+ t.Errorf("extensionName(0x%04x) = %q, want %q", c.extension, got, c.want)
+ }
+ }
}
func caPEMOf(t *testing.T, plain *httptest.Server) []byte {
diff --git a/test-server/tls.go b/test-server/tls.go
index 19d2cc4..8ae3c88 100644
--- a/test-server/tls.go
+++ b/test-server/tls.go
@@ -332,9 +332,12 @@ type handshakeReport struct {
CertificateSelfMade bool `json:"certificateSelfMade"`
}
-// The offer. Note what is missing: crypto/tls hands a server the parsed fields, not the
-// ClientHello's extension list or its order, so this is not enough to compute a JA3 or JA4
-// fingerprint. It answers what OkHttp offered; it does not answer how a CDN fingerprints it.
+// The offer.
+//
+// The extension IDs arrive in the order the client sent them, which is most of what a JA3 or
+// JA4 fingerprint is computed from. The rest — the raw extension *contents* — crypto/tls parses
+// and does not hand back, so this still answers what OkHttp offered rather than exactly how a
+// CDN would fingerprint it.
type clientHelloInfo struct {
SupportedVersions []string `json:"supportedVersions"`
CipherSuites []string `json:"cipherSuites"`
@@ -345,6 +348,13 @@ type clientHelloInfo struct {
SignatureSchemes []string `json:"signatureSchemes"`
ALPNProtocols []string `json:"alpnProtocols"`
ServerName string `json:"serverName"`
+ Extensions []string `json:"extensions"`
+ // True when extension 0xfe0d was offered at all, which is the question GREASE asks: a client
+ // with no ECH configuration is meant to send one anyway, so that a client using ECH and a
+ // client not using it look the same on the wire. Whether *this* one was real or GREASE is
+ // not visible from here — indistinguishability is the design — so the name is deliberately
+ // "offered" rather than "used".
+ EncryptedClientHelloOffered bool `json:"encryptedClientHelloOffered"`
}
func (s *server) handshake(r *http.Request) *handshakeReport {
@@ -381,6 +391,13 @@ func (s *server) handshake(r *http.Request) *handshakeReport {
SupportedCurves: []string{},
SignatureSchemes: []string{},
SupportedPoints: []int{},
+ Extensions: []string{},
+ }
+ for _, extension := range hello.Extensions {
+ offered.Extensions = append(offered.Extensions, extensionName(extension))
+ if extension == extensionEncryptedClientHello {
+ offered.EncryptedClientHelloOffered = true
+ }
}
if offered.ALPNProtocols == nil {
offered.ALPNProtocols = []string{}
@@ -404,6 +421,55 @@ func (s *server) handshake(r *http.Request) *handshakeReport {
return report
}
+// RFC 9849 §5, the extension a GREASE-ing client sends even with nothing to put in it.
+const extensionEncryptedClientHello = 0xfe0d
+
+// The extensions worth naming. The list is the ones a modern client actually sends, not the
+// whole IANA registry: an unnamed extension is reported as its hex code, which is readable
+// enough to look up and honest about not being recognised here.
+var extensionNames = map[uint16]string{
+ 0: "server_name",
+ 1: "max_fragment_length",
+ 5: "status_request",
+ 10: "supported_groups",
+ 11: "ec_point_formats",
+ 13: "signature_algorithms",
+ 14: "use_srtp",
+ 16: "application_layer_protocol_negotiation",
+ 17: "status_request_v2",
+ 18: "signed_certificate_timestamp",
+ 21: "padding",
+ 22: "encrypt_then_mac",
+ 23: "extended_master_secret",
+ 27: "compress_certificate",
+ 35: "session_ticket",
+ 41: "pre_shared_key",
+ 42: "early_data",
+ 43: "supported_versions",
+ 44: "cookie",
+ 45: "psk_key_exchange_modes",
+ 47: "certificate_authorities",
+ 49: "post_handshake_auth",
+ 50: "signature_algorithms_cert",
+ 51: "key_share",
+ extensionEncryptedClientHello: "encrypted_client_hello",
+ 0xff01: "renegotiation_info",
+ 0x4469: "application_settings",
+}
+
+func extensionName(extension uint16) string {
+ if name, ok := extensionNames[extension]; ok {
+ return name
+ }
+ // GREASE reserves the sixteen values whose halves are equal and end in 0xa (RFC 8701). A
+ // client sends them to keep servers tolerant of unknown values, so naming them as such
+ // stops sixteen mystery hex codes from reading like sixteen unrecognised extensions.
+ if extension&0x0f0f == 0x0a0a && extension>>8 == extension&0xff {
+ return fmt.Sprintf("GREASE(0x%04x)", extension)
+ }
+ return fmt.Sprintf("0x%04x", extension)
+}
+
func versionName(version uint16) string {
switch version {
case tls.VersionTLS10:
From b95f6193d68859a73b2fe79f154ce42d6be3c9ac Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 21:58:49 +0000
Subject: [PATCH 2/2] Don't require SNI in the fixture's ClientHello: the JDK
doesn't send it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
`theOfferCarriesItsExtensionList` asserted `server_name` was offered, and CI
disagreed. It was the assertion that was wrong, not OkHttp: the fixture is
reached as `localhost`, and the JDK omits SNI for a name with no dot in it, so
the extension is genuinely absent. Requiring it asserted a fact about the
container's address rather than about the client — exactly the trap the
ClientHello section of the README warns about.
`supported_versions` stands on its own: TLS 1.3 is negotiated through it rather
than through the record header, and the report says 1.3 was negotiated, so its
absence would mean the record was wrong.
Reproduced without Docker by running test-server natively and driving it with
OkHttp, which is also how the finding was confirmed rather than guessed at.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01LTGos7kiusobSSixhsD19N
---
README.md | 5 +++++
.../testbed/containers/ClientHelloExtensionsTest.kt | 12 ++++++++----
2 files changed, 13 insertions(+), 4 deletions(-)
diff --git a/README.md b/README.md
index d0a4fdf..bd318a8 100644
--- a/README.md
+++ b/README.md
@@ -286,6 +286,11 @@ it carries the two extensions no TLS 1.3 handshake can omit, and the ECH flag ag
list it came from — and records what OkHttp offered without asserting it. Today's JVM offers no
ECH extension at all; pinning that would turn the feature arriving into a failure.
+`server_name` is not among the required ones, though every ClientHello on the internet carries
+one. The fixture is reached as `localhost`, and the JDK omits SNI for a name with no dot in it —
+so requiring it would assert a fact about the container's address rather than about OkHttp. It
+is in the record either way, which is the distinction this whole section runs on.
+
The resolver matrix
-------------------
diff --git a/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt b/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
index ee7ac7f..280d518 100644
--- a/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
+++ b/containers/src/test/kotlin/okhttp/testbed/containers/ClientHelloExtensionsTest.kt
@@ -85,10 +85,14 @@ class ClientHelloExtensionsTest {
assertThat(extensions, name = "extensions offered").isNotEmpty()
- // Two a TLS 1.3 ClientHello cannot do without: the name being requested, and the version
- // list, since 1.3 is negotiated through supported_versions rather than the record header.
- // Anything beyond these is the platform's business and is recorded rather than required.
- assertThat(extensions, name = "extensions offered").contains("server_name")
+ // The one extension a TLS 1.3 handshake cannot do without: 1.3 is negotiated through
+ // supported_versions rather than the record header, and the report says 1.3 was negotiated.
+ // Anything beyond it is the platform's business and is recorded rather than required.
+ //
+ // `server_name` is deliberately not among them, though every ClientHello on the internet
+ // carries one. The fixture is reached as `localhost`, and the JDK omits SNI for a name with
+ // no dot in it — so asserting it here would be asserting a fact about the container's
+ // address rather than about OkHttp. It is in the record either way.
assertThat(extensions, name = "extensions offered").contains("supported_versions")
}