From 3bbe5b071230999afeffb4ed9b2f55527bfcd544 Mon Sep 17 00:00:00 2001
From: Claude
Date: Fri, 14 Aug 2026 19:40:48 +0000
Subject: [PATCH] Measure the missing ECH call instead of describing it
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Three changes that answer the same question from different sides.
**A platform that makes the call.** `EchConscryptTest` shows Conscrypt can encrypt
a client hello, but it shows it from a socket factory, with a config list the
suite kept for itself — so it never says whether the config list OkHttp resolved
would arrive. `EchConscryptPlatform` is a `Platform` that makes the one call
`ConscryptPlatform` omits, and `EchPlatformTest` runs `EchTest`'s requests
through ordinary public API with it installed. Same client, same servers, same
assertions; the difference between the two suites is one call to
`Conscrypt.setEchConfigList` and nothing else.
Upstream can't do this yet — OkHttp's master won't compile against a Conscrypt
with these methods, because no published Conscrypt has them — which is why the
platform is written here rather than waiting on #9559.
It has to import `okhttp3.internal`: a Platform is declared nowhere else. Rather
than delete the public-API rule for everybody, a file can now opt out by saying
why, and `checkPublicApiOnly` prints every exemption on every run:
// USES-OKHTTP-INTERNALS: is a Platform, which OkHttp only declares internally.
Verified without a network: after `install()`, a plain `OkHttpClient()` gets
Conscrypt's socket factory and the ECH-enabling trust manager, and `uninstall()`
puts `Jdk9Platform` back.
**Runs say what they ran on.** Each workflow records a platform — measured from
the JVM's own properties, or from the emulator's declared API level and
architecture rather than a second copy of them — and `collect_results.py` carries
it per suite rather than per version, because a version card merges an Android
artifact with a JVM one. Replaces the hardcoded `"javaVersion": "21"`, which was
neither measured nor true of the Android suite.
**The ECH page leads with results.** A matrix of server against platform, read
from `latest.json`: one row per server, one column per way of reaching it, so the
gap between "OkHttp as shipped" and "OkHttp + the missing call" is a column
comparison rather than a paragraph. Checked in a browser against synthesised
results, including the case where a suite is absent.
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01CqiK79k9uoWsn2AzgXpHMA
---
.github/workflows/android-ech.yml | 15 +-
.github/workflows/containers.yml | 9 +-
.github/workflows/network.yml | 11 +-
build.gradle.kts | 20 +-
conscrypt/README.md | 8 +
network/build.gradle.kts | 31 ++-
.../testbed/network/EchConscryptPlatform.kt | 118 +++++++++++
.../okhttp/testbed/network/EchPlatformTest.kt | 127 ++++++++++++
.../kotlin/okhttp/testbed/network/EchTest.kt | 6 +-
site/assets/ech-results.js | 183 ++++++++++++++++++
site/tools/collect_results.py | 26 ++-
site/topics/ech.html | 17 ++
12 files changed, 553 insertions(+), 18 deletions(-)
create mode 100644 network/src/test/kotlin/okhttp/testbed/network/EchConscryptPlatform.kt
create mode 100644 network/src/test/kotlin/okhttp/testbed/network/EchPlatformTest.kt
create mode 100644 site/assets/ech-results.js
diff --git a/.github/workflows/android-ech.yml b/.github/workflows/android-ech.yml
index e8c7915..7add28b 100644
--- a/.github/workflows/android-ech.yml
+++ b/.github/workflows/android-ech.yml
@@ -32,6 +32,13 @@ concurrency:
jobs:
android-ech:
name: android-ech (${{ inputs.okhttpVersion || 'pinned snapshot' }})
+ # Declared once and used twice — by the emulator that runs the suite, and by the metadata
+ # that tells the status page what the suite ran on. Two copies of an API level is how a
+ # page ends up confidently reporting the wrong device.
+ env:
+ EMULATOR_API_LEVEL: '37.0'
+ EMULATOR_TARGET: google_apis_playstore_ps16k
+ EMULATOR_ARCH: x86_64
runs-on: ubuntu-latest
timeout-minutes: 45
@@ -59,9 +66,9 @@ jobs:
- name: Run the ECH suite against the fixture containers
uses: reactivecircus/android-emulator-runner@v2
with:
- api-level: '37.0'
- target: google_apis_playstore_ps16k
- arch: x86_64
+ api-level: ${{ env.EMULATOR_API_LEVEL }}
+ target: ${{ env.EMULATOR_TARGET }}
+ arch: ${{ env.EMULATOR_ARCH }}
disable-animations: true
emulator-options: >-
-no-window
@@ -91,7 +98,7 @@ jobs:
"workflow": "android-ech",
"label": "${{ inputs.okhttpVersion || 'pinned-snapshot' }}",
"okhttpVersion": "$version",
- "javaVersion": "21",
+ "platform": "Android emulator API $EMULATOR_API_LEVEL · $EMULATOR_ARCH",
"jobStatus": "${{ job.status }}",
"runNumber": ${{ github.run_number }},
"runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}",
diff --git a/.github/workflows/containers.yml b/.github/workflows/containers.yml
index 7909e31..15437b5 100644
--- a/.github/workflows/containers.yml
+++ b/.github/workflows/containers.yml
@@ -79,13 +79,20 @@ jobs:
if [ -z "$version" ]; then
version=$(sed -n 's/^okhttp = "\(.*\)"$/\1/p' gradle/libs.versions.toml)
fi
+ # What this actually ran on. Measured rather than written down: a runner image bump
+ # or a toolchain change should show up on the status page as a different platform,
+ # not as the same string next to different results.
+ props=$(java -XshowSettings:properties -version 2>&1)
+ prop() { printf '%s\n' "$props" | sed -n "s/^ *$1 = //p" | head -1; }
+ platform="$(prop java.vendor) JDK $(prop java.version) · $(prop os.arch)"
+
mkdir -p containers/build/test-results
cat > containers/build/test-results/run-metadata.json <
- ./gradlew network:networkTest network:echTest network:echConscryptTest --continue
+ ./gradlew network:networkTest network:echTest network:echConscryptTest network:echPlatformTest --continue
${{ matrix.okhttpVersion && format('-PokhttpVersion={0}', matrix.okhttpVersion) || '' }}
# What the XML can't say: which OkHttp version 'pinned' actually resolved to, and
@@ -129,13 +129,20 @@ jobs:
if [ -z "$version" ]; then
version=$(sed -n 's/^okhttp = "\(.*\)"$/\1/p' gradle/libs.versions.toml)
fi
+ # What this actually ran on. Measured rather than written down: a runner image bump
+ # or a toolchain change should show up on the status page as a different platform,
+ # not as the same string next to different results.
+ props=$(java -XshowSettings:properties -version 2>&1)
+ prop() { printf '%s\n' "$props" | sed -n "s/^ *$1 = //p" | head -1; }
+ platform="$(prop java.vendor) JDK $(prop java.version) · $(prop os.arch)"
+
mkdir -p network/build/test-results
cat > network/build/test-results/run-metadata.json <
- file
- .readLines()
+ val lines = file.readLines()
+
+ // A file may opt out by saying why, on its own line:
+ //
+ // // USES-OKHTTP-INTERNALS: reimplements ConscryptPlatform's missing ECH call.
+ //
+ // The rule is about suites: a test that reaches into `okhttp3.internal` is testing
+ // something no caller can rely on. A file whose subject *is* an internal — a platform
+ // OkHttp doesn't ship yet — can't be written any other way, and the alternative to
+ // this marker is deleting the rule for everybody. The reason is required, and the
+ // exemptions are printed on every run so they stay visible rather than accumulating.
+ val exemption = lines.firstOrNull { it.trim().startsWith("// USES-OKHTTP-INTERNALS:") }
+ if (exemption != null) {
+ logger.lifecycle("public-api-only: ${file.name} exempt — ${exemption.substringAfter(":").trim()}")
+ return@flatMap emptyList()
+ }
+
+ lines
.withIndex()
.filter { (_, line) ->
line.startsWith("import ") && forbiddenImports.any { line.removePrefix("import ").startsWith("$it.") }
diff --git a/conscrypt/README.md b/conscrypt/README.md
index c62e720..a5c598c 100644
--- a/conscrypt/README.md
+++ b/conscrypt/README.md
@@ -25,6 +25,14 @@ The first is why this directory exists. The second is the small piece of work
[lysine-dev/okhttp#9559][okhttp-pr] does, one call to `Conscrypt.setEchConfigList` alongside the
ALPN and session-ticket configuration that method already does — `Android10Platform` is the model.
+`network:echPlatformTest` measures that second row rather than describing it. `EchConscryptPlatform`
+is a `Platform` that makes exactly that call and nothing else new; `EchPlatformTest` then runs
+`EchTest`'s requests through ordinary public API with it installed. The two suites are the same
+client against the same servers, so the difference between their results is the one call. That
+platform is the only file here allowed to import `okhttp3.internal` — it is a `Platform`, which
+OkHttp declares nowhere else — and it says so with a `USES-OKHTTP-INTERNALS:` marker that
+`checkPublicApiOnly` reports on every run.
+
The third is a Conscrypt change rather than an OkHttp one, and it is why `network:echConscryptTest`
has no counterpart to `EchTest.echIsRetriedOnStaleTlsEchDev`. On Android, a rejected ECH config
arrives as an `EchConfigMismatchException` carrying the config the server offered instead, which
diff --git a/network/build.gradle.kts b/network/build.gradle.kts
index 033309c..520b4a7 100644
--- a/network/build.gradle.kts
+++ b/network/build.gradle.kts
@@ -41,6 +41,7 @@ fun compareVersions(
val echTestPattern = "EchTest"
val echConscryptTestPattern = "EchConscryptTest"
val echClientHelloTestPattern = "EchClientHelloTest"
+val echPlatformTestPattern = "EchPlatformTest"
// The Conscrypt built from `google3-export`, if someone has fetched or built it. It is not on
// any repository — `Conscrypt.setEchConfigList` exists on that branch and in no release — so
@@ -63,7 +64,9 @@ sourceSets {
exclude(
"**/$echConscryptTestPattern.kt",
"**/$echClientHelloTestPattern.kt",
+ "**/$echPlatformTestPattern.kt",
"**/ConscryptEch.kt",
+ "**/EchConscryptPlatform.kt",
)
}
}
@@ -107,6 +110,7 @@ val networkTest =
"**/$echTestPattern.class",
"**/$echConscryptTestPattern.class",
"**/$echClientHelloTestPattern.class",
+ "**/$echPlatformTestPattern.class",
)
reportEndpointsTo("networkTest")
@@ -164,6 +168,31 @@ val echConscryptTest =
}
}
+// The third reading of the same servers, and the one that answers the question the other two
+// only bracket. echTest says OkHttp as shipped doesn't encrypt a client hello on the JVM;
+// echConscryptTest says Conscrypt can, from outside OkHttp. Neither says what happens when the
+// config list OkHttp resolved reaches Conscrypt through OkHttp's own platform, because no
+// platform does that yet. EchConscryptPlatform is one that does, so this task is the difference
+// between echTest and a fixed OkHttp, measured rather than argued.
+val echPlatformTest =
+ tasks.register("echPlatformTest") {
+ group = "verification"
+ description = "Reports whether OkHttp does ECH when its platform makes the Conscrypt call."
+
+ val testSourceSet = sourceSets.test.get()
+ testClassesDirs = testSourceSet.output.classesDirs
+ classpath = testSourceSet.runtimeClasspath
+ include("**/$echPlatformTestPattern.class")
+
+ reportEndpointsTo("echPlatformTest")
+ enabled = supportsEch && hasConscrypt
+ ignoreFailures = true
+
+ doFirst {
+ logger.lifecycle("Testing ECH against OkHttp $okhttpVersion on Conscrypt, through OkHttp's platform")
+ }
+ }
+
if (!supportsEch) {
logger.lifecycle("Skipping EchTest: OkHttp $okhttpVersion predates the ECH API")
}
@@ -173,7 +202,7 @@ if (!hasConscrypt) {
}
tasks.check {
- dependsOn(networkTest, echTest, echConscryptTest)
+ dependsOn(networkTest, echTest, echConscryptTest, echPlatformTest)
}
dependencies {
diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchConscryptPlatform.kt b/network/src/test/kotlin/okhttp/testbed/network/EchConscryptPlatform.kt
new file mode 100644
index 0000000..1e31c07
--- /dev/null
+++ b/network/src/test/kotlin/okhttp/testbed/network/EchConscryptPlatform.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2026 OkHttp Authors
+ *
+ * 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.
+ */
+// USES-OKHTTP-INTERNALS: is a Platform, which OkHttp only declares internally.
+package okhttp.testbed.network
+
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLSocket
+import javax.net.ssl.SSLSocketFactory
+import javax.net.ssl.X509TrustManager
+import okhttp3.Protocol
+import okhttp3.internal.OkHttpInternalApi
+import okhttp3.internal.platform.Platform
+import okio.ByteString
+import org.conscrypt.Conscrypt
+
+/**
+ * OkHttp's `ConscryptPlatform`, plus the one call it doesn't make.
+ *
+ * [EchConscryptTest] shows Conscrypt can encrypt a client hello, but it shows it from a socket
+ * factory — outside OkHttp, on a socket OkHttp then uses. That leaves the interesting question
+ * open, because the config list it passes is one the suite kept for itself rather than the one
+ * OkHttp resolved. This closes it: the config list arrives the way OkHttp delivers it, as the
+ * `echConfigList` argument to [configureTlsExtensions], and the only thing added is the
+ * [Conscrypt.setEchConfigList] call that `ConscryptPlatform` omits.
+ *
+ * So [EchPlatformTest] and [EchTest] make the same requests through the same public API, and
+ * differ only by which platform is installed. What that difference measures is one call.
+ *
+ * Why this exists here rather than upstream: OkHttp's `master` can't compile against a Conscrypt
+ * that has these methods, because no published Conscrypt does. [lysine-dev/okhttp#9559][pr] is
+ * the change on the OkHttp side, and it builds against a Conscrypt built from source. This
+ * reaches the same place from the other direction, with a platform written here.
+ *
+ * It is not a proposal for how OkHttp should do it. `ConscryptPlatform` is `final` and its
+ * constructor is private, so this can't extend it and reimplements the parts it needs instead;
+ * the upstream change is four lines in the class itself, and uses `setEchParameters`, a newer
+ * Conscrypt API than the `setEchConfigList` the `google3-export` build here exposes.
+ *
+ * [pr]: https://github.com/lysine-dev/okhttp/pull/9559
+ */
+@OptIn(OkHttpInternalApi::class)
+class EchConscryptPlatform : Platform() {
+ private val provider = Conscrypt.newProvider()
+
+ override fun newSSLContext(): SSLContext = SSLContext.getInstance("TLS", provider)
+
+ /**
+ * Conscrypt's own, wrapped so Conscrypt can find the policy that permits ECH.
+ *
+ * Both halves are load-bearing and both are covered by [ConscryptEch]: the JDK's trust manager
+ * rejects the authType Conscrypt uses for TLS 1.3, and without the policy Conscrypt treats ECH
+ * as not allowed and sends the hello in the clear whatever is set on the socket.
+ */
+ override fun platformTrustManager(): X509TrustManager = EchEnablingTrustManager(ConscryptEch.platformTrustManager())
+
+ override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = null
+
+ override fun configureTlsExtensions(
+ sslSocket: SSLSocket,
+ hostname: String?,
+ protocols: List,
+ echConfigList: ByteString?,
+ ) {
+ if (!Conscrypt.isConscrypt(sslSocket)) {
+ super.configureTlsExtensions(sslSocket, hostname, protocols, echConfigList)
+ return
+ }
+
+ Conscrypt.setUseSessionTickets(sslSocket, true)
+ Conscrypt.setApplicationProtocols(sslSocket, alpnProtocolNames(protocols).toTypedArray())
+
+ // The line this whole file exists for. `ConscryptPlatform` takes this argument and returns.
+ if (echConfigList != null) {
+ Conscrypt.setEchConfigList(sslSocket, echConfigList.toByteArray())
+ }
+ }
+
+ override fun getSelectedProtocol(sslSocket: SSLSocket): String? =
+ when {
+ Conscrypt.isConscrypt(sslSocket) -> Conscrypt.getApplicationProtocol(sslSocket)
+ else -> super.getSelectedProtocol(sslSocket)
+ }
+
+ override fun newSslSocketFactory(trustManager: X509TrustManager): SSLSocketFactory =
+ newSSLContext()
+ .apply { init(null, arrayOf(trustManager), null) }
+ .socketFactory
+
+ override fun toString(): String = "EchConscryptPlatform"
+
+ companion object {
+ /**
+ * Makes this the platform every client built afterwards will use.
+ *
+ * `Platform.get()` is a process-wide singleton, read when a client builds its socket factory,
+ * so a client built before this call keeps the platform it was built with. Pair with
+ * [uninstall]; both live here so that this file stays the only one reaching into
+ * `okhttp3.internal`.
+ */
+ fun install() = Platform.resetForTests(EchConscryptPlatform())
+
+ /** Puts back whichever platform OkHttp would have chosen for itself. */
+ fun uninstall() = Platform.resetForTests()
+ }
+}
diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchPlatformTest.kt b/network/src/test/kotlin/okhttp/testbed/network/EchPlatformTest.kt
new file mode 100644
index 0000000..9875294
--- /dev/null
+++ b/network/src/test/kotlin/okhttp/testbed/network/EchPlatformTest.kt
@@ -0,0 +1,127 @@
+/*
+ * Copyright (c) 2026 OkHttp Authors
+ *
+ * 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.doesNotContain
+import assertk.assertions.isNotNull
+import okhttp3.HttpUrl.Companion.toHttpUrl
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.dnsoverhttps.DnsOverHttps
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assumptions.assumeTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+
+/**
+ * [EchTest], with [EchConscryptPlatform] installed.
+ *
+ * The requests, the resolver and the assertions are [EchTest]'s. The client is built the same
+ * way, from public API, with nothing configured on it — no socket factory, no trust manager, no
+ * interception of the handshake. The single difference is which `Platform` OkHttp finds when it
+ * builds that client, and the single difference between the platforms is one call to
+ * `Conscrypt.setEchConfigList`.
+ *
+ * Which makes the pair the measurement. [EchTest] fails its `sni=encrypted` assertions and this
+ * passes them; everything else about the two runs is the same, so the difference between them is
+ * that call and nothing else. [EchConscryptTest] answers a nearby but weaker question — it
+ * reaches the same servers through a socket factory of its own, so it shows Conscrypt can do ECH
+ * without showing that OkHttp's own path would carry it.
+ *
+ * Two of [EchTest]'s cases are deliberately not here. `echIsRetriedOnStaleTlsEchDev` and
+ * `tlsIsNotUsedOnTls12TlsEchDev` both need a server's rejection to be read back and retried, and
+ * that needs `SSL_get0_ech_retry_configs`, which Conscrypt exposes on Android and not on the JVM.
+ * No platform written here can supply it. [EchConscryptTest] already records that gap; repeating
+ * it in a third suite would report one missing feature three times.
+ */
+@RequiresEndpoint(Endpoint.CLOUDFLARE_DOH)
+class EchPlatformTest {
+ private lateinit var client: OkHttpClient
+
+ @BeforeEach
+ fun setUp() {
+ assumeTrue(ConscryptEch.isSupported) {
+ "requires a Conscrypt with ECH. Run conscrypt/fetch-conscrypt.sh."
+ }
+
+ // Before the clients are built: a client keeps the platform it was built with.
+ EchConscryptPlatform.install()
+
+ val bootstrapClient = OkHttpClient()
+
+ val dns =
+ DnsOverHttps
+ .Builder()
+ .client(bootstrapClient)
+ .url("https://1.1.1.1/dns-query".toHttpUrl())
+ .includeServiceMetadata(true)
+ .build()
+
+ client =
+ bootstrapClient
+ .newBuilder()
+ .addNetworkInterceptor(RouteTagger)
+ .dns(dns)
+ .build()
+ }
+
+ @AfterEach
+ fun tearDown() {
+ EchConscryptPlatform.uninstall()
+ }
+
+ @Test
+ @RequiresEndpoint(Endpoint.CLOUDFLARE_ECH)
+ fun cloudflareUsesEch() {
+ val call = client.newCall(Request("https://cloudflare-ech.com/cdn-cgi/trace".toHttpUrl()))
+ call.execute().use { response ->
+ assertThat(call.routeList.routes.single().echConfigList).isNotNull()
+
+ val body = response.body.string()
+ assertThat(body).contains("sni=encrypted")
+ }
+ }
+
+ @Test
+ @RequiresEndpoint(Endpoint.TLS_ECH_DEV)
+ fun echIsAcceptedOnTlsEchDev() {
+ val call = client.newCall(Request("https://tls-ech.dev/".toHttpUrl()))
+ call.execute().use { response ->
+ assertThat(call.routeList.routes.single().echConfigList).isNotNull()
+
+ val body = response.body.string()
+
+ // Only the heading identifies the server we reached; every page links to all of the others.
+ assertThat(body).contains("tls-ech.dev
")
+ assertThat(body).contains("You are using ECH")
+ assertThat(body).doesNotContain("not using ECH")
+ }
+ }
+
+ @Test
+ @RequiresEndpoint(Endpoint.DEFO_IE)
+ fun echIsAcceptedOnDefoIe() {
+ val call = client.newCall(Request("https://defo.ie/ech-check.php".toHttpUrl()))
+ call.execute().use { response ->
+ assertThat(call.routeList.routes.single().echConfigList).isNotNull()
+
+ val body = response.body.string()
+ assertThat(body).contains("SSL_ECH_STATUS: success")
+ }
+ }
+}
diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchTest.kt b/network/src/test/kotlin/okhttp/testbed/network/EchTest.kt
index 200bfcb..1a531aa 100644
--- a/network/src/test/kotlin/okhttp/testbed/network/EchTest.kt
+++ b/network/src/test/kotlin/okhttp/testbed/network/EchTest.kt
@@ -182,7 +182,7 @@ class EchTest {
* Collect Route information to confirm we sent an ECH config list to our TLS stack. Whether we
* actually encrypted the client hello depends on our TLS stack.
*/
-private object RouteTagger : Interceptor {
+internal object RouteTagger : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val routeList = chain.call().routeList
routeList.routes += chain.connection()!!.route()
@@ -190,12 +190,12 @@ private object RouteTagger : Interceptor {
}
}
-private val Call.routeList: RouteList
+internal val Call.routeList: RouteList
get() = tag(RouteList::class) { RouteList() }
/**
* All of the routes used to retrieve an HTTP response.
*/
-private class RouteList {
+internal class RouteList {
val routes = mutableListOf()
}
diff --git a/site/assets/ech-results.js b/site/assets/ech-results.js
new file mode 100644
index 0000000..fba4717
--- /dev/null
+++ b/site/assets/ech-results.js
@@ -0,0 +1,183 @@
+// Renders the ECH results matrix from latest.json.
+//
+// The question this page exists to answer is "does ECH work, where?", and that is a grid: one
+// row per server, one column per way of reaching it. The four suites make the same requests
+// and differ only in what is doing the TLS, so reading them side by side is what turns four
+// separate results into one finding.
+//
+// Case names differ between suites for the same server, so the mapping is written out rather
+// than guessed. A blank cell means that suite doesn't cover that server, which is a fact worth
+// showing — it is why three of the rows have gaps.
+
+const SUITES = [
+ {
+ suite: 'EchTest',
+ heading: 'OkHttp as shipped',
+ note: 'JVM, no Conscrypt',
+ },
+ {
+ suite: 'EchPlatformTest',
+ heading: 'OkHttp + the missing call',
+ note: 'JVM, EchConscryptPlatform',
+ },
+ {
+ suite: 'EchConscryptTest',
+ heading: 'Conscrypt directly',
+ note: 'JVM, outside OkHttp',
+ },
+ {
+ suite: 'PublicEncryptedClientHelloTest',
+ heading: 'OkHttp on Android',
+ note: 'the platform makes the call',
+ },
+];
+
+const ROWS = [
+ {
+ server: 'cloudflare-ech.com',
+ asserts: 'the server reports sni=encrypted',
+ cases: {
+ EchTest: 'cloudflareUsesEch',
+ EchPlatformTest: 'cloudflareUsesEch',
+ EchConscryptTest: 'cloudflareAcceptsAnEncryptedClientHello',
+ PublicEncryptedClientHelloTest: 'cloudflareUsesEch',
+ },
+ },
+ {
+ server: 'tls-ech.dev',
+ asserts: 'the page says "You are using ECH"',
+ cases: {
+ EchTest: 'echIsAcceptedOnTlsEchDev',
+ EchPlatformTest: 'echIsAcceptedOnTlsEchDev',
+ EchConscryptTest: 'tlsEchDevAcceptsAnEncryptedClientHello',
+ PublicEncryptedClientHelloTest: 'echIsAcceptedOnTlsEchDev',
+ },
+ },
+ {
+ server: 'defo.ie',
+ asserts: 'SSL_ECH_STATUS: success',
+ cases: {
+ EchTest: 'echIsAcceptedOnDefoIe',
+ EchPlatformTest: 'echIsAcceptedOnDefoIe',
+ EchConscryptTest: 'defoIeAcceptsAnEncryptedClientHello',
+ PublicEncryptedClientHelloTest: 'echIsAcceptedOnDefoIe',
+ },
+ },
+ {
+ server: 'stale.tls-ech.dev',
+ asserts: 'a stale config is retried with the server’s',
+ cases: {
+ EchTest: 'echIsRetriedOnStaleTlsEchDev',
+ PublicEncryptedClientHelloTest: 'echIsRetriedOnStaleTlsEchDev',
+ },
+ },
+ {
+ server: 'tls12.tls-ech.dev',
+ asserts: 'TLS 1.2 is reached without ECH rather than failing',
+ cases: {
+ EchTest: 'tlsIsNotUsedOnTls12TlsEchDev',
+ EchConscryptTest: 'tls12IsReachedWithoutEch',
+ PublicEncryptedClientHelloTest: 'tlsIsNotUsedOnTls12TlsEchDev',
+ },
+ },
+ {
+ server: 'wrong.tls-ech.dev',
+ asserts: 'the redirect is followed and the name verified',
+ cases: {
+ EchTest: 'echIsAcceptedOnWrongTlsEchDev',
+ PublicEncryptedClientHelloTest: 'echIsAcceptedOnWrongTlsEchDev',
+ },
+ },
+];
+
+// Android's runner appends the device to every case name.
+const bareName = (name) => name.replace(/\s*\[.*\]\s*$/, '');
+
+const text = (el, value) => {
+ el.textContent = value;
+ return el;
+};
+
+function findSuites(data) {
+ const found = new Map();
+ for (const version of data.versions || []) {
+ for (const suite of version.suites || []) {
+ // A version testing ECH is a version with these suites in it; if two versions both ran
+ // one, the later-sorted (snapshot) wins, which is the one that can do ECH at all.
+ found.set(suite.name, { suite, version });
+ }
+ }
+ return found;
+}
+
+function cellFor(entry, caseName) {
+ if (!entry || !caseName) return null;
+ return (entry.suite.cases || []).find((c) => bareName(c.name) === caseName) || null;
+}
+
+function render(data, root) {
+ const found = findSuites(data);
+ const columns = SUITES.filter((c) => found.has(c.suite));
+
+ if (!columns.length) {
+ text(root, 'No ECH results in the most recent collection.');
+ return;
+ }
+
+ const table = document.createElement('table');
+
+ const head = table.createTHead().insertRow();
+ text(head.insertCell(), 'Server');
+ text(head.insertCell(), 'What passes means');
+ for (const column of columns) {
+ const cell = head.insertCell();
+ const strong = document.createElement('strong');
+ text(strong, column.heading);
+ cell.append(strong, document.createElement('br'));
+
+ // The platform is read from the run rather than written here, so a runner image change
+ // or a different emulator shows up as a different platform instead of a stale label.
+ const small = document.createElement('small');
+ text(small, found.get(column.suite).suite.platform || column.note);
+ cell.append(small);
+ }
+
+ const body = table.createTBody();
+ for (const row of ROWS) {
+ const tr = body.insertRow();
+ const server = tr.insertCell();
+ server.className = 'mono';
+ text(server, row.server);
+ text(tr.insertCell(), row.asserts);
+
+ for (const column of columns) {
+ const cell = tr.insertCell();
+ const result = cellFor(found.get(column.suite), row.cases[column.suite]);
+ if (!result) {
+ text(cell, '—');
+ cell.title = 'not covered by this suite';
+ continue;
+ }
+ const pill = document.createElement('span');
+ // A failure here is a finding rather than breakage: every one of these calls a server
+ // somebody else runs, and the suites are the reporting kind. The page says so in words
+ // under the table rather than colouring a real failure green.
+ pill.className = `pill ${result.status === 'failed' ? 'finding' : result.status}`;
+ text(pill, result.status === 'failed' ? 'no' : result.status === 'passed' ? 'yes' : result.status);
+ if (result.message) pill.title = result.message;
+ cell.append(pill);
+ }
+ }
+
+ const scroll = document.createElement('div');
+ scroll.className = 'table-scroll';
+ scroll.append(table);
+ root.replaceChildren(scroll);
+}
+
+fetch('../data/latest.json')
+ .then((response) => (response.ok ? response.json() : Promise.reject(response.status)))
+ .then((data) => render(data, document.getElementById('ech-matrix')))
+ .catch(() => {
+ text(document.getElementById('ech-matrix'), 'Results are unavailable — latest.json could not be read.');
+ });
diff --git a/site/tools/collect_results.py b/site/tools/collect_results.py
index 2bb72e8..44166a9 100644
--- a/site/tools/collect_results.py
+++ b/site/tools/collect_results.py
@@ -45,7 +45,14 @@
import xml.etree.ElementTree as ElementTree
# Gradle test tasks whose failures are findings about OkHttp rather than breakage here.
-REPORTING_TASKS = {"loomTest", "hostileTest", "echTest", "echConscryptTest", "networkTest"}
+REPORTING_TASKS = {
+ "loomTest",
+ "hostileTest",
+ "echTest",
+ "echConscryptTest",
+ "echPlatformTest",
+ "networkTest",
+}
# The same distinction for suites that can't make it with a task name. Android instrumentation
# runs under one task whatever it is testing, so the Android suite that calls tls-ech.dev and
@@ -58,7 +65,7 @@
HISTORY_LIMIT = 120
-def parse_suite(path: pathlib.Path, task: str, workflow: str, run_url: str) -> dict:
+def parse_suite(path: pathlib.Path, task: str, workflow: str, run_url: str, platform: str) -> dict:
"""Read one JUnit XML file into a suite record."""
root = ElementTree.parse(path).getroot()
# Gradle writes a single per file, but a wrapper is legal.
@@ -104,6 +111,9 @@ def parse_suite(path: pathlib.Path, task: str, workflow: str, run_url: str) -> d
"workflow": workflow,
"runUrl": run_url,
"task": task,
+ # Carried per suite rather than per version: a version card merges an Android artifact
+ # and a JVM one, and "which platform" is then a property of the suite, not of the card.
+ "platform": platform,
"reporting": task in REPORTING_TASKS or simple_name in REPORTING_CLASSES,
"timeSeconds": float(root.get("time") or 0.0),
"passed": sum(1 for c in cases if c["status"] == "passed"),
@@ -193,6 +203,10 @@ def parse_artifact(directory: pathlib.Path) -> dict | None:
workflow = metadata.get("workflow", "unknown")
run_url = metadata.get("runUrl", "")
+ # Runs from before this was recorded fall back to the Java version they did carry.
+ platform = metadata.get("platform") or (
+ f"JDK {metadata['javaVersion']}" if metadata.get("javaVersion") else ""
+ )
# The container workflow lays its XML out by Gradle task, which is the distinction the
# page needs. The Android suite lays it out by device instead, so its task comes from
@@ -204,7 +218,7 @@ def parse_artifact(directory: pathlib.Path) -> dict | None:
for xml in sorted(directory.rglob("*.xml")):
task = xml.parent.name if task_from_path and xml.parent != directory else default_task
try:
- suites.append(parse_suite(xml, task, workflow, run_url))
+ suites.append(parse_suite(xml, task, workflow, run_url, platform))
except ElementTree.ParseError as e:
print(f"skipping unreadable {xml}: {e}", file=sys.stderr)
@@ -218,7 +232,7 @@ def parse_artifact(directory: pathlib.Path) -> dict | None:
"workflow": workflow,
"label": label,
"okhttpVersion": metadata.get("okhttpVersion", label),
- "javaVersion": metadata.get("javaVersion", ""),
+ "platform": platform,
"jobStatus": metadata.get("jobStatus", ""),
"runNumber": metadata.get("runNumber", 0),
"runUrl": run_url,
@@ -239,13 +253,15 @@ def group_by_version(artifacts: list[dict]) -> list[dict]:
{
"okhttpVersion": artifact["okhttpVersion"],
"label": artifact["label"],
- "javaVersion": artifact["javaVersion"],
+ "platforms": [],
"workflows": [],
"suites": [],
},
)
if artifact["workflow"] not in version["workflows"]:
version["workflows"].append(artifact["workflow"])
+ if artifact["platform"] and artifact["platform"] not in version["platforms"]:
+ version["platforms"].append(artifact["platform"])
version["suites"].extend(artifact["suites"])
for version in versions.values():
diff --git a/site/topics/ech.html b/site/topics/ech.html
index b353704..cfd7749 100644
--- a/site/topics/ech.html
+++ b/site/topics/ech.html
@@ -37,6 +37,21 @@ Encrypted Client Hello
which is why it is here.
+ Where ECH works today
+
+ One row per server, one column per way of reaching it. The suites make the same requests and
+ differ only in what does the TLS, so the columns are readable against each other: the gap
+ between OkHttp as shipped and OkHttp + the missing call is one call to
+ Conscrypt.setEchConfigList, and nothing else.
+
+ Loading results…
+
+ A no here is a finding, not a broken build. Every case calls
+ a server someone else operates, so these suites record their results rather than gating — see
+ the status page for the run they came from, and
+ test servers for who runs what.
+
+
Why it lives here
A ClientHello is only meaningfully encrypted if every link holds: the resolver returns an
@@ -282,6 +297,8 @@
Reading a result
+
+