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

+ +