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
30 changes: 30 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,36 @@ 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.

`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
-------------------

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/*
* 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()

// 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")
}

/**
* 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<String> {
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()
}
}
94 changes: 94 additions & 0 deletions network/src/test/kotlin/okhttp/testbed/network/EchGreaseTest.kt
Original file line number Diff line number Diff line change
@@ -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",
),
}
}
25 changes: 25 additions & 0 deletions site/topics/ech.html
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,31 @@ <h2>What runs today</h2>
gates, for the reason everything calling those servers does.
</p>

<h2>The GREASE case</h2>
<p>
The failure most likely to reach a real user is not ECH breaking. A client that supports ECH
sends the <code>encrypted_client_hello</code> extension even with <em>no</em> 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.
</p>
<p>
<code>EchGreaseTest</code> 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 <em>not</em> used
(<code>sni=plaintext</code>, <code>SSL_ECH_STATUS: not attempted</code>, "You are not using
ECH"), so a run where ECH quietly started working fails rather than passing while measuring
something else.
</p>
<p>
The local half is the offer itself: <code>test-server</code>'s <code>/tls</code> reports the
ClientHello's extension IDs in order, names GREASE values rather than leaving them as hex, and
calls out <code>0xfe0d</code> 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.
</p>

<h2>What it would take on the JVM</h2>
<p>
<code>network:echTest</code> runs the same cases against the public servers on a JVM, and its
Expand Down
9 changes: 8 additions & 1 deletion test-server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
30 changes: 30 additions & 0 deletions test-server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading