diff --git a/.github/workflows/android-ech.yml b/.github/workflows/android-ech.yml index 9b86759..e8c7915 100644 --- a/.github/workflows/android-ech.yml +++ b/.github/workflows/android-ech.yml @@ -110,6 +110,6 @@ jobs: name: android-ech-test-results-${{ inputs.okhttpVersion || 'pinned-snapshot' }} path: | android-ech/build/run-metadata.json - android-ech/build/outputs/androidTest-results/connected/**/*.xml + android-ech/build/outputs/androidTest-results/connected*/**/*.xml android-ech/build/reports/androidTests/connected/ retention-days: 30 diff --git a/.github/workflows/conscrypt.yml b/.github/workflows/conscrypt.yml new file mode 100644 index 0000000..723dbff --- /dev/null +++ b/.github/workflows/conscrypt.yml @@ -0,0 +1,124 @@ +name: conscrypt + +# Builds the Conscrypt the ECH suites need, and caches it as a release on this repository. +# +# Not on every commit, and not as part of any test workflow: the build takes several minutes, +# needs a C++ toolchain, a cross compiler and two source trees, and its output depends +# on nothing in this repository except `conscrypt/pinned.properties`. So it runs when that file +# changes, or on demand, and everything else downloads what it published. The release tag carries +# both pinned shas, which makes the cache self-invalidating: bump a pin and the tag no longer +# exists, so this workflow builds and publishes it, and the suites pick it up on their next run. +# +# See conscrypt/README.md for why this is being built from a branch at all. +on: + push: + branches: + - main + paths: + - 'conscrypt/**' + - '.github/workflows/conscrypt.yml' + pull_request: + paths: + - 'conscrypt/**' + - '.github/workflows/conscrypt.yml' + workflow_dispatch: + inputs: + force: + description: 'Rebuild and replace the release even if the tag already exists' + required: false + default: false + type: boolean + +permissions: + contents: write + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + +jobs: + conscrypt: + name: conscrypt + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + # Conscrypt's own build wants a JDK to compile against and JNI headers to build against. + - name: Configure JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 17 + + - name: Resolve the release tag + id: pin + run: | + echo "tag=$(conscrypt/release-tag.sh)" >> "$GITHUB_OUTPUT" + + # A release for these two shas is the whole point of the cache, so having one already is + # the good outcome, not a reason to do the work again. `force` is for the case where a + # published asset is wrong rather than missing. + - name: Check for an existing release + id: existing + env: + GH_TOKEN: ${{ github.token }} + run: | + if gh release view "${{ steps.pin.outputs.tag }}" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then + echo "exists=true" >> "$GITHUB_OUTPUT" + else + echo "exists=false" >> "$GITHUB_OUTPUT" + fi + + - name: Install the build toolchain + if: steps.existing.outputs.exists == 'false' || inputs.force + run: | + sudo apt-get -qq update + sudo apt-get -qq install -y --no-install-recommends \ + clang \ + cmake \ + g++-aarch64-linux-gnu \ + binutils-aarch64-linux-gnu \ + ninja-build + + - name: Build Conscrypt + if: steps.existing.outputs.exists == 'false' || inputs.force + run: conscrypt/build-conscrypt.sh + + # A pull request touching the pin gets the build checked and nothing published: a release + # is a fact about the repository, and a proposed pin isn't one yet. + - name: Publish the release + if: >- + github.event_name != 'pull_request' + && (steps.existing.outputs.exists == 'false' || inputs.force) + env: + GH_TOKEN: ${{ github.token }} + run: | + tag='${{ steps.pin.outputs.tag }}' + gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag || true + gh release create "$tag" \ + --repo "$GITHUB_REPOSITORY" \ + --title "Conscrypt for ECH ($tag)" \ + --notes-file conscrypt/RELEASE_NOTES.md \ + conscrypt/build/dist/* + + # The suites consume this by tag, so a run that built something is worth proving can be + # fetched back — including on a pull request, where it exercises the fallback path. + - name: Upload the build + if: always() && (steps.existing.outputs.exists == 'false' || inputs.force) + uses: actions/upload-artifact@v4 + with: + name: conscrypt-${{ steps.pin.outputs.tag }} + path: conscrypt/build/dist/ + retention-days: 30 + if-no-files-found: warn + + - name: Report + run: | + if [ '${{ steps.existing.outputs.exists }}' = 'true' ] && [ '${{ inputs.force }}' != 'true' ]; then + echo "Release ${{ steps.pin.outputs.tag }} already exists; nothing to build." >> "$GITHUB_STEP_SUMMARY" + else + echo "Built ${{ steps.pin.outputs.tag }}." >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/network.yml b/.github/workflows/network.yml index 5e45de0..69a176e 100644 --- a/.github/workflows/network.yml +++ b/.github/workflows/network.yml @@ -58,6 +58,13 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 + # Without this the Conscrypt suites are left out of the source set and a change that + # breaks them compiles clean. Failure is not fatal here: a pull request that bumps the + # pin has no release to fetch yet, and the conscrypt workflow is what builds that one. + - name: Fetch Conscrypt + continue-on-error: true + run: conscrypt/fetch-conscrypt.sh + - name: Compile Network Suites run: ./gradlew network:compileTestKotlin network:checkPublicApiOnly @@ -95,13 +102,21 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 + # The ECH suites' other half: a Conscrypt that can encrypt a client hello, which is not + # published anywhere. Downloaded rather than built — the build is its own workflow, and + # this one runs daily. A missing release leaves echConscryptTest out of the build and + # the rest of the run unaffected, which is why this doesn't fail the job. + - name: Fetch Conscrypt + continue-on-error: true + run: conscrypt/fetch-conscrypt.sh + - name: Run Network Tests - # Neither task gates — both carry ignoreFailures, because everything here calls a + # No task here gates — all of them carry ignoreFailures, because everything here calls a # server someone else operates. --continue still earns its place: it covers the # failures that happen before a test runs, so one suite failing to compile or to - # resolve doesn't rob the other of a result. + # resolve doesn't rob the others of a result. run: > - ./gradlew network:networkTest network:echTest --continue + ./gradlew network:networkTest network:echTest network:echConscryptTest --continue ${{ matrix.okhttpVersion && format('-PokhttpVersion={0}', matrix.okhttpVersion) || '' }} # What the XML can't say: which OkHttp version 'pinned' actually resolved to, and diff --git a/README.md b/README.md index 79d45de..01fd044 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Suites |---------------|-----------------------------|---------------------------------------------------------------------| | `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, virtual threads (Loom) | | `network` | Outbound network | ALPN and SNI overrides, Let's Encrypt trust, ECH on the public servers | -| `android-ech` | Docker, an API 37 emulator | Encrypted Client Hello over DoH: accepted, retried, and declined | +| `android-ech` | Docker, an API 37 emulator | Encrypted Client Hello over DoH: accepted, retried, and declined, plus the public servers | The `network` suites call servers other people operate — Google, Cloudflare, Let's Encrypt, and the ECH test servers at `tls-ech.dev` and `defo.ie`. They came from OkHttp's @@ -89,10 +89,11 @@ Requires Docker and JDK 21+. ``` ./gradlew containers:test containers:loomTest ./gradlew network:networkTest network:echTest +./gradlew network:echConscryptTest # after conscrypt/fetch-conscrypt.sh; see ECH on the JVM ``` -`test` covers the gating suites and fails the build. `loomTest`, `networkTest` and `echTest` -all run with `ignoreFailures`, because what they report is not this repository being broken — +`test` covers the gating suites and fails the build. `loomTest`, `networkTest`, `echTest` and +`echConscryptTest` all run with `ignoreFailures`, because what they report is not this repository being broken — see [Suites that report rather than gate](#suites-that-report-rather-than-gate). `network` has no gating task at all: its `test` task is disabled, so those two are the only way to run it. @@ -187,14 +188,52 @@ A and AAAA records only, so there is no HTTPS record to carry an ECH config list at whatever you like with `-PokhttpVersion`, and drop `ech-okhttp` once a release ships the API. +`android-ech` also runs `PublicEncryptedClientHelloTest`, which is `EchTest`'s cases against +the same public servers the JVM suite calls — `tls-ech.dev`, `defo.ie`, `cloudflare-ech.com`. +It is there so the public-server results can be read across platforms: the JVM row of the +status page and the Android row are then the same assertions against the same servers, and +the only variable left between them is the TLS stack. It runs first, and its failures do not +fail the job, for the same reason nothing in `network` gates — those servers belong to other +people. The fixture suite that follows it does gate. + ECH is Android-only in OkHttp today: JVM platforms accept the config list and ignore it. The `network` suite is where that shows up, from the other direction — `EchTest` came from OkHttp's `android-test` with its assertions intact, so on the JVM the route assertions pass, the assertions about what the server saw fail, and the difference is recorded rather than fixed up. See [Suites that report rather than gate](#suites-that-report-rather-than-gate). -The two ECH suites are not duplicates: `android-ech` proves the client behaviour against a -fixture nobody else can change, and `network` is what notices when `tls-ech.dev` or `defo.ie` -does change. +The ECH suites are not duplicates: `android-ech` proves the client behaviour against a +fixture nobody else can change, `network` is what notices when `tls-ech.dev` or `defo.ie` +does change, and `echConscryptTest` below is what says *why* the JVM ones are red. + +ECH on the JVM +-------------- + +`network:echTest` cannot pass on the JVM, and it is worth being precise about what is +missing, because it is less than it looks. + +There is no published TLS stack a JVM can load that will encrypt a client hello. Conscrypt's +`google3-export` branch has one — `Conscrypt.setEchConfigList(SSLSocket, byte[])` is public +API there and in no release. `conscrypt/` builds that branch and caches the result as a +release on this repository, and `network:echConscryptTest` runs the ECH cases against it: + +``` +conscrypt/fetch-conscrypt.sh +./gradlew network:echConscryptTest -PokhttpVersion=5.5.0-SNAPSHOT +``` + +Two suites run under that task. `EchClientHelloTest` reads the bytes of the client hello +against a local socket that accepts a connection and says nothing — no DNS, no internet, no +server — and asserts that the name is not in them. `EchConscryptTest` is `EchTest`'s cases +against the public servers, with the two things the JVM lacks supplied from outside OkHttp: +this Conscrypt, and a network security policy saying ECH is allowed. When those pass and +`echTest` doesn't, the difference between them is one call OkHttp's `ConscryptPlatform` +doesn't make. It is not a claim that OkHttp does ECH on the JVM — the suite makes that call +itself, from a socket factory, precisely because OkHttp doesn't. + +The whole arrangement is temporary and `conscrypt/` should be deleted the day Conscrypt ships +ECH. [`conscrypt/README.md`](conscrypt/README.md) has the detail: what is missing where, why +the build is cached as a release rather than run per commit, and why the stale-config retry +case has no counterpart on the JVM at all. That suite is also the one place a version matters to compilation. `Route.echConfigList` and `DnsOverHttps.Builder.includeServiceMetadata` arrived after 5.4.0, so diff --git a/android-ech/build.gradle.kts b/android-ech/build.gradle.kts index 6feca96..28bcb63 100644 --- a/android-ech/build.gradle.kts +++ b/android-ech/build.gradle.kts @@ -55,6 +55,12 @@ dependencies { androidTestImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") androidTestImplementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") + // Not optional, despite nothing here naming it. On Android the public suffix list is read + // from `assets/PublicSuffixDatabase.list`, which only this artifact ships; without it every + // `DnsOverHttps` query throws from `isPrivateHost` before a connection is attempted, and the + // whole suite fails on something that has nothing to do with ECH. + androidTestImplementation("com.squareup.okhttp3:okhttp-android:$okhttpVersion") + androidTestImplementation(libs.assertk) androidTestImplementation(libs.junit.jupiter.api) androidTestImplementation(libs.junit5android.core) diff --git a/android-ech/run-ech-test.sh b/android-ech/run-ech-test.sh index 8d10740..4552837 100755 --- a/android-ech/run-ech-test.sh +++ b/android-ech/run-ech-test.sh @@ -92,9 +92,76 @@ adb reverse tcp:8053 "tcp:$doh_host_port" adb reverse tcp:443 "tcp:$target_host_port" adb reverse tcp:8443 "tcp:$target_host_port" -"$repository_root/gradlew" -p "$repository_root" :android-ech:connectedDebugAndroidTest \ - "${gradle_arguments[@]}" \ - -Pandroid.testInstrumentationRunnerArguments.class=okhttp.testbed.android.ech.EncryptedClientHelloTest \ +# One instrumentation run per suite, because the two report differently and Gradle writes both +# to the same place. `results_dir` is moved aside after each run so the workflow can upload them +# together; without that the second run would overwrite the first. +results_dir="$repository_root/android-ech/build/outputs/androidTest-results/connected" + +run_suite() { + local class="$1" + shift + local status=0 + + rm -rf "$results_dir" + # `|| status=$?` rather than a bare call: this runs under `set -e`, and a failing suite whose + # results were never moved aside is a failing suite nobody can read. + "$repository_root/gradlew" -p "$repository_root" :android-ech:connectedDebugAndroidTest \ + "${gradle_arguments[@]}" \ + -Pandroid.testInstrumentationRunnerArguments.class="okhttp.testbed.android.ech.$class" \ + "$@" || status=$? + + # A run that produced no results at all didn't fail its assertions — it never got as far as + # running them. The way that happens here is an APK install against an emulator whose package + # service is still coming up, which answers `Broken pipe` and leaves Gradle reporting zero + # tests. Retried once, because a suite that reported nothing is worse than a slow job: it + # looks like a pass on the status page and is not one. + if [ "$status" -ne 0 ] && [ ! -d "$results_dir" ]; then + echo "$class produced no results; retrying once." >&2 + status=0 + "$repository_root/gradlew" -p "$repository_root" :android-ech:connectedDebugAndroidTest \ + "${gradle_arguments[@]}" \ + -Pandroid.testInstrumentationRunnerArguments.class="okhttp.testbed.android.ech.$class" \ + "$@" || status=$? + fi + + if [ -d "$results_dir" ]; then + rm -rf "$results_dir-$class" + mv "$results_dir" "$results_dir-$class" + fi + return $status +} + +# The emulator reports itself booted before its package service will accept an install, and the +# first `connectedDebugAndroidTest` of a run is what meets that. Waiting for `pm` to answer is +# the check that matches the failure — `sys.boot_completed` on its own is already true when the +# install fails. Best effort: on a machine where this can't be asked, the retry above still +# covers it. +wait_for_package_service() { + adb wait-for-device || return 0 + for _ in $(seq 1 90); do + if adb shell pm path android >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + echo "Timed out waiting for the device's package service; running anyway." >&2 +} + +wait_for_package_service + +# The public servers first, and not allowed to fail the run. tls-ech.dev, defo.ie and +# cloudflare-ech.com belong to other people; an outage there is not a result about OkHttp, and +# the JVM `network` suites treat the same servers the same way. The XML still records what +# happened, which is what the status page reads. +public_status=0 +run_suite PublicEncryptedClientHelloTest || public_status=$? +if [ "$public_status" -ne 0 ]; then + echo "PublicEncryptedClientHelloTest failed; recorded, not fatal." >&2 +fi + +# The fixture suite does gate: it runs against containers this repository starts, so a failure +# is about OkHttp or about this repository, and there is nobody else to blame for it. +run_suite EncryptedClientHelloTest \ -Pandroid.testInstrumentationRunnerArguments.ech=true \ -Pandroid.testInstrumentationRunnerArguments.dohPort=8053 \ -Pandroid.testInstrumentationRunnerArguments.caCertificate="$ca_certificate" diff --git a/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/PublicEncryptedClientHelloTest.kt b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/PublicEncryptedClientHelloTest.kt new file mode 100644 index 0000000..16da8d2 --- /dev/null +++ b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/PublicEncryptedClientHelloTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright (C) 2026 Block, 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.android.ech + +import android.os.Build +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.hasSize +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import okhttp3.Call +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import okhttp3.dnsoverhttps.DnsOverHttps +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * [okhttp.testbed.network.EchTest]'s cases, on the one platform where they can all pass. + * + * The JVM suite reaches the same servers through the JDK's TLS stack, which cannot encrypt a + * client hello: its route assertions pass and its assertions about what the server saw do not. + * That is a finding about the platform rather than about OkHttp, and it is only legible against + * a platform where the whole thing works. This is that platform — Android 16 QPR2, API 37, where + * `android.net.ssl.EchConfigList` is what OkHttp's Android platform hands the config list to. + * + * [EncryptedClientHelloTest] covers the same feature against containers on the host, which is a + * different job: it can arrange a stale config and a server that refuses to offer a new one, and + * it doesn't depend on anyone else's uptime. This one covers the servers real clients meet, and + * makes the public-server results comparable across the JVM and Android rows of the status page. + * + * Two cases from the JVM suite don't cross over in the other direction, and remain in + * OkHttp's own `android-test`: the `AndroidDns` variant, and the one covering a host excluded by + * `network_security_config.xml`. Both are Android platform behaviour, not OkHttp's. + */ +class PublicEncryptedClientHelloTest { + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + assumeTrue(Build.VERSION.SDK_INT >= 37, "ECH requires Android API 37") + + val bootstrapClient = OkHttpClient() + + // DNS server is addressed by IP, so resolving the resolver doesn't need a resolver. + val dns = + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + // HTTPS records, which is where the ECH config list arrives. + .includeServiceMetadata(true) + .build() + + client = + bootstrapClient + .newBuilder() + .addNetworkInterceptor(RouteTagger) + .dns(dns) + .build() + } + + @Test + 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 + 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 + fun echIsRetriedOnStaleTlsEchDev() { + val call = client.newCall(Request("https://stale.tls-ech.dev/".toHttpUrl())) + call.execute().use { response -> + val routes = call.routeList.routes + assertThat(routes).hasSize(2) + assertThat(routes[0].echConfigList).isNotNull() + assertThat(routes[1].echConfigList).isNotNull() + + val body = response.body.string() + assertThat(body).contains("

