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 { 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..4b0a727f6de0 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,152 @@ */ 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.matchesPredicate +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.Response +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 import org.junit.jupiter.api.Test +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 { - @Test - fun testHttpsRequest() { - val client: OkHttpClient = +@Burst +class EchTest( + private val useDoh: Boolean = false, +) { + private lateinit var client: OkHttpClient + + @BeforeEach + fun setUp() { + // EchAwareDns reads API 37 NetworkSecurityPolicy.getDomainEncryptionMode(). + assumeTrue(Build.VERSION.SDK_INT >= 37) + + client = OkHttpClient .Builder() + .dns(dns()) .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 = 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") + } - val tlsEchBody = - client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { - it.body.string() + @Test + fun wrongPublicNameIsNotRetried() { + 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") + } + + /** + * TLS 1.2 cannot carry ECH. + */ + @Test + fun tls12OffersNothingToRetryWith() { + assertThat(client.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 OkHttpClient.echRejectionFrom(url: String): EchConfigMismatchException { + val body = + try { + get(url) + } catch (e: EchConfigMismatchException) { + return e } - assertThat(tlsEchBody).matchesPredicate { it.contains("ECH enabled") } + + fail("expected $url to reject ECH, but it returned: $body") + } + + @Test + fun defoUsesEch() { + assertThat(client.get("https://defo.ie/ech-check.php")).contains("SSL_ECH_STATUS: success") } - private fun OkHttpClient.sendRequest( - request: Request, - fn: (Response) -> T, - ): T { - val response = newCall(request).execute() + /** + * Disabled by policy. + */ + @Test + fun policyDisabledHostDoesNotUseEch() { + assertThat(client.get("https://crypto.cloudflare.com/cdn-cgi/trace")).contains("sni=plaintext") + } - return response.use { - fn(it) + /** + * [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() } - } } 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 + + +