From 55e2c9c5c4cd13b1a8fd219e38f451233b8e3929 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 14:07:08 +0100 Subject: [PATCH 1/3] Add local only EchTests --- .../java/okhttp/android/test/EchTest.kt | 120 +++++++++++++----- .../main/res/xml/network_security_config.xml | 10 +- 2 files changed, 99 insertions(+), 31 deletions(-) diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index cad2037d8d29..823f04edec14 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -15,52 +15,114 @@ */ package okhttp.android.test +import android.annotation.SuppressLint +import android.net.ssl.EchConfigMismatchException +import android.os.Build import assertk.assertThat -import assertk.assertions.matchesPredicate +import assertk.assertions.contains +import assertk.assertions.doesNotContain +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isTrue import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response +import okhttp3.android.EchAwareDns +import org.junit.jupiter.api.Assumptions.assumeTrue +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Tag import org.junit.jupiter.api.Test +import org.junit.jupiter.api.fail +/** + * Confirms Encrypted Client Hello (ECH) end to end, with [EchAwareDns]. + * + * See `res/xml/network_security_config.xml` for overrides. + */ +@SuppressLint("NewApi") @Tag("Remote") class EchTest { - @Test - fun testHttpsRequest() { - val client: OkHttpClient = + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + // EchAwareDns reads API 37 NetworkSecurityPolicy.getDomainEncryptionMode(). + assumeTrue(Build.VERSION.SDK_INT >= 37) + + client = OkHttpClient .Builder() + .dns(EchAwareDns()) .build() + } - val cloudflareEchBody = - client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { - it.body.string() - } - assertThat(cloudflareEchBody).matchesPredicate { it.contains("ECH enabled") } + @Test + fun cloudflareUsesEch() { + assertThat(client.get("https://cloudflare-ech.com/cdn-cgi/trace")).contains("sni=encrypted") + } - val cloudflareBody = - client.sendRequest( - Request.Builder().url("https://crypto.cloudflare.com/cdn-cgi/trace").build(), - ) { - it.body.string() - } - assertThat(cloudflareBody).matchesPredicate { it.contains("ECH enabled") } + @Test + fun tlsEchDevUsesEch() { + val body = client.get("https://tls-ech.dev/") + + assertThat(body).contains("You are using ECH") + assertThat(body).doesNotContain("not using ECH") + } + + @Test + fun staleEchConfigIsNotRetried() { + val rejection = echRejectionFrom("https://stale.tls-ech.dev/") - val tlsEchBody = - client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { - it.body.string() + assertThat(rejection.hasRetryConfigList()).isTrue() + assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + } + + @Test + fun wrongPublicNameIsNotRetried() { + val rejection = echRejectionFrom("https://wrong.tls-ech.dev/") + + assertThat(rejection.hasRetryConfigList()).isTrue() + assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") + } + + /** + * TLS 1.2 cannot carry ECH. + */ + @Test + fun tls12OffersNothingToRetryWith() { + assertThat(echRejectionFrom("https://tls12.tls-ech.dev/").hasRetryConfigList()).isFalse() + } + + /** + * Makes the call at [url] and returns the ECH rejection it fails with. + * + * TODO handle EchConfigMismatchException.retry_configs. + */ + private fun echRejectionFrom(url: String): EchConfigMismatchException { + val body = + try { + client.get(url) + } catch (e: EchConfigMismatchException) { + return e } - assertThat(tlsEchBody).matchesPredicate { it.contains("ECH enabled") } + + fail("expected $url to reject ECH, but it returned: $body") } - private fun OkHttpClient.sendRequest( - request: Request, - fn: (Response) -> T, - ): T { - val response = newCall(request).execute() + @Test + fun defoUsesEch() { + assertThat(client.get("https://defo.ie/ech-check.php")).contains("SSL_ECH_STATUS: success") + } - return response.use { - fn(it) - } + /** + * Disabled by policy. + */ + @Test + fun policyDisabledHostDoesNotUseEch() { + assertThat(client.get("https://crypto.cloudflare.com/cdn-cgi/trace")).contains("sni=plaintext") } + + private fun OkHttpClient.get(url: String): String = + newCall(Request.Builder().url(url).build()).execute().use { response -> + response.body.string() + } } diff --git a/android-test/src/main/res/xml/network_security_config.xml b/android-test/src/main/res/xml/network_security_config.xml index 96c2e646ab80..319ff322509a 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -5,10 +5,16 @@ localhost + cloudflare-ech.com - crypto.cloudflare.com tls-ech.dev + defo.ie - \ No newline at end of file + + + crypto.cloudflare.com + + + From a20315ab5e86bde97265e4b3f447909238979734 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 14:15:59 +0100 Subject: [PATCH 2/3] test doh as well --- .../java/okhttp/android/test/EchTest.kt | 52 ++++++++++++++++--- 1 file changed, 45 insertions(+), 7 deletions(-) diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt index 823f04edec14..4b0a727f6de0 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -18,15 +18,18 @@ package okhttp.android.test import android.annotation.SuppressLint import android.net.ssl.EchConfigMismatchException import android.os.Build +import app.cash.burst.Burst import assertk.assertThat import assertk.assertions.contains import assertk.assertions.doesNotContain import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isTrue +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.android.EchAwareDns +import okhttp3.dnsoverhttps.DnsOverHttps import org.junit.jupiter.api.Assumptions.assumeTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Tag @@ -36,11 +39,16 @@ import org.junit.jupiter.api.fail /** * Confirms Encrypted Client Hello (ECH) end to end, with [EchAwareDns]. * + * Test with both [okhttp3.android.AndroidDns] and [DnsOverHttps]. + * * See `res/xml/network_security_config.xml` for overrides. */ @SuppressLint("NewApi") @Tag("Remote") -class EchTest { +@Burst +class EchTest( + private val useDoh: Boolean = false, +) { private lateinit var client: OkHttpClient @BeforeEach @@ -51,7 +59,7 @@ class EchTest { client = OkHttpClient .Builder() - .dns(EchAwareDns()) + .dns(dns()) .build() } @@ -70,16 +78,18 @@ class EchTest { @Test fun staleEchConfigIsNotRetried() { - val rejection = echRejectionFrom("https://stale.tls-ech.dev/") + val rejection = client.echRejectionFrom("https://stale.tls-ech.dev/") + // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. assertThat(rejection.hasRetryConfigList()).isTrue() assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") } @Test fun wrongPublicNameIsNotRetried() { - val rejection = echRejectionFrom("https://wrong.tls-ech.dev/") + val rejection = client.echRejectionFrom("https://wrong.tls-ech.dev/") + // TODO retry with these, then assert "You are using ECH" like tlsEchDevUsesEch. assertThat(rejection.hasRetryConfigList()).isTrue() assertThat(rejection.publicHostname).isEqualTo("public.tls-ech.dev") } @@ -89,7 +99,7 @@ class EchTest { */ @Test fun tls12OffersNothingToRetryWith() { - assertThat(echRejectionFrom("https://tls12.tls-ech.dev/").hasRetryConfigList()).isFalse() + assertThat(client.echRejectionFrom("https://tls12.tls-ech.dev/").hasRetryConfigList()).isFalse() } /** @@ -97,10 +107,10 @@ class EchTest { * * TODO handle EchConfigMismatchException.retry_configs. */ - private fun echRejectionFrom(url: String): EchConfigMismatchException { + private fun OkHttpClient.echRejectionFrom(url: String): EchConfigMismatchException { val body = try { - client.get(url) + get(url) } catch (e: EchConfigMismatchException) { return e } @@ -121,6 +131,34 @@ class EchTest { assertThat(client.get("https://crypto.cloudflare.com/cdn-cgi/trace")).contains("sni=plaintext") } + /** + * [EchAwareDns] over the platform resolver, or over DoH when [useDoh]. Both arms use the same + * source: the ECH one carries service metadata, the other doesn't. + */ + private fun dns(): EchAwareDns = + when { + useDoh -> { + val bootstrapClient = OkHttpClient() + EchAwareDns( + echDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = true), + addressOnlyDns = dnsOverHttps(bootstrapClient, includeServiceMetadata = false), + ) + } + else -> EchAwareDns() + } + + /** Addressed by IP, so resolving the resolver doesn't need a resolver. */ + private fun dnsOverHttps( + bootstrapClient: OkHttpClient, + includeServiceMetadata: Boolean, + ): DnsOverHttps = + DnsOverHttps + .Builder() + .client(bootstrapClient) + .url("https://1.1.1.1/dns-query".toHttpUrl()) + .includeServiceMetadata(includeServiceMetadata) + .build() + private fun OkHttpClient.get(url: String): String = newCall(Request.Builder().url(url).build()).execute().use { response -> response.body.string() From c23a1fb30d04e606d348c9489ba63b5bfdc2e623 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 14:33:15 +0100 Subject: [PATCH 3/3] add burst --- android-test/build.gradle.kts | 1 + 1 file changed, 1 insertion(+) diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index bc0e5212c78b..9ca1ccfd704b 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -5,6 +5,7 @@ plugins { id("okhttp.base-conventions") id("com.android.library") id("de.mannodermaus.android-junit5") + id("app.cash.burst") } android {