stale.tls-ech.dev

") + assertThat(body).contains("You are using ECH") + assertThat(body).doesNotContain("not using ECH") + } + } + + /** + * This page redirects to 'https://wrong.tls-ech.dev:445/', but nothing is listening on that port + * on that server. + */ + @Test + fun echIsAcceptedOnWrongTlsEchDev() { + val verifiedHostnames = mutableListOf() + val hostnameVerifier = client.hostnameVerifier + val client = + client + .newBuilder() + .hostnameVerifier { hostname, session -> + verifiedHostnames += hostname + hostnameVerifier.verify(hostname, session) + }.followRedirects(false) + .build() + + val call = client.newCall(Request("https://wrong.tls-ech.dev/".toHttpUrl())) + call.execute().use { response -> + assertThat(call.routeList.routes.single().echConfigList).isNotNull() + + assertThat(response.code).isEqualTo(302) + assertThat(response.headers["Location"]) + .isEqualTo("https://wrong.tls-ech.dev:445/") + assertThat(verifiedHostnames).contains("wrong.tls-ech.dev") + } + } + + /** TLS 1.2 cannot carry ECH. */ + @Test + fun tlsIsNotUsedOnTls12TlsEchDev() { + val call = client.newCall(Request("https://tls12.tls-ech.dev/".toHttpUrl())) + call.execute().use { response -> + val routes = call.routeList.routes + assertThat(routes).hasSize(2) + assertThat(routes[0].echConfigList).isNotNull() + assertThat(routes[1].echConfigList).isNull() + + val body = response.body.string() + assertThat(body).contains("

