diff --git a/.github/workflows/android-ech.yml b/.github/workflows/android-ech.yml new file mode 100644 index 0000000..c369f9e --- /dev/null +++ b/.github/workflows/android-ech.yml @@ -0,0 +1,84 @@ +name: android-ech + +on: + push: + branches: + - main + pull_request: + schedule: + # Daily, an hour after the container suites, so a regression in the snapshot's ECH or + # DoH handling shows up without anyone pushing to this repo. + - cron: '47 7 * * *' + workflow_dispatch: + inputs: + okhttpVersion: + description: 'OkHttp version under test (e.g. 5.5.0-SNAPSHOT)' + required: false + type: string + +permissions: + contents: read + +env: + GRADLE_OPTS: "-Dorg.gradle.jvmargs=-Xmx4g -Dorg.gradle.daemon=false -Dkotlin.incremental=false" + # Empty means the version pinned as ech-okhttp in libs.versions.toml, which is the + # snapshot: ECH needs DnsOverHttps.includeServiceMetadata, and no release has it yet. + OKHTTP_VERSION: ${{ inputs.okhttpVersion || '' }} + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + android-ech: + name: android-ech (${{ inputs.okhttpVersion || 'pinned snapshot' }}) + runs-on: ubuntu-latest + timeout-minutes: 45 + + steps: + - name: Checkout + uses: actions/checkout@v7 + + - name: Configure JDK + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 21 + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Enable KVM group permissions + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + # API 37 is the emulator this suite needs: ECH is applied by OkHttp's Android platform + # through android.net.ssl.EchConfigList, which arrived there. + - 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 + disable-animations: true + emulator-options: >- + -no-window + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -memory 2048 + script: android-ech/run-ech-test.sh + + # The suite's XML, for the status page, whatever colour the job ended up. + - name: Upload Test Results + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-ech-test-results-${{ inputs.okhttpVersion || 'pinned-snapshot' }} + path: | + android-ech/build/outputs/androidTest-results/connected/**/*.xml + android-ech/build/reports/androidTests/connected/ + retention-days: 30 diff --git a/README.md b/README.md index e3ce10f..a3f0365 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,13 @@ schedule, and publishes the current status. Suites ------ -| Suite | What it needs | What it covers | -|--------------|--------------------|-------------------------------------------------------------------| -| `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, virtual threads (Loom) | +| Suite | What it needs | What it covers | +|---------------|-----------------------------|---------------------------------------------------------------------| +| `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, virtual threads (Loom) | +| `android-ech` | Docker, an API 37 emulator | Encrypted Client Hello over DoH: accepted, retried, and declined | More suites are planned — notably network tests against external IETF and vendor test -servers, and the heavier Android device matrix. +servers, and the rest of the Android device matrix. Public API only --------------- @@ -34,7 +35,8 @@ Two things enforce that, rather than leaving it to good intentions: can quietly lean on package-level access. - `checkPublicApiOnly` fails the build on any import of `okhttp3.internal`, `okhttp3.testing`, `mockwebserver3.internal` or `okio.internal`. It runs as part of - `check` and before every `test` task. Extend `forbiddenImports` in the root + `check`, before every `test` task, and before the Android suite's `connected…AndroidTest` + task, which `check` doesn't cover. Extend `forbiddenImports` in the root `build.gradle.kts` as new dependencies arrive. Where a test needs something the public API doesn't offer, prefer solving it with the @@ -64,6 +66,51 @@ another release, a release candidate, or a snapshot: Snapshots resolve from Sonatype; releases from Maven Central. +The ECH suite +------------- + +`android-ech` tests Encrypted Client Hello end to end. It needs Docker *and* an emulator, +which is why it is its own suite with its own workflow rather than another entry under +`containers`. + +Two containers stand behind it, built from one small Go program in `ech-fixture`: + +- an origin that holds the ECH keys, generates the CA and leaf certificates, and answers + every request with the two facts the test is about — whether the handshake it accepted + used ECH, and which name it was for; +- a DoH resolver, configured from the origin's keys, answering HTTPS records that carry an + ECH config list, the origin's port, and an IPv4 hint. + +Three hostnames give three outcomes. `green.secret.test` is published with the config the +origin holds, so the first handshake is accepted. `retry.secret.test` is published with a +stale config and the origin offers a retry config, so the client should retry and succeed +with ECH. `disabled.secret.test` is published with a stale config and the origin offers +nothing, so the client should fall back to a handshake without ECH rather than fail. + +The tests run on the device, and the device has no Docker — so the containers run on the +host and the device reaches them over `adb reverse`. `ech-fixture` is what starts them: +not a test, but a process that publishes its host ports and the fixture CA to a file and +stays up until that file is deleted. `run-ech-test.sh` ties the two together: + +``` +android-ech/run-ech-test.sh # fixture, adb reverse, instrumentation tests +android-ech/run-ech-test.sh --smoke-only # fixture only, for a machine with no emulator +``` + +It needs a running emulator or a connected device on API 37 — `android.net.ssl.EchConfigList`, +which is how OkHttp's Android platform applies a config list, arrived there. The tests skip +themselves on anything older, and on a run that didn't come through the script. + +This suite tests **5.5.0-SNAPSHOT** by default, not the release the other suites pin, and +`libs.versions.toml` carries that as a separate `ech-okhttp` version. It has to: the suite +needs `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 an ECH config list. Point it +at whatever you like with `-PokhttpVersion`, and drop `ech-okhttp` once a release ships the +API. + +ECH itself is Android-only in OkHttp today: JVM platforms accept the config list and ignore +it, so there is nothing here for the `containers` suite to assert. + CI -- @@ -97,6 +144,13 @@ will be built from. The job runs with `--continue` so one failing suite doesn't others of a result, and the matrix runs with `fail-fast: false` so one failing version doesn't rob the other. +The `android-ech` workflow runs on the same events, on its own daily schedule, and uploads +its results as `android-ech-test-results-`. It runs one version rather than a +matrix — the snapshot — because that is the only version with the API the suite needs. It +boots an API 37 emulator, so it is slower and more failure-prone than the container jobs; +that is the price of testing ECH at all, and it is why it is a separate workflow whose +colour doesn't mask the container suites'. + Suites that report rather than gate ----------------------------------- diff --git a/android-ech/build.gradle.kts b/android-ech/build.gradle.kts new file mode 100644 index 0000000..6feca96 --- /dev/null +++ b/android-ech/build.gradle.kts @@ -0,0 +1,71 @@ +plugins { + // AGP 9 brings Kotlin support with it — applying the Kotlin Android plugin here fails. + alias(libs.plugins.android.library) + alias(libs.plugins.android.junit5) +} + +// The version of OkHttp under test. This suite defaults to the snapshot rather than to the +// release the other suites pin, because ECH needs `DnsOverHttps.includeServiceMetadata` and +// no release has it yet. Override the same way as everywhere else: +// ./gradlew android-ech:connectedDebugAndroidTest -PokhttpVersion=5.5.0-SNAPSHOT +val okhttpVersion = + providers + .gradleProperty("okhttpVersion") + .getOrElse(libs.versions.ech.okhttp.get()) + +// Snapshots are republished under the same name, and Gradle caches a changing module for 24 +// hours, so without this a daily run can test yesterday's build. Releases are immutable. +if (okhttpVersion.endsWith("-SNAPSHOT")) { + configurations.configureEach { + resolutionStrategy.cacheChangingModulesFor(0, "seconds") + } +} + +android { + namespace = "okhttp.testbed.android.ech" + + compileSdk { + // ECH arrived in Android 16 QPR2 / API 37: `android.net.ssl.EchConfigList` is what + // OkHttp's Android platform hands the config list to. + version = release(37) + } + + defaultConfig { + minSdk = 21 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + testInstrumentationRunnerArguments += + mapOf( + // The suite is JUnit 5, as the JVM suites are. + "runnerBuilder" to "de.mannodermaus.junit5.AndroidJUnit5Builder", + ) + } + + compileOptions { + sourceCompatibility(JavaVersion.VERSION_11) + targetCompatibility(JavaVersion.VERSION_11) + } + + testOptions { + targetSdk = 37 + } +} + +dependencies { + androidTestImplementation("com.squareup.okhttp3:okhttp:$okhttpVersion") + androidTestImplementation("com.squareup.okhttp3:okhttp-dnsoverhttps:$okhttpVersion") + + androidTestImplementation(libs.assertk) + androidTestImplementation(libs.junit.jupiter.api) + androidTestImplementation(libs.junit5android.core) + androidTestImplementation(libs.androidx.test.runner) + androidTestRuntimeOnly(libs.junit5android.runner) +} + +// `check` doesn't run instrumentation tests, so the public-API check has to be wired to the +// task that does — otherwise this suite could reach into okhttp3.internal unnoticed. +tasks + .matching { it.name.startsWith("connected") && it.name.endsWith("AndroidTest") } + .configureEach { + dependsOn("checkPublicApiOnly") + } diff --git a/android-ech/run-ech-test.sh b/android-ech/run-ech-test.sh new file mode 100755 index 0000000..8d10740 --- /dev/null +++ b/android-ech/run-ech-test.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash + +# Runs the Android ECH suite against the host-side containers. +# +# The containers can't run on the device, and the device can't reach the host by name, so +# this script is the glue: it starts the fixture on the host, waits for it to publish its +# ports and CA, forwards those ports onto the device with `adb reverse`, and then runs the +# instrumentation tests. `--smoke-only` stops after the fixture is up, which is what to run +# where no emulator is available — it still exercises Docker, Gradle and the fixture itself. + +set -euo pipefail + +mode="${1:-instrumentation}" +if [[ "$mode" != "instrumentation" && "$mode" != "--smoke-only" ]]; then + echo "usage: $0 [--smoke-only]" >&2 + exit 2 +fi + +repository_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +temporary_dir="${RUNNER_TEMP:-${TMPDIR:-/tmp}}" +endpoint_file="$temporary_dir/okhttp-testbed-ech.endpoint" +service_log="$temporary_dir/okhttp-testbed-ech.log" +startup_timeout_seconds="${ECH_FIXTURE_TIMEOUT_SECONDS:-1200}" +rm -f "$endpoint_file" "$service_log" + +# Passed through so a run can pick a version the same way the Gradle suites do. +gradle_arguments=() +if [[ -n "${OKHTTP_VERSION:-}" ]]; then + gradle_arguments+=("-PokhttpVersion=$OKHTTP_VERSION") +fi + +ECH_FIXTURE_ENDPOINT_FILE="$endpoint_file" \ + "$repository_root/gradlew" -p "$repository_root" :ech-fixture:runEchFixture "${gradle_arguments[@]}" \ + >"$service_log" 2>&1 & +service_pid=$! + +cleanup() { + adb reverse --remove tcp:8053 >/dev/null 2>&1 || true + adb reverse --remove tcp:443 >/dev/null 2>&1 || true + adb reverse --remove tcp:8443 >/dev/null 2>&1 || true + # Deleting the endpoint file is how the fixture is asked to stop. + rm -f "$endpoint_file" + for _ in {1..200}; do + if ! kill -0 "$service_pid" 2>/dev/null; then + break + fi + sleep 0.1 + done + kill "$service_pid" >/dev/null 2>&1 || true + wait "$service_pid" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +startup_deadline=$((SECONDS + startup_timeout_seconds)) +while ((SECONDS < startup_deadline)); do + if [[ -s "$endpoint_file" ]]; then + break + fi + if ! kill -0 "$service_pid" 2>/dev/null; then + cat "$service_log" >&2 + exit 1 + fi + sleep 1 +done + +if [[ ! -s "$endpoint_file" ]]; then + cat "$service_log" >&2 + echo "Timed out waiting for the ECH fixture" >&2 + exit 1 +fi + +property() { + sed -n "s/^$1=//p" "$endpoint_file" +} + +doh_host_port="$(property DOH_HOST_PORT)" +target_host_port="$(property TARGET_HOST_PORT)" +ca_certificate="$(property CA_CERT)" +if [[ ! "$doh_host_port" =~ ^[0-9]+$ || ! "$target_host_port" =~ ^[0-9]+$ || -z "$ca_certificate" ]]; then + cat "$service_log" >&2 + echo "Invalid ECH fixture metadata" >&2 + exit 1 +fi + +if [[ "$mode" == "--smoke-only" ]]; then + exit 0 +fi + +# 8053 is the resolver. The origin is reached on 8443, the port the HTTPS record publishes, +# and on 443 for the default the URL would otherwise use. +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 \ + -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/EncryptedClientHelloTest.kt b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/EncryptedClientHelloTest.kt new file mode 100644 index 0000000..65291ac --- /dev/null +++ b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/EncryptedClientHelloTest.kt @@ -0,0 +1,144 @@ +/* + * 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 androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import java.io.ByteArrayInputStream +import java.net.InetAddress +import java.security.KeyStore +import java.security.cert.CertificateFactory +import java.util.Base64 +import javax.net.ssl.SSLContext +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +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.Test + +/** + * Encrypted Client Hello, end to end, against the containers `run-ech-test.sh` starts on the + * host: a DoH resolver that answers HTTPS records carrying an ECH config list, and an origin + * that reports back whether the handshake it accepted used ECH. + * + * Three hostnames, three outcomes. `green` is published with the config the origin holds, so + * the first handshake is accepted. `retry` is published with a stale config, and the origin + * offers a retry config when it rejects it, so the client should retry and succeed with ECH. + * `disabled` is published with a stale config and the origin offers nothing, so the client + * should fall back to a handshake without ECH rather than fail. + */ +class EncryptedClientHelloTest { + @Test + fun greenPathAcceptsEncryptedClientHello() { + val response = fixture().get(GREEN_NAME) + + assertThat(response).contains("\"echAccepted\":true") + assertThat(response).contains("\"serverName\":\"$GREEN_NAME\"") + } + + @Test + fun rejectedConfigIsRetriedWithServerConfig() { + val response = fixture().get(RETRY_NAME) + + assertThat(response).contains("\"echAccepted\":true") + assertThat(response).contains("\"serverName\":\"$RETRY_NAME\"") + } + + @Test + fun rejectedConfigWithoutServerConfigIsRetriedWithoutEch() { + val response = fixture().get(DISABLED_NAME) + + assertThat(response).contains("\"echAccepted\":false") + assertThat(response).contains("\"serverName\":\"$DISABLED_NAME\"") + } + + private fun fixture(): Fixture { + val arguments = InstrumentationRegistry.getArguments() + assumeTrue(arguments.getString("ech") == "true", "requires the host-side ECH fixtures") + assumeTrue(Build.VERSION.SDK_INT >= 37, "ECH requires Android API 37") + val dohPort = requireNotNull(arguments.getString("dohPort")).toInt() + val caCertificate = Base64.getDecoder().decode(requireNotNull(arguments.getString("caCertificate"))) + val (sslContext, trustManager) = sslContext(caCertificate) + return Fixture(dohPort, sslContext, trustManager) + } + + private class Fixture( + dohPort: Int, + sslContext: SSLContext, + trustManager: X509TrustManager, + ) { + private val client: OkHttpClient + + init { + val bootstrapClient = + OkHttpClient + .Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .build() + val dns = + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://$DOH_NAME:$dohPort/dns-query".toHttpUrl()) + .bootstrapDnsHosts(InetAddress.getByName("127.0.0.1")) + // HTTPS records, which is where the ECH config list and the origin's port arrive. + .includeServiceMetadata(true) + // The fixture resolves to 127.0.0.1, forwarded to the host by `adb reverse`. + .resolvePrivateAddresses(true) + .post(true) + .build() + client = bootstrapClient.newBuilder().dns(dns).build() + } + + fun get(hostname: String): String = + client + .newCall(Request("https://$hostname/".toHttpUrl())) + .execute() + .use { response -> + assertThat(response.code).isEqualTo(200) + response.body.string() + } + } + + private companion object { + private fun sslContext(caCertificatePem: ByteArray): Pair { + val certificate = + CertificateFactory + .getInstance("X.509") + .generateCertificate(ByteArrayInputStream(caCertificatePem)) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + keyStore.setCertificateEntry("fixture", certificate) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + val trustManager = trustManagerFactory.trustManagers.single() as X509TrustManager + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustManager), null) + return sslContext to trustManager + } + + private const val DOH_NAME = "doh.test" + private const val GREEN_NAME = "green.secret.test" + private const val RETRY_NAME = "retry.secret.test" + private const val DISABLED_NAME = "disabled.secret.test" + } +} diff --git a/build.gradle.kts b/build.gradle.kts index 4d398b7..bfffec1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,9 @@ +import org.gradle.language.base.plugins.LifecycleBasePlugin + plugins { alias(libs.plugins.kotlin.jvm) apply false + alias(libs.plugins.android.library) apply false + alias(libs.plugins.android.junit5) apply false } val testJavaVersion = @@ -21,11 +25,14 @@ val forbiddenImports = ) subprojects { - apply(plugin = "org.jetbrains.kotlin.jvm") - - configure { - toolchain { - languageVersion.set(JavaLanguageVersion.of(testJavaVersion)) + // Suites bring their own Kotlin plugin: `android-ech` is an Android module and can't + // share one with the JVM suites. Everything below is common to all of them, so it + // reacts to whichever plugin the suite applied rather than applying one from here. + plugins.withId("org.jetbrains.kotlin.jvm") { + configure { + toolchain { + languageVersion.set(JavaLanguageVersion.of(testJavaVersion)) + } } } @@ -67,8 +74,10 @@ subprojects { } } - tasks.named("check") { - dependsOn(checkPublicApiOnly) + plugins.withType { + tasks.named("check") { + dependsOn(checkPublicApiOnly) + } } tasks.withType().configureEach { diff --git a/containers/build.gradle.kts b/containers/build.gradle.kts index 3f2964b..98f8c36 100644 --- a/containers/build.gradle.kts +++ b/containers/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + alias(libs.plugins.kotlin.jvm) +} + // The version of OkHttp under test. Override to check a release candidate or a snapshot: // ./gradlew test -PokhttpVersion=5.5.0-SNAPSHOT val okhttpVersion = diff --git a/ech-fixture/build.gradle.kts b/ech-fixture/build.gradle.kts new file mode 100644 index 0000000..1484d13 --- /dev/null +++ b/ech-fixture/build.gradle.kts @@ -0,0 +1,19 @@ +import org.gradle.language.base.plugins.LifecycleBasePlugin + +plugins { + alias(libs.plugins.kotlin.jvm) +} + +// Not a suite: this module holds no tests and never touches OkHttp. It builds and runs the +// containers the `android-ech` suite talks to, on the host, because the device that runs +// those tests has no Docker of its own. +dependencies { + implementation(libs.testcontainers) +} + +tasks.register("runEchFixture") { + group = LifecycleBasePlugin.VERIFICATION_GROUP + description = "Starts the DoH and HTTPS containers the Android ECH suite runs against." + classpath = sourceSets["main"].runtimeClasspath + mainClass = "okhttp.testbed.ech.EchFixtureService" +} diff --git a/ech-fixture/src/main/kotlin/okhttp/testbed/ech/EchFixtureService.kt b/ech-fixture/src/main/kotlin/okhttp/testbed/ech/EchFixtureService.kt new file mode 100644 index 0000000..8734d15 --- /dev/null +++ b/ech-fixture/src/main/kotlin/okhttp/testbed/ech/EchFixtureService.kt @@ -0,0 +1,134 @@ +/* + * 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.ech + +import java.net.URI +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicBoolean +import org.testcontainers.containers.GenericContainer +import org.testcontainers.containers.wait.strategy.Wait +import org.testcontainers.images.builder.ImageFromDockerfile + +/** + * The two containers the Android ECH suite runs against: a DoH resolver and an HTTPS origin + * that speaks Encrypted Client Hello. + * + * This is not a test. The tests are Android instrumentation tests and run on a device or an + * emulator, which has no Docker; the containers have to live on the host, and the device + * reaches them over `adb reverse`. So the fixture runs as its own process: it starts both + * containers, writes their host ports and the fixture CA to the file named by + * `ECH_FIXTURE_ENDPOINT_FILE`, and stays up until that file is deleted. `run-ech-test.sh` + * is what drives it — see the ECH suite's section in the README. + */ +object EchFixtureService { + private const val CONTROL_PORT = 8080 + private const val DOH_PORT = 8053 + private const val TARGET_PORT = 8443 + + @JvmStatic + fun main(args: Array) { + require(args.isEmpty()) { "This service does not accept command-line arguments" } + + val endpointFile = + Path.of( + requireNotNull(System.getenv("ECH_FIXTURE_ENDPOINT_FILE")) { + "ECH_FIXTURE_ENDPOINT_FILE is not set" + }, + ) + val image = + ImageFromDockerfile("okhttp/testbed-ech-fixture:local", false) + .withFileFromClasspath("Dockerfile", "ech-fixture/Dockerfile") + .withFileFromClasspath("main.go", "ech-fixture/main.go") + + // The origin generates the CA, the leaf certificates and the ECH keys, then reports them + // on a plain HTTP control port. The resolver is configured from that, so both containers + // agree on the config lists without anything being pinned in this repository. + val target = GenericContainer(image) + target.withCommand("target") + target.withExposedPorts(CONTROL_PORT, TARGET_PORT) + target.waitingFor(Wait.forHttp("/health").forPort(CONTROL_PORT)) + // Building the Go image from source on a cold machine is the slow part, not the boot. + target.withStartupTimeout(Duration.ofMinutes(10)) + var doh: GenericContainer? = null + val stopped = AtomicBoolean() + val stop = { + if (stopped.compareAndSet(false, true)) { + doh?.stop() + target.stop() + } + } + + target.start() + val targetHost = target.host.normalizedLoopback() + val metadata = + URI("http://$targetHost:${target.getMappedPort(CONTROL_PORT)}/metadata") + .toURL() + .readText() + .lineSequence() + .filter { it.isNotEmpty() } + .associate { line -> + val (name, value) = line.split('=', limit = 2) + name to value + } + + val dohContainer = GenericContainer(image) + dohContainer.withCommand("doh") + dohContainer.withEnv("ECH_GREEN_CONFIG_LIST", metadata.required("ECH_GREEN_CONFIG_LIST")) + dohContainer.withEnv("ECH_RETRY_STALE_CONFIG_LIST", metadata.required("ECH_RETRY_STALE_CONFIG_LIST")) + dohContainer.withEnv( + "ECH_DISABLED_STALE_CONFIG_LIST", + metadata.required("ECH_DISABLED_STALE_CONFIG_LIST"), + ) + dohContainer.withEnv("DOH_CERT", metadata.required("DOH_CERT")) + dohContainer.withEnv("DOH_KEY", metadata.required("DOH_KEY")) + // The port the device dials, not the port the container is published on: the HTTPS + // record sends the client to 127.0.0.1:8443, which `adb reverse` forwards to the host. + dohContainer.withEnv("TARGET_PORT", TARGET_PORT.toString()) + dohContainer.withExposedPorts(DOH_PORT) + dohContainer.waitingFor(Wait.forHttps("/health").forPort(DOH_PORT).allowInsecure()) + dohContainer.withStartupTimeout(Duration.ofMinutes(5)) + doh = dohContainer + dohContainer.start() + + Runtime.getRuntime().addShutdownHook(Thread({ stop() }, "ech-fixture-service-shutdown")) + try { + val endpoints = + """ + DOH_HOST_PORT=${dohContainer.getMappedPort(DOH_PORT)} + TARGET_HOST_PORT=${target.getMappedPort(TARGET_PORT)} + CA_CERT=${metadata.required("CA_CERT")} + """.trimIndent() + "\n" + endpointFile.parent?.let { Files.createDirectories(it) } + Files.writeString(endpointFile, endpoints) + println("ECH fixture ready on $targetHost") + + // Deleting the endpoint file is how the script says it is done with the containers. + while (Files.exists(endpointFile)) { + Thread.sleep(250L) + } + } finally { + Files.deleteIfExists(endpointFile) + stop() + } + } + + private fun String.normalizedLoopback() = if (this == "localhost") "127.0.0.1" else this + + private fun Map.required(name: String) = + requireNotNull(this[name]) { "ECH fixture metadata does not contain $name" } +} diff --git a/ech-fixture/src/main/resources/ech-fixture/Dockerfile b/ech-fixture/src/main/resources/ech-fixture/Dockerfile new file mode 100644 index 0000000..312ef13 --- /dev/null +++ b/ech-fixture/src/main/resources/ech-fixture/Dockerfile @@ -0,0 +1,9 @@ +FROM golang:1.26-alpine AS build + +WORKDIR /src +COPY main.go . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /fixture main.go + +FROM alpine:3.23 +COPY --from=build /fixture /fixture +ENTRYPOINT ["/fixture"] diff --git a/ech-fixture/src/main/resources/ech-fixture/main.go b/ech-fixture/src/main/resources/ech-fixture/main.go new file mode 100644 index 0000000..00bc88f --- /dev/null +++ b/ech-fixture/src/main/resources/ech-fixture/main.go @@ -0,0 +1,361 @@ +package main + +import ( + "crypto/ecdh" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/binary" + "encoding/pem" + "fmt" + "io" + "log" + "math/big" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +const ( + greenName = "green.secret.test" + greenPublicName = "green.public.test" + retryName = "retry.secret.test" + retryPublicName = "retry.public.test" + disabledName = "disabled.secret.test" + disabledPublicName = "disabled.public.test" + dohName = "doh.test" +) + +type echKey struct { + config []byte + private []byte + retryable bool +} + +func main() { + if len(os.Args) != 2 { + log.Fatal("usage: fixture target|doh") + } + + switch os.Args[1] { + case "target": + runTarget() + case "doh": + runDoH() + default: + log.Fatalf("unknown mode %q", os.Args[1]) + } +} + +func runTarget() { + caCert, caKey, caPEM := newCA() + targetCertPEM, targetKeyPEM := newLeaf( + caCert, + caKey, + greenName, + greenPublicName, + retryName, + retryPublicName, + disabledName, + disabledPublicName, + ) + dohCertPEM, dohKeyPEM := newLeaf(caCert, caKey, dohName) + targetCertificate, err := tls.X509KeyPair(targetCertPEM, targetKeyPEM) + must(err) + + greenKey := newECHKey(1, greenPublicName, true) + retryStaleKey := newECHKey(2, retryPublicName, false) + retryKey := newECHKey(3, retryPublicName, true) + disabledStaleKey := newECHKey(4, disabledPublicName, false) + disabledKey := newECHKey(5, disabledPublicName, false) + + metadata := strings.Join([]string{ + "ECH_GREEN_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(greenKey)), + "ECH_RETRY_STALE_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(retryStaleKey)), + "ECH_DISABLED_STALE_CONFIG_LIST=" + base64.StdEncoding.EncodeToString(configList(disabledStaleKey)), + "DOH_CERT=" + base64.StdEncoding.EncodeToString(dohCertPEM), + "DOH_KEY=" + base64.StdEncoding.EncodeToString(dohKeyPEM), + "CA_CERT=" + base64.StdEncoding.EncodeToString(caPEM), + }, "\n") + "\n" + + go func() { + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") }) + mux.HandleFunc("/metadata", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, metadata) }) + log.Fatal(http.ListenAndServe(":8080", mux)) + }() + + tlsConfig := &tls.Config{ + Certificates: []tls.Certificate{targetCertificate}, + MinVersion: tls.VersionTLS13, + GetEncryptedClientHelloKeys: func(hello *tls.ClientHelloInfo) ([]tls.EncryptedClientHelloKey, error) { + var key echKey + switch hello.ServerName { + case greenPublicName: + key = greenKey + case retryPublicName: + key = retryKey + case disabledPublicName: + key = disabledKey + default: + return []tls.EncryptedClientHelloKey{}, nil + } + return []tls.EncryptedClientHelloKey{{ + Config: key.config, + PrivateKey: key.private, + SendAsRetry: key.retryable, + }}, nil + }, + } + mux := http.NewServeMux() + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"echAccepted":%t,"serverName":%q}`, r.TLS.ECHAccepted, r.TLS.ServerName) + }) + server := &http.Server{Addr: ":8443", Handler: mux, TLSConfig: tlsConfig} + log.Fatal(server.ListenAndServeTLS("", "")) +} + +func runDoH() { + certPEM := decodeEnvironment("DOH_CERT") + keyPEM := decodeEnvironment("DOH_KEY") + echConfigs := map[string][]byte{ + greenName: decodeEnvironment("ECH_GREEN_CONFIG_LIST"), + retryName: decodeEnvironment("ECH_RETRY_STALE_CONFIG_LIST"), + disabledName: decodeEnvironment("ECH_DISABLED_STALE_CONFIG_LIST"), + } + targetPort, err := strconv.Atoi(requiredEnvironment("TARGET_PORT")) + must(err) + certificate, err := tls.X509KeyPair(certPEM, keyPEM) + must(err) + + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { _, _ = io.WriteString(w, "ok") }) + mux.HandleFunc("/dns-query", func(w http.ResponseWriter, r *http.Request) { + query, err := readDnsQuery(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + response, err := dnsResponse(query, echConfigs, targetPort) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/dns-message") + _, _ = w.Write(response) + }) + server := &http.Server{ + Addr: ":8053", + Handler: mux, + TLSConfig: &tls.Config{ + Certificates: []tls.Certificate{certificate}, + MinVersion: tls.VersionTLS13, + }, + } + log.Fatal(server.ListenAndServeTLS("", "")) +} + +func readDnsQuery(r *http.Request) ([]byte, error) { + if r.Method == http.MethodPost { + return io.ReadAll(io.LimitReader(r.Body, 65536)) + } + if r.Method == http.MethodGet { + encoded := r.URL.Query().Get("dns") + return base64.RawURLEncoding.DecodeString(encoded) + } + return nil, fmt.Errorf("unsupported method %s", r.Method) +} + +func dnsResponse(query []byte, echConfigs map[string][]byte, targetPort int) ([]byte, error) { + if len(query) < 17 { + return nil, fmt.Errorf("short DNS query") + } + questionEnd, questionName, err := dnsQuestion(query) + if err != nil { + return nil, err + } + qtype := binary.BigEndian.Uint16(query[questionEnd-4 : questionEnd-2]) + var rdata []byte + switch qtype { + case 1: // A + rdata = []byte{127, 0, 0, 1} + case 65: // HTTPS + echConfigList, ok := echConfigs[questionName] + if !ok { + return nil, fmt.Errorf("no ECH fixture for %q", questionName) + } + rdata = httpsRecord(echConfigList, targetPort) + case 28: // AAAA: a successful response with no answers. + default: + } + + answerCount := uint16(0) + if rdata != nil { + answerCount = 1 + } + response := make([]byte, 12) + copy(response[0:2], query[0:2]) + binary.BigEndian.PutUint16(response[2:4], 0x8180) + binary.BigEndian.PutUint16(response[4:6], 1) + binary.BigEndian.PutUint16(response[6:8], answerCount) + response = append(response, query[12:questionEnd]...) + if rdata == nil { + return response, nil + } + response = append(response, 0xc0, 0x0c) + response = appendUint16(response, qtype) + response = appendUint16(response, 1) + response = appendUint32(response, 60) + response = appendUint16Length(response, rdata) + return response, nil +} + +func dnsQuestion(query []byte) (int, string, error) { + position := 12 + var labels []string + for { + if position >= len(query) { + return 0, "", fmt.Errorf("invalid DNS name") + } + length := int(query[position]) + position++ + if length == 0 { + break + } + if length > 63 || position+length > len(query) { + return 0, "", fmt.Errorf("invalid DNS label") + } + labels = append(labels, string(query[position:position+length])) + position += length + } + if position+4 > len(query) { + return 0, "", fmt.Errorf("short DNS question") + } + return position + 4, strings.ToLower(strings.Join(labels, ".")), nil +} + +func httpsRecord(echConfigList []byte, targetPort int) []byte { + result := appendUint16(nil, 1) // SvcPriority. + result = append(result, 0) // TargetName is the owner name. + result = appendSvcParam(result, 1, []byte{2, 'h', '2', 8, 'h', 't', 't', 'p', '/', '1', '.', '1'}) + port := make([]byte, 2) + binary.BigEndian.PutUint16(port, uint16(targetPort)) + result = appendSvcParam(result, 3, port) + result = appendSvcParam(result, 4, []byte{127, 0, 0, 1}) + result = appendSvcParam(result, 5, echConfigList) + return result +} + +func appendSvcParam(dst []byte, key uint16, value []byte) []byte { + dst = appendUint16(dst, key) + return appendUint16Length(dst, value) +} + +func marshalECHConfig(id byte, publicKey []byte, publicName string, maxNameLength byte) []byte { + contents := []byte{id} + contents = appendUint16(contents, 0x0020) // DHKEM(X25519, HKDF-SHA256). + contents = appendUint16Length(contents, publicKey) + cipherSuites := appendUint16(nil, 0x0001) // HKDF-SHA256. + cipherSuites = appendUint16(cipherSuites, 0x0001) // AES-128-GCM. + contents = appendUint16Length(contents, cipherSuites) + contents = append(contents, maxNameLength, byte(len(publicName))) + contents = append(contents, publicName...) + contents = appendUint16(contents, 0) // Extensions. + + config := appendUint16(nil, 0xfe0d) + return appendUint16Length(config, contents) +} + +func newECHKey(id byte, publicName string, retryable bool) echKey { + privateKey, err := ecdh.X25519().GenerateKey(rand.Reader) + must(err) + return echKey{ + config: marshalECHConfig(id, privateKey.PublicKey().Bytes(), publicName, 64), + private: privateKey.Bytes(), + retryable: retryable, + } +} + +func configList(key echKey) []byte { + return appendUint16Length(nil, key.config) +} + +func appendUint16(dst []byte, value uint16) []byte { + return binary.BigEndian.AppendUint16(dst, value) +} + +func appendUint32(dst []byte, value uint32) []byte { + return binary.BigEndian.AppendUint32(dst, value) +} + +func appendUint16Length(dst, value []byte) []byte { + dst = appendUint16(dst, uint16(len(value))) + return append(dst, value...) +} + +func newCA() (*x509.Certificate, *ecdsa.PrivateKey, []byte) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + must(err) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "OkHttp ECH Test CA"}, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + IsCA: true, + BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + must(err) + certificate, err := x509.ParseCertificate(der) + must(err) + return certificate, key, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) +} + +func newLeaf(ca *x509.Certificate, caKey *ecdsa.PrivateKey, names ...string) ([]byte, []byte) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + must(err) + now := time.Now() + template := &x509.Certificate{ + SerialNumber: big.NewInt(now.UnixNano()), + Subject: pkix.Name{CommonName: names[0]}, + DNSNames: names, + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(24 * time.Hour), + KeyUsage: x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, template, ca, &key.PublicKey, caKey) + must(err) + keyDER, err := x509.MarshalPKCS8PrivateKey(key) + must(err) + return pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}) +} + +func requiredEnvironment(name string) string { + value := os.Getenv(name) + if value == "" { + log.Fatalf("%s is not set", name) + } + return value +} + +func decodeEnvironment(name string) []byte { + value, err := base64.StdEncoding.DecodeString(requiredEnvironment(name)) + must(err) + return value +} + +func must(err error) { + if err != nil { + log.Fatal(err) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 01b90d7..5c11c9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,4 +1,7 @@ [versions] +agp = "9.1.1" +android-junit5 = "2.0.1" +androidx-test-runner = "1.7.0" assertk = "0.28.1" junit-platform = "1.14.4" kotlin = "2.2.21" @@ -6,15 +9,22 @@ kotlin = "2.2.21" # server whose major.minor differs. Injected into the tests as mockserver.version. mockserver = "7.4.0" okhttp = "5.4.0" +# The ECH suite needs DnsOverHttps.Builder.includeServiceMetadata, which no release has +# yet: 5.4.0 resolves A and AAAA records only, so there is no HTTPS record to carry an +# ECH config list. Drop this back to `okhttp` once a release ships it. +ech-okhttp = "5.5.0-SNAPSHOT" org-junit-jupiter = "5.13.4" testcontainers = "1.21.4" [libraries] +androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidx-test-runner" } assertk = { module = "com.willowtreeapps.assertk:assertk", version.ref = "assertk" } junit-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "org-junit-jupiter" } junit-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "org-junit-jupiter" } junit-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "org-junit-jupiter" } junit-platform-launcher = { module = "org.junit.platform:junit-platform-launcher", version.ref = "junit-platform" } +junit5android-core = { module = "de.mannodermaus.junit5:android-test-core", version.ref = "android-junit5" } +junit5android-runner = { module = "de.mannodermaus.junit5:android-test-runner", version.ref = "android-junit5" } mockserver = { module = "org.testcontainers:mockserver", version.ref = "testcontainers" } mockserver-client = { module = "org.mock-server:mockserver-client-java-no-dependencies", version.ref = "mockserver" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } @@ -22,4 +32,6 @@ testcontainers = { module = "org.testcontainers:testcontainers", version.ref = " testcontainers-junit5 = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" } [plugins] +android-junit5 = { id = "de.mannodermaus.android-junit5", version.ref = "android-junit5" } +android-library = { id = "com.android.library", version.ref = "agp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 712643c..774d40b 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,16 @@ rootProject.name = "okhttp-testbed" +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} + dependencyResolutionManagement { repositories { + google() mavenCentral() maven("https://central.sonatype.com/repository/maven-snapshots/") { mavenContent { @@ -12,3 +21,5 @@ dependencyResolutionManagement { } include(":containers") +include(":ech-fixture") +include(":android-ech")