tls12.tls-ech.dev

") + assertThat(body).contains("You are not using ECH") + assertThat(body).doesNotContain("You are using ECH") + } + } + + @Test + 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") + } + } +} + +/** + * Collect Route information to confirm we sent an ECH config list to our TLS stack. Unlike the + * JVM suite, here the assertions about what the *server* saw are expected to hold too. + */ +private object RouteTagger : Interceptor { + override fun intercept(chain: Interceptor.Chain): Response { + val routeList = chain.call().routeList + routeList.routes += chain.connection()!!.route() + return chain.proceed(chain.request()) + } +} + +private val Call.routeList: RouteList + get() = tag(RouteList::class) { RouteList() } + +/** All of the routes used to retrieve an HTTP response. */ +private class RouteList { + val routes = mutableListOf() +} diff --git a/conscrypt/README.md b/conscrypt/README.md new file mode 100644 index 0000000..c62e720 --- /dev/null +++ b/conscrypt/README.md @@ -0,0 +1,86 @@ +Conscrypt, for ECH +================== + +A build of Conscrypt's OpenJDK artifact from a branch, cached as a release on this repository. +It exists for one reason: **there is no published TLS stack a JVM can load that will encrypt a +client hello**, so `network:echTest` cannot pass on the JVM, and without something like this +there is no way to say whether that is OkHttp's problem or the platform's. + +This directory should be deleted the day Conscrypt ships ECH. + +What's missing, and where +------------------------- + +Encrypted Client Hello takes two halves. OkHttp supplies the first on every platform: it reads +the ECH config list out of the DNS `HTTPS` record and carries it on the [`Route`][route]. The TLS +stack supplies the second, and on the JVM three separate things are in the way. + +| What | Where | Status | +|------|-------|--------| +| A TLS stack that can encrypt a client hello | Conscrypt `google3-export` | Exists, unpublished — this directory | +| Handing the config list to that stack | OkHttp `ConscryptPlatform.configureTlsExtensions` | Takes an `echConfigList` and ignores it | +| Reading a server's retry config back | Conscrypt OpenJDK `Platform.wrapEchRejectedException` | Discards the retry configs and the public name | + +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. + +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 +is what `Android10Platform.getEchRetryConfig` reads. On OpenJDK the same code path throws an +`EchRejectedException` with the retry configs dropped on the floor, and nothing public exposes +`SSL_get0_ech_retry_configs` — so a stale config can be detected and not recovered from, on any +JVM client, however OkHttp is changed. + +The ECH work in Conscrypt was proposed as [google/conscrypt#1406][conscrypt-pr], which is still +open; what landed on `google3-export` is Google's internal version of it, exported by Copybara. +The API this suite uses is `Conscrypt.setEchConfigList(SSLSocket, byte[])`. + +How it's built and cached +------------------------- + +The build takes several minutes, needs a C++ toolchain and two source trees, and its output +depends on nothing in this repository except `pinned.properties`. So it does not happen in a test +workflow. The `conscrypt` workflow builds it when that file changes and publishes the jars as a +release; the release tag carries both pinned shas, so bumping a pin invalidates the cache by +construction, and a run that finds its tag already published does nothing. + +| File | What it does | +|------|--------------| +| `pinned.properties` | The Conscrypt and BoringSSL commits. The cache key. | +| `build-conscrypt.sh` | Builds BoringSSL for x86-64 and aarch64, then `conscrypt-openjdk`, into `build/dist`. | +| `fetch-conscrypt.sh` | Downloads the release for the pinned shas into `build/dist`, checksums it. | +| `release-tag.sh` | The tag those two agree on. | + +To run the JVM ECH suite locally: + +``` +conscrypt/fetch-conscrypt.sh # or --build-if-missing, if no release exists yet +./gradlew network:echConscryptTest -PokhttpVersion=5.5.0-SNAPSHOT +``` + +`network/build.gradle.kts` picks up `build/dist/conscrypt-openjdk-*.jar` if it is there and leaves +`EchConscryptTest` out of the build if it isn't, so neither the fetch nor the build is on anyone's +critical path. + +Building it needs `cmake`, `ninja`, `clang`, a JDK and an aarch64 cross compiler. Conscrypt's own +`jar` task builds a native library per architecture the host can target and fails if any of them +won't link, which is the only reason aarch64 is built at all — nothing here uses it. + +What a pass means +----------------- + +`network:echConscryptTest` supplies the two things the JVM lacks — this Conscrypt, and a network +security policy saying ECH is allowed, which on Android comes from `network_security_config.xml` +and on the JVM defaults to a value Conscrypt reads as "no" — and leaves the rest to OkHttp. When +it passes and `network:echTest` doesn't, the difference between them is the second row of the +table above and nothing else. + +It is not a claim that OkHttp does ECH on the JVM. It cannot be: the suite makes the +`setEchConfigList` call itself, from a socket factory, because OkHttp doesn't. What it does say is +that everything else is in place, and that the remaining change is worth making. + +[route]: https://square.github.io/okhttp/5.x/okhttp/okhttp3/-route/ +[okhttp-pr]: https://github.com/lysine-dev/okhttp/pull/9559 +[conscrypt-pr]: https://github.com/google/conscrypt/pull/1406 diff --git a/conscrypt/RELEASE_NOTES.md b/conscrypt/RELEASE_NOTES.md new file mode 100644 index 0000000..fd2fc50 --- /dev/null +++ b/conscrypt/RELEASE_NOTES.md @@ -0,0 +1,16 @@ +A build of [Conscrypt][conscrypt]'s OpenJDK artifact from the `google3-export` branch, for the +testbed's Encrypted Client Hello suites. **Not a Conscrypt release.** It is published here only +because ECH is not in one yet. + +`Conscrypt.setEchConfigList(SSLSocket, byte[])` exists on that branch and in no published +Conscrypt artifact, so the JVM has no TLS stack that can encrypt a client hello. That is what +`network:echTest` reports, and what `network:echConscryptTest` uses this to isolate. + +The exact commits, of both Conscrypt and BoringSSL, are in the tag and in `build-info.json`. +Verify the jars against `SHA256SUMS`. Delete this release once Conscrypt ships ECH: at that point +`conscrypt/` should go and the suite should depend on the release instead. + +Built by [`conscrypt/build-conscrypt.sh`][script] in the `conscrypt` workflow. + +[conscrypt]: https://github.com/google/conscrypt +[script]: https://github.com/yschimke/okhttp-testbed/blob/main/conscrypt/build-conscrypt.sh diff --git a/conscrypt/build-conscrypt.sh b/conscrypt/build-conscrypt.sh new file mode 100755 index 0000000..c9fcf26 --- /dev/null +++ b/conscrypt/build-conscrypt.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash + +# Builds Conscrypt's OpenJDK artifact from the pinned google3-export commit, against the +# pinned BoringSSL commit, and stages the result under conscrypt/build/dist. +# +# This is deliberately not part of the Gradle build. It takes several minutes, needs +# a C++ toolchain and a cross compiler, and its output changes only when `pinned.properties` +# changes — so it runs in its own workflow and its output is cached as a release. Suites get +# the jar from `fetch-conscrypt.sh`, which downloads that release. See README.md. +# +# Requires: git, cmake, ninja, clang, a JDK, and g++-aarch64-linux-gnu. On Ubuntu: +# sudo apt-get install -y cmake ninja-build clang g++-aarch64-linux-gnu binutils-aarch64-linux-gnu + +set -euo pipefail + +conscrypt_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +build_dir="$conscrypt_dir/build" +dist_dir="$build_dir/dist" + +# shellcheck disable=SC1091 +source "$conscrypt_dir/pinned.properties" + +: "${conscryptRef:?pinned.properties must set conscryptRef}" +: "${conscryptVersion:?pinned.properties must set conscryptVersion}" +: "${boringsslRef:?pinned.properties must set boringsslRef}" + +# One checkout per sha, so a bumped pin doesn't build on top of the previous tree and a +# rerun with an unchanged pin reuses what's already there. +boringssl_dir="$build_dir/boringssl-$boringsslRef" +conscrypt_src_dir="$build_dir/conscrypt-$conscryptRef" + +# Fetches exactly one commit. `git clone --depth 1` can only take a branch tip, and both of +# these are pinned to a sha that may be behind it. +checkout() { + local url="$1" ref="$2" target="$3" + + if [ -e "$target/.git" ]; then + echo "Reusing $target" + return + fi + + rm -rf "$target" + mkdir -p "$target" + git -C "$target" init --quiet + git -C "$target" remote add origin "$url" + git -C "$target" fetch --quiet --depth 1 origin "$ref" + git -C "$target" checkout --quiet FETCH_HEAD +} + +echo "==> BoringSSL $boringsslRef" +checkout https://github.com/google/boringssl.git "$boringsslRef" "$boringssl_dir" + +# Both architectures, because Conscrypt's `jar` task builds a native library for each and +# fails the build if either can't link. Only x86_64 is used by the suites today. +if [ ! -f "$boringssl_dir/build64/libssl.a" ]; then + echo "==> BoringSSL x86_64" + cmake -S "$boringssl_dir" -B "$boringssl_dir/build64" -GNinja \ + -DCMAKE_POSITION_INDEPENDENT_CODE=TRUE \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_COMPILER=clang \ + -DCMAKE_CXX_COMPILER=clang++ + ninja -C "$boringssl_dir/build64" crypto ssl +fi + +if [ ! -f "$boringssl_dir/build.arm/libssl.a" ]; then + echo "==> BoringSSL aarch64" + cmake -S "$boringssl_dir" -B "$boringssl_dir/build.arm" -GNinja \ + -DCMAKE_SYSTEM_NAME=Linux \ + -DCMAKE_SYSTEM_PROCESSOR=aarch64 \ + -DCMAKE_C_COMPILER=aarch64-linux-gnu-gcc \ + -DCMAKE_CXX_COMPILER=aarch64-linux-gnu-g++ \ + -DCMAKE_POSITION_INDEPENDENT_CODE=TRUE \ + -DCMAKE_BUILD_TYPE=Release + ninja -C "$boringssl_dir/build.arm" crypto ssl +fi + +echo "==> Conscrypt $conscryptRef" +checkout https://github.com/google/conscrypt.git "$conscryptRef" "$conscrypt_src_dir" + +# `jar` covers every native jar the host can build, which on Linux is x86_64 and aarch64. +# The Android modules are only included when an SDK is visible, and this build has no use +# for them, so ANDROID_HOME is cleared to keep them out of the task graph. +( + cd "$conscrypt_src_dir" + unset ANDROID_HOME ANDROID_SDK_ROOT + BORINGSSL_HOME="$boringssl_dir" CC=clang CXX=clang++ \ + ./gradlew --no-daemon :conscrypt-openjdk:jar +) + +rm -rf "$dist_dir" +mkdir -p "$dist_dir" +cp "$conscrypt_src_dir/openjdk/build/libs/"conscrypt-openjdk-*.jar "$dist_dir/" + +# What the jars can't say: which commits they came from. `fetch-conscrypt.sh` writes the +# same file after a download, so a suite can report its provenance either way. +cat > "$dist_dir/build-info.json" < SHA256SUMS) + +echo "==> Staged in $dist_dir" +ls -l "$dist_dir" diff --git a/conscrypt/fetch-conscrypt.sh b/conscrypt/fetch-conscrypt.sh new file mode 100755 index 0000000..d0eaacb --- /dev/null +++ b/conscrypt/fetch-conscrypt.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +# Puts the pinned Conscrypt build under conscrypt/build/dist, downloading it from this +# repository's releases rather than building it. +# +# The release tag is derived from `pinned.properties`, so this and `build-conscrypt.sh` +# always agree on what "the pinned build" means, and a bumped pin misses the cache rather +# than silently returning the old jar. `--build-if-missing` falls back to building; without +# it a missing release is an error, which is what a test workflow wants — a suite that +# quietly builds Conscrypt on every checkin is the thing this arrangement exists to avoid. +# +# Nothing here needs a token: the releases of a public repository are public. + +set -euo pipefail + +build_if_missing=false +if [ "${1:-}" = "--build-if-missing" ]; then + build_if_missing=true +elif [ $# -gt 0 ]; then + echo "usage: $0 [--build-if-missing]" >&2 + exit 2 +fi + +conscrypt_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +dist_dir="$conscrypt_dir/build/dist" + +# shellcheck disable=SC1091 +source "$conscrypt_dir/pinned.properties" + +repository="${GITHUB_REPOSITORY:-yschimke/okhttp-testbed}" +tag="$("$conscrypt_dir/release-tag.sh")" +base_url="https://github.com/$repository/releases/download/$tag" + +if [ -f "$dist_dir/build-info.json" ] && grep -q "\"$conscryptRef\"" "$dist_dir/build-info.json"; then + echo "Already have $tag in $dist_dir" + exit 0 +fi + +echo "==> Fetching $tag from $repository" +staging="$(mktemp -d)" +trap 'rm -rf "$staging"' EXIT + +if ! curl -fsSL --retry 3 --retry-delay 2 "$base_url/SHA256SUMS" -o "$staging/SHA256SUMS"; then + echo "No release $tag on $repository." >&2 + if [ "$build_if_missing" = true ]; then + echo "Building it instead." >&2 + exec "$conscrypt_dir/build-conscrypt.sh" + fi + echo "Run conscrypt/build-conscrypt.sh, or the conscrypt workflow, to publish it." >&2 + exit 1 +fi + +# The names come from the checksum file rather than being guessed, so adding an +# architecture to the build doesn't need a matching change here. +while read -r _ name; do + name="${name#./}" + curl -fsSL --retry 3 --retry-delay 2 "$base_url/$name" -o "$staging/$name" +done < "$staging/SHA256SUMS" + +curl -fsSL --retry 3 --retry-delay 2 "$base_url/build-info.json" -o "$staging/build-info.json" + +(cd "$staging" && sha256sum --check --quiet SHA256SUMS) + +rm -rf "$dist_dir" +mkdir -p "$(dirname "$dist_dir")" +mv "$staging" "$dist_dir" +trap - EXIT + +echo "==> Fetched into $dist_dir" +ls -l "$dist_dir" diff --git a/conscrypt/pinned.properties b/conscrypt/pinned.properties new file mode 100644 index 0000000..f132b3d --- /dev/null +++ b/conscrypt/pinned.properties @@ -0,0 +1,15 @@ +# What `build-conscrypt.sh` builds, and what `fetch-conscrypt.sh` looks for. +# +# Pinned rather than tracked: the build takes about fifteen minutes and its output is +# cached as a release on this repository, so the pin is the cache key. Moving either sha +# means the next run rebuilds and publishes a new release. Bump them deliberately. + +# google/conscrypt, branch google3-export. This is where ECH lives until it reaches a +# Conscrypt release: `Conscrypt.setEchConfigList(SSLSocket, byte[])` is public API here and +# exists in no published artifact. See README.md. +conscryptRef=d65d6b602c174408a6beaa9feab445fb500ce2bb +conscryptVersion=2.6-SNAPSHOT + +# google/boringssl. Conscrypt builds its JNI library against BoringSSL's source tree; the +# upstream CI tracks main, which is why this is pinned here rather than left to float. +boringsslRef=3c31f33f61cdb22e48437c4347d17c6cebab1506 diff --git a/conscrypt/release-tag.sh b/conscrypt/release-tag.sh new file mode 100755 index 0000000..d112541 --- /dev/null +++ b/conscrypt/release-tag.sh @@ -0,0 +1,17 @@ +#!/usr/bin/env bash + +# Prints the release tag for the currently pinned build. +# +# One place, because three things have to agree on it: the workflow that publishes the +# release, the script that downloads it, and anyone looking at the releases page trying to +# work out which commits a jar came from. Both shas are in the tag because the artifact +# depends on both. + +set -euo pipefail + +conscrypt_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# shellcheck disable=SC1091 +source "$conscrypt_dir/pinned.properties" + +echo "conscrypt-${conscryptRef:0:12}-boringssl-${boringsslRef:0:12}" diff --git a/network/build.gradle.kts b/network/build.gradle.kts index 7e0a5dd..033309c 100644 --- a/network/build.gradle.kts +++ b/network/build.gradle.kts @@ -39,6 +39,19 @@ fun compareVersions( ): Int = a.zip(b).firstNotNullOfOrNull { (x, y) -> (x - y).takeIf { it != 0 } } ?: 0 val echTestPattern = "EchTest" +val echConscryptTestPattern = "EchConscryptTest" +val echClientHelloTestPattern = "EchClientHelloTest" + +// 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 +// `conscrypt/fetch-conscrypt.sh` stages it here. Absent, the suite that needs it is left out of +// the build entirely rather than failing to compile. See conscrypt/README.md. +val conscryptJars = + fileTree(rootProject.layout.projectDirectory.dir("conscrypt/build/dist")) { + include("conscrypt-openjdk-*.jar") + } + +val hasConscrypt = !conscryptJars.isEmpty sourceSets { test { @@ -46,6 +59,13 @@ sourceSets { if (!supportsEch) { exclude("**/$echTestPattern.kt") } + if (!supportsEch || !hasConscrypt) { + exclude( + "**/$echConscryptTestPattern.kt", + "**/$echClientHelloTestPattern.kt", + "**/ConscryptEch.kt", + ) + } } } } @@ -83,7 +103,11 @@ val networkTest = val testSourceSet = sourceSets.test.get() testClassesDirs = testSourceSet.output.classesDirs classpath = testSourceSet.runtimeClasspath - exclude("**/$echTestPattern.class") + exclude( + "**/$echTestPattern.class", + "**/$echConscryptTestPattern.class", + "**/$echClientHelloTestPattern.class", + ) reportEndpointsTo("networkTest") ignoreFailures = true @@ -114,12 +138,42 @@ val echTest = } } +// The same servers, through Conscrypt rather than the JDK. Its own task for the same reason +// echTest is: its result answers a different question. echTest says whether OkHttp can do ECH as +// shipped, which on the JVM it cannot; this says whether the missing piece is the TLS stack and +// nothing else. Reading them together is the point, so they report side by side. +val echConscryptTest = + tasks.register("echConscryptTest") { + group = "verification" + description = "Reports whether ECH works on the JVM with a Conscrypt that supports it." + + val testSourceSet = sourceSets.test.get() + testClassesDirs = testSourceSet.output.classesDirs + classpath = testSourceSet.runtimeClasspath + // EchClientHelloTest runs here rather than under networkTest despite calling nobody: it + // answers the same question as the rest of this task and needs the same Conscrypt, and + // reading a "the servers agree" result next to a "the bytes are right" one is the point. + include("**/$echConscryptTestPattern.class", "**/$echClientHelloTestPattern.class") + + reportEndpointsTo("echConscryptTest") + enabled = supportsEch && hasConscrypt + ignoreFailures = true + + doFirst { + logger.lifecycle("Testing ECH against OkHttp $okhttpVersion on Conscrypt") + } + } + if (!supportsEch) { logger.lifecycle("Skipping EchTest: OkHttp $okhttpVersion predates the ECH API") } +if (!hasConscrypt) { + logger.lifecycle("Skipping EchConscryptTest: no Conscrypt build. Run conscrypt/fetch-conscrypt.sh.") +} + tasks.check { - dependsOn(networkTest, echTest) + dependsOn(networkTest, echTest, echConscryptTest) } dependencies { @@ -127,6 +181,9 @@ dependencies { testImplementation("com.squareup.okhttp3:okhttp-tls:$okhttpVersion") testImplementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") + // Absent unless someone staged it; `hasConscrypt` leaves EchConscryptTest out when it is. + testImplementation(conscryptJars) + testImplementation(libs.junit.jupiter.api) testImplementation(libs.junit.jupiter.params) testRuntimeOnly(libs.junit.jupiter.engine) diff --git a/network/src/test/kotlin/okhttp/testbed/network/ConscryptEch.kt b/network/src/test/kotlin/okhttp/testbed/network/ConscryptEch.kt new file mode 100644 index 0000000..b180ea4 --- /dev/null +++ b/network/src/test/kotlin/okhttp/testbed/network/ConscryptEch.kt @@ -0,0 +1,202 @@ +/* + * 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 java.io.IOException +import java.net.Socket +import java.security.KeyStore +import java.util.concurrent.ConcurrentHashMap +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import okhttp3.Dns +import okio.ByteString +import org.conscrypt.Conscrypt +import org.conscrypt.DomainEncryptionMode +import org.conscrypt.NetworkSecurityPolicy +import org.conscrypt.metrics.CertificateTransparencyVerificationReason + +/** + * Everything needed to encrypt a client hello on the JVM, which as of this writing is not + * something OkHttp can arrange on its own. + * + * OkHttp resolves the ECH config list out of the DNS HTTPS record and hands it to its platform; + * on Android that platform passes it to Conscrypt and ECH happens. On the JVM `ConscryptPlatform` + * takes the same parameter and drops it — `configureTlsExtensions` sets session tickets and ALPN + * and returns. So the config list reaches [okhttp3.Route] and stops there, which is exactly what + * [EchTest] reports. + * + * These types close that gap from outside OkHttp, using published API on both sides: OkHttp's + * [Dns.Record.ServiceMetadata.echConfigList] for the config, and Conscrypt's + * `Conscrypt.setEchConfigList` for the socket. What that measures is *Conscrypt's* half of ECH, + * not OkHttp's — see [EchConscryptTest] for what does and doesn't follow from a pass here. + */ +object ConscryptEch { + /** + * True if the Conscrypt on the classpath can encrypt a client hello. + * + * `setEchConfigList` exists only on the `google3-export` branch. A released Conscrypt has the + * rest of this class's API and not this method, so the check is for the method rather than for + * the provider — anything else would report "Conscrypt is missing" for a Conscrypt that is + * there and merely too old. + */ + val isSupported: Boolean by lazy { + try { + Conscrypt::class.java.getMethod("setEchConfigList", SSLSocket::class.java, ByteArray::class.java) + Conscrypt.isAvailable() + } catch (_: NoSuchMethodException) { + false + } catch (_: NoClassDefFoundError) { + false + } + } + + val version: String + get() = Conscrypt.version().let { "${it.major()}.${it.minor()}.${it.patch()}" } + + /** An [SSLContext] from Conscrypt, trusting whatever the JVM trusts. */ + fun sslContext(trustManager: X509TrustManager): SSLContext = + SSLContext.getInstance("TLS", Conscrypt.newProvider()).apply { + init(null, arrayOf(trustManager), null) + } + + /** The JVM's default trust manager. */ + fun platformTrustManager(): X509TrustManager = + TrustManagerFactory + .getInstance(TrustManagerFactory.getDefaultAlgorithm()) + .apply { init(null as KeyStore?) } + .trustManagers + .filterIsInstance() + .first() +} + +/** + * A trust manager that also answers Conscrypt's question about whether ECH is allowed here. + * + * Conscrypt asks the platform, through a [NetworkSecurityPolicy] it looks for by calling a + * `getNetworkSecurityPolicy` method on the configured trust manager. On Android the platform + * supplies one and the answer comes from `network_security_config.xml`. On the JVM the default + * answers `UNKNOWN`, which Conscrypt reads as "no" — so a config list set on the socket is + * ignored and the handshake goes out in the clear. That is the second half of why ECH does not + * work on the JVM today, and it is not something a caller can fix by configuring OkHttp. + * + * The method is found reflectively, which is why it has to be public on a public class. + */ +class EchEnablingTrustManager( + private val delegate: X509TrustManager, +) : X509TrustManager by delegate { + @Suppress("unused") // Called by Conscrypt, reflectively. + fun getNetworkSecurityPolicy(): NetworkSecurityPolicy = Policy + + private object Policy : NetworkSecurityPolicy { + override fun isCertificateTransparencyVerificationRequired(hostname: String): Boolean = false + + override fun getCertificateTransparencyVerificationReason( + hostname: String, + ): CertificateTransparencyVerificationReason = CertificateTransparencyVerificationReason.UNKNOWN + + // ENABLED rather than REQUIRED: a hostname with no config list should still connect, in the + // clear, the way a browser does. REQUIRED would turn tls12.tls-ech.dev into a failure to + // connect rather than the negative result it is there to give. + override fun getDomainEncryptionMode(hostname: String): DomainEncryptionMode = DomainEncryptionMode.ENABLED + } +} + +/** + * A [Dns] that remembers the ECH config list it saw for each hostname. + * + * OkHttp reads the config list out of the HTTPS record and carries it on the [okhttp3.Route], but + * a route is only visible once a connection exists — too late to configure the socket that makes + * it. So this watches the same records on their way past, and [EchSocketFactory] reads them back + * when a socket for that hostname is created. + * + * Only [Dns.newCall] carries service metadata; [Dns.lookup] is addresses only. Both are delegated, + * because OkHttp uses each in different circumstances. + */ +class EchRecordingDns( + private val delegate: Dns, +) : Dns { + private val echConfigLists = ConcurrentHashMap() + + operator fun get(hostname: String): ByteString? = echConfigLists[hostname.lowercase()] + + override fun lookup(hostname: String) = delegate.lookup(hostname) + + override fun newCall(request: Dns.Request): Dns.Call = RecordingCall(delegate.newCall(request)) + + private inner class RecordingCall( + private val delegate: Dns.Call, + ) : Dns.Call by delegate { + override fun enqueue(callback: Dns.Callback) { + delegate.enqueue( + object : Dns.Callback { + override fun onRecords( + call: Dns.Call, + last: Boolean, + records: List, + ) { + for (record in records) { + if (record !is Dns.Record.ServiceMetadata) continue + val echConfigList = record.echConfigList ?: continue + echConfigLists[record.hostname.lowercase()] = echConfigList + } + callback.onRecords(call, last, records) + } + + override fun onFailure( + call: Dns.Call, + e: IOException, + ) { + callback.onFailure(call, e) + } + }, + ) + } + } +} + +/** + * Applies the config list [dns] saw for a hostname to the socket about to connect to it. + * + * This is the call OkHttp's `ConscryptPlatform` doesn't make. Doing it here means the handshake + * is encrypted, and means the retry path isn't covered: when a server rejects a stale config it + * offers a fresh one, and reading that back needs `SSL_get0_ech_retry_configs`, which Conscrypt + * exposes on Android as `EchConfigMismatchException` and does not expose at all on the JVM. + */ +class EchSocketFactory( + private val delegate: SSLSocketFactory, + private val dns: EchRecordingDns, +) : DelegatingSSLSocketFactory(delegate) { + /** The hostnames a config list was applied to, so a test can assert it was applied at all. */ + val encryptedHostnames: MutableList = mutableListOf() + + override fun createSocket( + socket: Socket, + host: String, + port: Int, + autoClose: Boolean, + ): SSLSocket { + val sslSocket = delegate.createSocket(socket, host, port, autoClose) as SSLSocket + val echConfigList = dns[host] + if (echConfigList != null) { + Conscrypt.setEchConfigList(sslSocket, echConfigList.toByteArray()) + synchronized(encryptedHostnames) { encryptedHostnames += host } + } + return sslSocket + } +} diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchClientHelloTest.kt b/network/src/test/kotlin/okhttp/testbed/network/EchClientHelloTest.kt new file mode 100644 index 0000000..d2d1328 --- /dev/null +++ b/network/src/test/kotlin/okhttp/testbed/network/EchClientHelloTest.kt @@ -0,0 +1,170 @@ +/* + * 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.isFalse +import assertk.assertions.isTrue +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.io.OutputStream +import java.net.InetAddress +import java.net.ServerSocket +import java.net.Socket +import java.security.SecureRandom +import javax.net.ssl.SSLSocket +import kotlin.concurrent.thread +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.Test + +/** + * Reads the client hello Conscrypt actually sends, and checks that the name is not in it. + * + * Every other ECH suite here asks a server whether it saw an encrypted client hello, which means + * every other ECH suite depends on somebody's uptime and on a network that doesn't intercept TLS. + * This one asks the only question that has to be true for any of the rest to be — did the bytes + * on the wire carry the name in the clear — and answers it against a socket that accepts a + * connection and says nothing. It needs no DNS, no internet and no server, so it is the suite + * that says whether a red result elsewhere is about ECH or about the network the run was on. + * + * The config list here is synthetic. A real one comes from a DNS `HTTPS` record and is paired + * with a key the server holds; nothing here completes a handshake, so an unpaired public key is + * fine. It does have to be a well-formed `ECHConfigList` — BoringSSL rejects a malformed one, and + * a rejected config means a client hello with no ECH in it, which is exactly the failure this + * would otherwise miss. + */ +class EchClientHelloTest { + @Test + fun theNameIsNotSentInTheClear() { + assumeTrue(ConscryptEch.isSupported) { + "requires a Conscrypt with ECH. Run conscrypt/fetch-conscrypt.sh." + } + + val trustManager = EchEnablingTrustManager(ConscryptEch.platformTrustManager()) + val sslContext = ConscryptEch.sslContext(trustManager) + + val clientHello = captureClientHello { server -> + val socket = + sslContext.socketFactory.createSocket( + Socket(InetAddress.getLoopbackAddress(), server.localPort), + INNER_NAME, + server.localPort, + true, + ) as SSLSocket + org.conscrypt.Conscrypt.setEchConfigList(socket, echConfigList(PUBLIC_NAME)) + try { + socket.startHandshake() + } catch (_: IOException) { + // Nothing answers. The first flight is all this needs. + } + } + + // 0xfe0d is both the `encrypted_client_hello` extension type and the ECHConfig version, which + // is why matching the two bytes anywhere in the hello is enough: an ECH-less hello from this + // stack contains neither. + assertThat(clientHello.containsBytes(byteArrayOf(0xfe.toByte(), 0x0d))).isTrue() + + // The outer hello names the public name, and the real one is encrypted. Both halves matter: + // a client that sent no SNI at all would pass the second check on its own. + assertThat(clientHello.containsBytes(PUBLIC_NAME.toByteArray(Charsets.US_ASCII))).isTrue() + assertThat(clientHello.containsBytes(INNER_NAME.toByteArray(Charsets.US_ASCII))).isFalse() + } + + /** Runs [connect] against a socket that accepts once, reads the first flight, and closes. */ + private fun captureClientHello(connect: (ServerSocket) -> Unit): ByteArray { + ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { server -> + var captured = ByteArray(0) + val accepter = + thread { + try { + server.accept().use { accepted -> + val buffer = ByteArray(16 * 1024) + val read = accepted.getInputStream().read(buffer) + if (read > 0) captured = buffer.copyOf(read) + } + } catch (_: IOException) { + } + } + + connect(server) + accepter.join(5_000) + return captured + } + } + + private fun ByteArray.containsBytes(needle: ByteArray): Boolean = + (0..size - needle.size).any { start -> + needle.indices.all { this[start + it] == needle[it] } + } + + /** + * An `ECHConfigList` for [publicName], from RFC 9849 section 4. + * + * ``` + * ECHConfigList uint16 length, then ECHConfig* + * ECHConfig uint16 version, uint16 length, ECHConfigContents + * ``` + */ + private fun echConfigList(publicName: String): ByteArray { + val publicKey = ByteArray(32).also(SecureRandom()::nextBytes) + + val contents = + ByteArrayOutputStream().apply { + write(CONFIG_ID) + writeShort(KEM_X25519_HKDF_SHA256) + writeShort(publicKey.size) + write(publicKey) + writeShort(4) // cipher_suites, one suite of two uint16s. + writeShort(KDF_HKDF_SHA256) + writeShort(AEAD_AES_128_GCM) + write(MAXIMUM_NAME_LENGTH) + val name = publicName.toByteArray(Charsets.US_ASCII) + write(name.size) + write(name) + writeShort(0) // No extensions. + }.toByteArray() + + val config = + ByteArrayOutputStream().apply { + writeShort(ECH_CONFIG_VERSION) + writeShort(contents.size) + write(contents) + }.toByteArray() + + return ByteArrayOutputStream().apply { + writeShort(config.size) + write(config) + }.toByteArray() + } + + private fun OutputStream.writeShort(value: Int) { + write((value ushr 8) and 0xff) + write(value and 0xff) + } + + private companion object { + /** Neither name is resolved or connected to; they only have to be distinguishable. */ + private const val PUBLIC_NAME = "public.example.com" + private const val INNER_NAME = "secret.example.com" + + private const val ECH_CONFIG_VERSION = 0xfe0d + private const val CONFIG_ID = 42 + private const val KEM_X25519_HKDF_SHA256 = 0x0020 + private const val KDF_HKDF_SHA256 = 0x0001 + private const val AEAD_AES_128_GCM = 0x0001 + private const val MAXIMUM_NAME_LENGTH = 64 + } +} diff --git a/network/src/test/kotlin/okhttp/testbed/network/EchConscryptTest.kt b/network/src/test/kotlin/okhttp/testbed/network/EchConscryptTest.kt new file mode 100644 index 0000000..d89f9d6 --- /dev/null +++ b/network/src/test/kotlin/okhttp/testbed/network/EchConscryptTest.kt @@ -0,0 +1,155 @@ +/* + * 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.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** + * The same servers [EchTest] calls, reached through a Conscrypt built from `google3-export` + * instead of through the JDK's TLS stack. + * + * [EchTest] fails its `sni=encrypted` assertions on the JVM, and that is a true result: no + * published TLS stack a JVM can load will encrypt a client hello. This suite exists to say what + * is missing rather than that something is. It supplies the two pieces the JVM lacks — a + * Conscrypt with ECH in it, and a network security policy that says ECH is allowed — and leaves + * everything else to OkHttp. When these pass and [EchTest] doesn't, the difference is the work + * still to be done, and it is a small and specific piece of work: + * + * * `ConscryptPlatform.configureTlsExtensions` accepts an `echConfigList` and ignores it. It + * needs to call `Conscrypt.setEchConfigList`, the way `Android10Platform` does. + * * `ConscryptPlatform.getEchRetryConfig` doesn't exist. Android's reads the retry config out + * of an `EchConfigMismatchException`; Conscrypt's OpenJDK `Platform` throws away both the + * retry configs and the public name, so on the JVM there is nothing to read. That one is a + * Conscrypt change, not an OkHttp change, which is why the stale-config case from [EchTest] + * has no counterpart here. + * + * The Conscrypt this needs is not published anywhere. `conscrypt/build-conscrypt.sh` builds it + * and the `conscrypt` workflow caches the result as a release; without it every test here skips. + * See `conscrypt/README.md`. + */ +@RequiresEndpoint(Endpoint.CLOUDFLARE_DOH) +class EchConscryptTest { + private lateinit var client: OkHttpClient + private lateinit var dns: EchRecordingDns + private lateinit var socketFactory: EchSocketFactory + + @BeforeEach + fun setUp() { + assumeTrue(ConscryptEch.isSupported) { + "requires a Conscrypt with ECH. Run conscrypt/fetch-conscrypt.sh." + } + + val trustManager = EchEnablingTrustManager(ConscryptEch.platformTrustManager()) + val sslContext = ConscryptEch.sslContext(trustManager) + + // Conscrypt for the bootstrap client too, so one TLS stack is under test rather than two. + // The resolver is addressed by IP, so resolving it doesn't need a resolver. + val bootstrapClient = + OkHttpClient + .Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + + dns = + EchRecordingDns( + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + .includeServiceMetadata(true) + .build(), + ) + + socketFactory = EchSocketFactory(sslContext.socketFactory, dns) + + client = + bootstrapClient + .newBuilder() + .sslSocketFactory(socketFactory, trustManager) + .dns(dns) + .build() + } + + @Test + @RequiresEndpoint(Endpoint.CLOUDFLARE_ECH) + fun cloudflareAcceptsAnEncryptedClientHello() { + val body = get("https://cloudflare-ech.com/cdn-cgi/trace") + + assertThat(socketFactory.encryptedHostnames).contains("cloudflare-ech.com") + assertThat(body).contains("sni=encrypted") + } + + @Test + @RequiresEndpoint(Endpoint.TLS_ECH_DEV) + fun tlsEchDevAcceptsAnEncryptedClientHello() { + val body = get("https://tls-ech.dev/") + + assertThat(socketFactory.encryptedHostnames).contains("tls-ech.dev") + + // 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 defoIeAcceptsAnEncryptedClientHello() { + val body = get("https://defo.ie/ech-check.php") + + assertThat(socketFactory.encryptedHostnames).contains("defo.ie") + assertThat(body).contains("SSL_ECH_STATUS: success") + } + + /** + * TLS 1.2 cannot carry ECH, and this name publishes no config list. The point is that the + * connection still happens: a client that can do ECH must not break the servers that can't. + */ + @Test + @RequiresEndpoint(Endpoint.TLS_ECH_DEV) + fun tls12IsReachedWithoutEch() { + val body = get("https://tls12.tls-ech.dev/") + + assertThat(body).contains("

tls12.tls-ech.dev

") + assertThat(body).contains("You are not using ECH") + assertThat(body).doesNotContain("You are using ECH") + } + + /** + * The config list still has to reach OkHttp for any of this to be OkHttp's ECH rather than the + * suite's. This asserts the half that already works, on the same connection as the rest. + */ + @Test + @RequiresEndpoint(Endpoint.CLOUDFLARE_ECH) + fun okHttpResolvesTheConfigListFromDns() { + get("https://cloudflare-ech.com/cdn-cgi/trace") + + assertThat(dns["cloudflare-ech.com"]).isNotNull() + } + + private fun get(url: String): String = + client.newCall(Request(url.toHttpUrl())).execute().use { it.body.string() } +} diff --git a/site/tools/collect_results.py b/site/tools/collect_results.py index e2b0d59..2337c69 100644 --- a/site/tools/collect_results.py +++ b/site/tools/collect_results.py @@ -45,7 +45,13 @@ import xml.etree.ElementTree as ElementTree # Gradle test tasks whose failures are findings about OkHttp rather than breakage here. -REPORTING_TASKS = {"loomTest", "echTest", "networkTest"} +REPORTING_TASKS = {"loomTest", "echTest", "echConscryptTest", "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 +# defo.ie has no way to say it reports rather than gates except by being named here. Everything +# else in the Android module runs against containers this repository starts. +REPORTING_CLASSES = {"PublicEncryptedClientHelloTest"} # How many collections the history keeps. Enough for the trend strip to show a few weeks of # daily runs without the file growing without bound. @@ -98,7 +104,7 @@ def parse_suite(path: pathlib.Path, task: str, workflow: str, run_url: str) -> d "workflow": workflow, "runUrl": run_url, "task": task, - "reporting": task in REPORTING_TASKS, + "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"), "failed": sum(1 for c in cases if c["status"] == "failed"), diff --git a/site/topics/ech.html b/site/topics/ech.html index 2bdcaa9..b353704 100644 --- a/site/topics/ech.html +++ b/site/topics/ech.html @@ -96,6 +96,60 @@

What runs today

DnsOverHttps.Builder.includeServiceMetadata, and no release has it: 5.4.0 resolves A and AAAA records only, so there is no HTTPS record to carry a config list at all.

+

+ The same emulator also runs PublicEncryptedClientHelloTest, which is the JVM + suite's cases against tls-ech.dev, defo.ie and + cloudflare-ech.com. The fixture suite says whether the client is right; this one + makes the JVM and Android rows of the status page comparable, since they are then the same + assertions against the same servers with only the TLS stack differing. It reports rather than + gates, for the reason everything calling those servers does. +

+ +

What it would take on the JVM

+

+ network:echTest runs the same cases against the public servers on a JVM, and its + route assertions pass while its assertions about what the server saw fail. That is a true + result and a slightly misleading one: it reads as "OkHttp cannot do ECH on the JVM", where the + accurate version is that three separate things are in the way and only one of them is OkHttp's. +

+
+ + + + + + + + + + + + + + + + + + + + + +
WhatWhereStatus
A TLS stack that can encrypt a client helloConscrypt google3-exportExists, unpublished. Built and cached by this repository.
Handing the config list to that stackOkHttp ConscryptPlatform.configureTlsExtensionsTakes an echConfigList and ignores it. One call, next to the ALPN one.
Reading a server's retry config backConscrypt OpenJDK PlatformDiscards the retry configs and the public name. A Conscrypt change, not an OkHttp one.
+
+

+ network:echConscryptTest supplies the first and leaves the rest to OkHttp, so the + gap between it and echTest is the second row and nothing else. Under it, + EchClientHelloTest reads the client hello off a local socket that answers nothing + and asserts the name is not in the bytes — no DNS, no server, no network — which makes it the + one ECH result that is never about somebody else's uptime. +

+

+ The third row is why stale.tls-ech.dev has no JVM counterpart. On Android a + rejected config arrives as an EchConfigMismatchException carrying the config the + server offered instead; on OpenJDK the same path throws the retry configs away, and nothing + public exposes them. A stale config can be detected there and not recovered from, whatever + OkHttp does. +

Specifications