From 34b5d294641df5d3b7394da8279ef8b22de39de6 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sun, 26 Jul 2026 18:15:00 +0100 Subject: [PATCH 1/9] Handle ECH Retry --- .../java/okhttp/android/test/EchTest.kt | 37 +++++++----- .../internal/platform/Android10Platform.kt | 18 ++++++ .../internal/connection/ConnectPlan.kt | 58 +++++++++++++++++-- .../okhttp3/internal/dns/-DnsMessage.kt | 5 ++ .../okhttp3/internal/platform/Platform.kt | 5 ++ 5 files changed, 105 insertions(+), 18 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 4b0a727f6de0..54e48ca5b589 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -22,9 +22,7 @@ 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 @@ -77,21 +75,34 @@ class EchTest( } @Test - fun staleEchConfigIsNotRetried() { - val rejection = client.echRejectionFrom("https://stale.tls-ech.dev/") + fun staleEchConfigIsRetried() { + val body = client.get("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") + assertThat(body).contains("You are using ECH") + assertThat(body).doesNotContain("not using ECH") } @Test - fun wrongPublicNameIsNotRetried() { - val rejection = client.echRejectionFrom("https://wrong.tls-ech.dev/") + fun differentPublicHostnameIsVerifiedBeforeRetry() { + // The outer certificate authenticates public.tls-ech.dev, + // so the retry config may be used if it matches. + // https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 + // TODO: Add a fixture whose public hostname fails authentication. + val verifiedHostnames = mutableListOf() + val hostnameVerifier = client.hostnameVerifier + val client = + client + .newBuilder() + .hostnameVerifier { hostname, session -> + verifiedHostnames += hostname + hostnameVerifier.verify(hostname, session) + } + .build() - // 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 body = client.get("https://wrong.tls-ech.dev/") + + assertThat(body).contains("You are using ECH") + assertThat(verifiedHostnames).contains("public.tls-ech.dev") } /** @@ -104,8 +115,6 @@ class EchTest( /** * 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 = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index e89e0260f59c..0d212706918b 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -17,17 +17,20 @@ package okhttp3.internal.platform import android.annotation.SuppressLint import android.content.Context +import android.net.ssl.EchConfigMismatchException import android.os.Build import android.os.StrictMode import android.security.NetworkSecurityPolicy import android.util.CloseGuard import android.util.Log import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.platform.AndroidPlatform.Companion.Tag import okhttp3.internal.platform.android.Android10SocketAdapter import okhttp3.internal.platform.android.Android17SocketAdapter @@ -39,6 +42,7 @@ import okhttp3.internal.platform.android.DeferredSocketAdapter import okhttp3.internal.tls.CertificateChainCleaner import okhttp3.internal.tls.TrustRootIndex import okio.ByteString +import okio.ByteString.Companion.toByteString /** Android 10+ (API 29+). */ @SuppressSignatureCheck @@ -86,6 +90,20 @@ class Android10Platform : ?.configureTlsExtensions(sslSocket, hostname, protocols, echConfigList) } + @SuppressLint("NewApi") + internal override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? { + if (Build.VERSION.SDK_INT < 37 || exception !is EchConfigMismatchException) return null + + return EchRetryConfig( + publicHostname = exception.publicHostname ?: return null, + configList = + exception.retryConfigList + ?.toBytes() + ?.toByteString() + ?: return null, + ) + } + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = // No TLS extensions if the socket class is custom. socketAdapters.find { it.matchesSocket(sslSocket) }?.getSelectedProtocol(sslSocket) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 5047053680d5..33fee9daf8fd 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -25,6 +25,7 @@ import java.net.Socket as JavaNetSocket import java.net.UnknownServiceException import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit +import javax.net.ssl.SSLException import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSocket import okhttp3.CertificatePinner @@ -38,6 +39,7 @@ import okhttp3.internal.closeQuietly import okhttp3.internal.concurrent.TaskRunner import okhttp3.internal.concurrent.withLock import okhttp3.internal.connection.RoutePlanner.ConnectResult +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.http.ExchangeCodec import okhttp3.internal.http1.Http1ExchangeCodec import okhttp3.internal.platform.Platform @@ -73,6 +75,7 @@ class ConnectPlan internal constructor( private val tunnelRequest: Request?, internal val connectionSpecIndex: Int, internal val isTlsFallback: Boolean, + private val echRetryConfig: EchRetryConfig? = null, ) : RoutePlanner.Plan, ExchangeCodec.Carrier { /** True if this connect was canceled; typically because it lost a race. */ @@ -98,10 +101,12 @@ class ConnectPlan internal constructor( get() = protocol != null private fun copy( + route: Route = this.route, attempt: Int = this.attempt, tunnelRequest: Request? = this.tunnelRequest, connectionSpecIndex: Int = this.connectionSpecIndex, isTlsFallback: Boolean = this.isTlsFallback, + echRetryConfig: EchRetryConfig? = this.echRetryConfig, ): ConnectPlan = ConnectPlan( taskRunner = taskRunner, @@ -120,6 +125,7 @@ class ConnectPlan internal constructor( tunnelRequest = tunnelRequest, connectionSpecIndex = connectionSpecIndex, isTlsFallback = isTlsFallback, + echRetryConfig = echRetryConfig, ) override fun connectTcp(): ConnectResult { @@ -161,6 +167,7 @@ class ConnectPlan internal constructor( check(!isReady) { "already connected" } val connectionSpecs = route.address.connectionSpecs + var offeredEchRetryConfig: EchRetryConfig? = null var retryTlsConnection: ConnectPlan? = null var success = false @@ -204,7 +211,21 @@ class ConnectPlan internal constructor( retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket) connectionSpec.apply(sslSocket, isFallback = tlsEquipPlan.isTlsFallback) - connectTls(sslSocket, connectionSpec) + try { + connectTls(sslSocket, connectionSpec) + } catch (e: SSLException) { + val echRetryConfig = Platform.get().getEchRetryConfig(e) + if ( + echRetryConfig != null && + route.address.hostnameVerifier!!.verify( + echRetryConfig.publicHostname, + sslSocket.session, + ) + ) { + offeredEchRetryConfig = echRetryConfig + } + throw e + } call.eventListener.secureConnectEnd(call, handshake) } else { javaNetSocket = rawSocket @@ -239,9 +260,37 @@ class ConnectPlan internal constructor( call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, e) connectionPool.connectionListener.connectFailed(route, call, e) - if (!retryOnConnectionFailure || !retryTlsHandshake(e)) { - retryTlsConnection = null - } + retryTlsConnection = + when { + echRetryConfig == null && offeredEchRetryConfig != null -> { + // TODO: Should an ECH retry honor retryOnConnectionFailure? + // Typically Conscrypt throwing EchConfigMismatchException + copy( + route = + Route( + address = route.address, + proxy = route.proxy, + socketAddress = route.socketAddress, + echConfigList = offeredEchRetryConfig.configList, + ), + echRetryConfig = offeredEchRetryConfig, + ) + } + + echRetryConfig != null && offeredEchRetryConfig != null -> { + // TODO: Should we treat an untrusted or missing ECH retry config as an ordinary + // SSLException and try another connection spec? + null + } + + retryOnConnectionFailure && retryTlsHandshake(e) -> { + retryTlsConnection + } + + else -> { + null + } + } return ConnectResult( plan = this, @@ -561,6 +610,7 @@ class ConnectPlan internal constructor( tunnelRequest = tunnelRequest, connectionSpecIndex = connectionSpecIndex, isTlsFallback = isTlsFallback, + echRetryConfig = echRetryConfig, ) fun closeQuietly() { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt index 3c8e7af5c091..0352d47f6ba4 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt @@ -68,6 +68,11 @@ data class DnsMessage( } } +internal data class EchRetryConfig( + val configList: ByteString, + val publicHostname: String, +) + @OkHttpInternalApi data class Question( val name: String, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index 3633ab55801d..8f9eb1a45406 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -26,6 +26,7 @@ import java.util.logging.Logger import javax.net.ssl.ExtendedSSLSession import javax.net.ssl.SNIHostName import javax.net.ssl.SSLContext +import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -34,6 +35,7 @@ import javax.net.ssl.X509TrustManager import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.internal.dns.EchRetryConfig import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.readFieldOrNull import okhttp3.internal.tls.BasicCertificateChainCleaner @@ -122,6 +124,9 @@ open class Platform { ) { } + /** Returns the ECH retry configuration carried by [exception]. */ + internal open fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = null + /** Called after the TLS handshake to release resources allocated by [configureTlsExtensions]. */ open fun afterHandshake(sslSocket: SSLSocket) { } From 859cb994e2a9b56f19d40ab7cc55302d50bc5dd3 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 27 Jul 2026 20:40:04 +0100 Subject: [PATCH 2/9] refactor ech retry logic --- .../java/okhttp/android/test/EchTest.kt | 1 - .../src/main/kotlin/okhttp3/FakeDns.kt | 2 +- .../internal/connection/ConnectPlan.kt | 101 +++++++-------- .../internal/connection/RouteSelector.kt | 8 +- .../okhttp3/internal/dns/-DnsMessage.kt | 5 - .../okhttp3/internal/dns/EchRetryConfig.kt | 29 +++++ .../connection/RetryConnectionTest.kt | 119 +++++++++++++++++- .../internal/connection/RouteSelectorTest.kt | 38 ++++++ 8 files changed, 242 insertions(+), 61 deletions(-) create mode 100644 okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt 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 54e48ca5b589..c97de41d4eae 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -87,7 +87,6 @@ class EchTest( // The outer certificate authenticates public.tls-ech.dev, // so the retry config may be used if it matches. // https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 - // TODO: Add a fixture whose public hostname fails authentication. val verifiedHostnames = mutableListOf() val hostnameVerifier = client.hostnameVerifier val client = diff --git a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt index 78fff4e85481..0f7075ac9935 100644 --- a/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt +++ b/okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt @@ -275,7 +275,7 @@ class FakeDns( } is ResourceRecord.IpAddress -> { - val ipAddressRecord = Dns.Record.IpAddress(request.hostname, resourceRecord.address) + val ipAddressRecord = Dns.Record.IpAddress(resourceRecord.name, resourceRecord.address) when (resourceRecord.address) { is Inet4Address -> ipv4Records += ipAddressRecord is Inet6Address -> ipv6Records += ipAddressRecord diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 33fee9daf8fd..e815df74a70c 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -167,7 +167,6 @@ class ConnectPlan internal constructor( check(!isReady) { "already connected" } val connectionSpecs = route.address.connectionSpecs - var offeredEchRetryConfig: EchRetryConfig? = null var retryTlsConnection: ConnectPlan? = null var success = false @@ -207,23 +206,11 @@ class ConnectPlan internal constructor( val tlsEquipPlan = planWithCurrentOrInitialConnectionSpec(connectionSpecs, sslSocket) val connectionSpec = connectionSpecs[tlsEquipPlan.connectionSpecIndex] - // Figure out the next connection spec in case we need a retry. - retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket) - connectionSpec.apply(sslSocket, isFallback = tlsEquipPlan.isTlsFallback) try { connectTls(sslSocket, connectionSpec) } catch (e: SSLException) { - val echRetryConfig = Platform.get().getEchRetryConfig(e) - if ( - echRetryConfig != null && - route.address.hostnameVerifier!!.verify( - echRetryConfig.publicHostname, - sslSocket.session, - ) - ) { - offeredEchRetryConfig = echRetryConfig - } + retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket, e) throw e } call.eventListener.secureConnectEnd(call, handshake) @@ -260,38 +247,6 @@ class ConnectPlan internal constructor( call.eventListener.connectFailed(call, route.socketAddress, route.proxy, null, e) connectionPool.connectionListener.connectFailed(route, call, e) - retryTlsConnection = - when { - echRetryConfig == null && offeredEchRetryConfig != null -> { - // TODO: Should an ECH retry honor retryOnConnectionFailure? - // Typically Conscrypt throwing EchConfigMismatchException - copy( - route = - Route( - address = route.address, - proxy = route.proxy, - socketAddress = route.socketAddress, - echConfigList = offeredEchRetryConfig.configList, - ), - echRetryConfig = offeredEchRetryConfig, - ) - } - - echRetryConfig != null && offeredEchRetryConfig != null -> { - // TODO: Should we treat an untrusted or missing ECH retry config as an ordinary - // SSLException and try another connection spec? - null - } - - retryOnConnectionFailure && retryTlsHandshake(e) -> { - retryTlsConnection - } - - else -> { - null - } - } - return ConnectResult( plan = this, nextPlan = retryTlsConnection, @@ -528,7 +483,7 @@ class ConnectPlan internal constructor( sslSocket: SSLSocket, ): ConnectPlan { if (connectionSpecIndex != -1) return this - return nextConnectionSpec(connectionSpecs, sslSocket) + return nextCompatibleConnectionSpec(connectionSpecs, sslSocket) ?: throw UnknownServiceException( "Unable to find acceptable protocols." + " isFallback=$isTlsFallback," + @@ -538,12 +493,60 @@ class ConnectPlan internal constructor( } /** - * Returns a copy of this connection with the next connection spec to try, or null if no other - * compatible connection specs are available. + * Returns a copy of this connection that recovers from [sslException], or null if the failure + * should not be retried. */ internal fun nextConnectionSpec( connectionSpecs: List, sslSocket: SSLSocket, + sslException: SSLException, + ): ConnectPlan? { + if (!retryOnConnectionFailure) return null + + val offeredEchRetryConfig = Platform.get().getEchRetryConfig(sslException) + if (offeredEchRetryConfig != null) { + // TODO should we emit an event that we considered ech retry? + + // Only use ECH retry once + if (echRetryConfig != null) return null + + // Validate the publicHostname against the session certificate + if ( + !route.address.hostnameVerifier!!.verify( + offeredEchRetryConfig.publicHostname, + sslSocket.session, + ) + ) { + return null + } + + // retry with an updated ECH config + return copy( + route = + Route( + address = route.address, + proxy = route.proxy, + socketAddress = route.socketAddress, + echConfigList = offeredEchRetryConfig.configList, + ), + echRetryConfig = offeredEchRetryConfig, + ) + } + + // If this was already in response to an ech retry, we are done for this + // connection + if (echRetryConfig != null || !retryTlsHandshake(sslException)) return null + + return nextCompatibleConnectionSpec(connectionSpecs, sslSocket) + } + + /** + * Returns a copy of this connection with the next compatible connection spec, or null if none + * are available. + */ + private fun nextCompatibleConnectionSpec( + connectionSpecs: List, + sslSocket: SSLSocket, ): ConnectPlan? { for (i in connectionSpecIndex + 1 until connectionSpecs.size) { if (connectionSpecs[i].isCompatible(sslSocket)) { diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt index 0bba12759fb4..d9828345e203 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt @@ -180,11 +180,15 @@ class RouteSelector internal constructor( } val routes = dnsLookup(proxy, socketHost, socketPort) + + // If DNS advertises ECH for any route, don't permit a retry without ECH. + val echRoutes = routes.filter { it.echConfigList != null } + val routesToTry = echRoutes.ifEmpty { routes } // Try each address for best behavior in mixed IPv4/IPv6 environments. return when { - fastFallback -> reorderForHappyEyeballs(routes) - else -> routes + fastFallback -> reorderForHappyEyeballs(routesToTry) + else -> routesToTry } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt index 0352d47f6ba4..3c8e7af5c091 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/-DnsMessage.kt @@ -68,11 +68,6 @@ data class DnsMessage( } } -internal data class EchRetryConfig( - val configList: ByteString, - val publicHostname: String, -) - @OkHttpInternalApi data class Question( val name: String, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt new file mode 100644 index 000000000000..d7fb7ae4e70b --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt @@ -0,0 +1,29 @@ +/* + * 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 okhttp3.internal.dns + +import okio.ByteString + +/** + * ECH Retry config. Generally sent by a server when there is a + * mismatch between A/AAAA and HTTPS Records. Must only be used + * when publicHostname can be validated against the certificate + * from the SSLSession. + */ +internal data class EchRetryConfig( + val configList: ByteString, + val publicHostname: String, +) diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt index 8d75d19e9d6d..aed129234f8a 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt @@ -17,20 +17,28 @@ package okhttp3.internal.connection import assertk.assertThat import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEqualTo import assertk.assertions.isFalse +import assertk.assertions.isNotEqualTo import assertk.assertions.isNotNull import assertk.assertions.isNull import assertk.assertions.isTrue import java.io.IOException import java.security.cert.CertificateException +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLSocket import okhttp3.ConnectionSpec +import okhttp3.FakeDns import okhttp3.OkHttpClientTestRule import okhttp3.TestValueFactory import okhttp3.TlsVersion +import okhttp3.internal.dns.EchRetryConfig +import okhttp3.internal.dns.ResourceRecord +import okhttp3.internal.platform.Platform import okhttp3.testing.PlatformRule import okhttp3.tls.internal.TlsUtil.localhost +import okio.ByteString.Companion.encodeUtf8 import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension @@ -39,12 +47,25 @@ class RetryConnectionTest { private val factory = TestValueFactory() private val handshakeCertificates = localhost() private val retryableException = SSLHandshakeException("Simulated handshake exception") + private val echRetryException = SSLHandshakeException("Simulated ECH rejection") + private val echRetryConfig = + EchRetryConfig( + configList = "retry config".encodeUtf8(), + publicHostname = "public.tls-ech.dev", + ) @RegisterExtension val clientTestRule = OkHttpClientTestRule() @RegisterExtension - val platform = PlatformRule() + val platform = + PlatformRule( + platform = + object : Platform() { + override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = + if (exception === echRetryException) echRetryConfig else null + }, + ) private var client = clientTestRule.newClient() @@ -69,6 +90,98 @@ class RetryConnectionTest { assertThat(retryTlsHandshake(retryableException)).isTrue() } + @Test fun echRetryConfigIsUsedOnceWithoutTlsFallback() { + val verifiedHostnames = mutableListOf() + val address = + factory.newHttpsAddress( + hostnameVerifier = { hostname, _ -> + verifiedHostnames += hostname + true + }, + ) + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.echConfigList).isEqualTo(echRetryConfig.configList) + assertThat(attempt1.isTlsFallback).isFalse() + assertThat(verifiedHostnames).isEqualTo(listOf(echRetryConfig.publicHostname)) + + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, retryableException) + assertThat(attempt2).isNull() + socket.close() + } + + /** https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 */ + @Test fun echRetryUsesOnlyAddressesFromOriginalDnsResults() { + val dns = FakeDns() + val hostname = "stale.tls-ech.dev" + val originalAddresses = dns.allocate(2) + val newAddress = dns.allocate(1).single() + factory.dns = dns + factory.uriHost = hostname + dns[hostname] = + listOf( + ResourceRecord.Https( + name = hostname, + timeToLive = 5, + echConfigList = "stale config".encodeUtf8(), + ), + *originalAddresses + .map { + ResourceRecord.IpAddress( + name = hostname, + timeToLive = 5, + address = it, + ) + }.toTypedArray(), + ) + val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> true }) + val routePlanner = factory.newRoutePlanner(client, address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2) + val attempt0 = + routePlanner + .planConnect() + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + // A new DNS result must not influence a retry of the previous ECH configuration. + dns[hostname] = listOf(newAddress) + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.socketAddress.address).isEqualTo(originalAddresses[0]) + assertThat(attempt1.route.socketAddress.address).isNotEqualTo(newAddress) + dns.assertRequests(hostname) + socket.close() + } + + @Test fun untrustedEchRetryConfigIsNotRetried() { + val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> false }) + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + // not retried because validation failed + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + + assertThat(attempt1).isNull() + socket.close() + } + @Test fun someFallbacksSupported() { val sslV3 = ConnectionSpec @@ -94,7 +207,7 @@ class RetryConnectionTest { assertThat(attempt0.isTlsFallback).isFalse() connectionSpecs[attempt0.connectionSpecIndex].apply(socket, attempt0.isTlsFallback) assertEnabledProtocols(socket, TlsVersion.TLS_1_2) - val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket) + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, retryableException) assertThat(attempt1).isNotNull() assertThat(attempt1!!.isTlsFallback).isTrue() socket.close() @@ -110,7 +223,7 @@ class RetryConnectionTest { assertEnabledProtocols(socket, TlsVersion.TLS_1_2, TlsVersion.TLS_1_1, TlsVersion.TLS_1_0) } - val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket) + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, retryableException) assertThat(attempt2).isNull() socket.close() diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt index 10ef278e2319..38c4e52b30c5 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RouteSelectorTest.kt @@ -18,6 +18,7 @@ package okhttp3.internal.connection import app.cash.burst.Burst import assertk.assertThat import assertk.assertions.containsExactly +import assertk.assertions.hasSize import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isSameInstanceAs @@ -133,6 +134,43 @@ class RouteSelectorTest( dns.assertRequests(uriHost) } + /** https://www.rfc-editor.org/rfc/rfc9848.html#section-5.1 */ + @Test fun echAddressesDoNotFallBackToNonEch() { + assumeTrue(entryPoint == EntryPoint.NewCall) + + val echAddress = dns.allocate(1).single() + val nonEchAddress = dns.allocate(1).single() + dns[uriHost] = + listOf( + ResourceRecord.Https( + name = uriHost, + timeToLive = 5, + targetName = "ech.$uriHost", + echConfigList = echConfigList, + ), + ResourceRecord.IpAddress( + name = "ech.$uriHost", + timeToLive = 5, + address = echAddress, + ), + ResourceRecord.IpAddress( + name = "non-ech.$uriHost", + timeToLive = 5, + address = nonEchAddress, + ), + ) + val address = factory.newAddress() + val routeSelector = newRouteSelector(address) + + val selection = routeSelector.next() + + assertThat(selection.routes).hasSize(1) + assertRoute(selection.next(), address, Proxy.NO_PROXY, echAddress, uriPort, echConfigList) + assertThat(selection.hasNext()).isFalse() + assertThat(routeSelector.hasNext()).isFalse() + dns.assertRequests(uriHost) + } + @Test fun singleRouteReturnsFailedRoute() { val address = factory.newAddress() var routeSelector = newRouteSelector(address) From f136497a1380373d0e53dc32bc7d6a28cf4d8ea2 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 27 Jul 2026 20:44:28 +0100 Subject: [PATCH 3/9] reformat --- .../kotlin/okhttp3/internal/connection/ConnectPlan.kt | 2 +- .../kotlin/okhttp3/internal/connection/RouteSelector.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index e815df74a70c..a408875bf43a 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -506,7 +506,7 @@ class ConnectPlan internal constructor( val offeredEchRetryConfig = Platform.get().getEchRetryConfig(sslException) if (offeredEchRetryConfig != null) { // TODO should we emit an event that we considered ech retry? - + // Only use ECH retry once if (echRetryConfig != null) return null diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt index d9828345e203..e144932f78d6 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt @@ -180,7 +180,7 @@ class RouteSelector internal constructor( } val routes = dnsLookup(proxy, socketHost, socketPort) - + // If DNS advertises ECH for any route, don't permit a retry without ECH. val echRoutes = routes.filter { it.echConfigList != null } val routesToTry = echRoutes.ifEmpty { routes } From 0a553c2cf1243d91a6790c7c54a932600108e39f Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Mon, 27 Jul 2026 21:03:58 +0100 Subject: [PATCH 4/9] fix test --- .../okhttp3/InterceptorOverridesTest.kt | 19 +++++-------------- 1 file changed, 5 insertions(+), 14 deletions(-) diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt index b664b7f3547c..9b4b4399bb4c 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt @@ -39,7 +39,6 @@ import java.util.Locale.getDefault import java.util.concurrent.TimeUnit import javax.net.SocketFactory import javax.net.ssl.HostnameVerifier -import javax.net.ssl.SSLException import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager @@ -242,23 +241,15 @@ class InterceptorOverridesTest { OverrideParam.RetryOnConnectionFailure -> { enableTls() - var first = true + + server.enqueue(MockResponse.Builder().failHandshake().build()) client = client .newBuilder() .connectionSpecs(listOf(ConnectionSpec.RESTRICTED_TLS, ConnectionSpec.MODERN_TLS)) - .eventListener( - object : EventListener() { - override fun secureConnectEnd( - call: Call, - handshake: Handshake?, - ) { - if (first) { - first = false - throw SSLException("") - } - } - }, + .sslSocketFactory( + FallbackTestClientSocketFactory(handshakeCertificates.sslSocketFactory()), + handshakeCertificates.trustManager, ).build() overrideBadImplementation( From e72b03555e8037c25ec3357fcd4b656396eb26b8 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 31 Jul 2026 10:29:39 +0100 Subject: [PATCH 5/9] Update tests and retry logic --- .../internal/platform/Android10Platform.kt | 7 +- .../internal/connection/ConnectPlan.kt | 24 ++-- .../okhttp3/internal/dns/EchRetryConfig.kt | 20 ++- .../connection/RetryConnectionTest.kt | 116 ++++++++++++++++-- 4 files changed, 140 insertions(+), 27 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index 0d212706918b..bb34cced1823 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -94,13 +94,16 @@ class Android10Platform : internal override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? { if (Build.VERSION.SDK_INT < 37 || exception !is EchConfigMismatchException) return null + // From https://cs.android.com/android/platform/superproject/+/android-latest-release:external/conscrypt/platform/src/main/java/org/conscrypt/Platform.java;bpv=0 + // we can get neither, publicHostname only, or both return EchRetryConfig( publicHostname = exception.publicHostname ?: return null, + // An absent retry config list is how a server securely disables ECH. + // TODO can Conscrypt hand us an empty list, and does it mean the same thing? configList = exception.retryConfigList ?.toBytes() - ?.toByteString() - ?: return null, + ?.toByteString(), ) } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index a408875bf43a..5c92f2bd7503 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -507,20 +507,25 @@ class ConnectPlan internal constructor( if (offeredEchRetryConfig != null) { // TODO should we emit an event that we considered ech retry? - // Only use ECH retry once - if (echRetryConfig != null) return null + // https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 + val retryable = + when (offeredEchRetryConfig.configList) { + // The server securely disabled ECH. Retry unless we already disabled ECH. + null -> echRetryConfig == null || echRetryConfig.configList != null + + // A retry config in response to a retry config signals a misconfigured server. + else -> echRetryConfig == null + } + if (!retryable) return null // Validate the publicHostname against the session certificate - if ( - !route.address.hostnameVerifier!!.verify( - offeredEchRetryConfig.publicHostname, - sslSocket.session, - ) - ) { + // The session is protected by the outer client hello (e.g. cloudflare-ech.com) + // not the origin server + val hostnameVerifier = route.address.hostnameVerifier!! + if (!hostnameVerifier.verify(offeredEchRetryConfig.publicHostname, sslSocket.session)) { return null } - // retry with an updated ECH config return copy( route = Route( @@ -529,6 +534,7 @@ class ConnectPlan internal constructor( socketAddress = route.socketAddress, echConfigList = offeredEchRetryConfig.configList, ), + // echRetryConfig.configList is possibly null to retry with ECH disabled echRetryConfig = offeredEchRetryConfig, ) } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt index d7fb7ae4e70b..fff5ff1aa437 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt @@ -19,11 +19,23 @@ import okio.ByteString /** * ECH Retry config. Generally sent by a server when there is a - * mismatch between A/AAAA and HTTPS Records. Must only be used - * when publicHostname can be validated against the certificate - * from the SSLSession. + * mismatch between A/AAAA and HTTPS Records. + * + * If a new [configList] is present, the server securely replaced our ECH configuration, and it + * must only be used when [publicHostname] can be validated against the certificate + * from the SSLSession (the outer client hello). + * + * A null [configList] means the server offered no usable retry configuration, which securely + * disables ECH. Retry without ECH. + * + * The SSL Session is valid using the outer client hello, so it's safe. + * Conscrypt guarantees this is safe if we verify the publicHostname on the session. + * + * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 */ internal data class EchRetryConfig( - val configList: ByteString, + /** The client-facing server's name from `ECHConfig.contents.public_name`. */ val publicHostname: String, + /** updated ECH configList or null to retry without ECH */ + val configList: ByteString?, ) diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt index aed129234f8a..7755bb49ae4c 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt @@ -17,6 +17,7 @@ package okhttp3.internal.connection import assertk.assertThat import assertk.assertions.containsExactlyInAnyOrder +import assertk.assertions.isEmpty import assertk.assertions.isEqualTo import assertk.assertions.isFalse import assertk.assertions.isNotEqualTo @@ -31,6 +32,7 @@ import javax.net.ssl.SSLSocket import okhttp3.ConnectionSpec import okhttp3.FakeDns import okhttp3.OkHttpClientTestRule +import okhttp3.Route import okhttp3.TestValueFactory import okhttp3.TlsVersion import okhttp3.internal.dns.EchRetryConfig @@ -38,6 +40,7 @@ import okhttp3.internal.dns.ResourceRecord import okhttp3.internal.platform.Platform import okhttp3.testing.PlatformRule import okhttp3.tls.internal.TlsUtil.localhost +import okio.ByteString import okio.ByteString.Companion.encodeUtf8 import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Test @@ -47,12 +50,18 @@ class RetryConnectionTest { private val factory = TestValueFactory() private val handshakeCertificates = localhost() private val retryableException = SSLHandshakeException("Simulated handshake exception") - private val echRetryException = SSLHandshakeException("Simulated ECH rejection") + private val echRetryException = SSLHandshakeException("ECH Mismatch with updated config") + private val echDisabledException = SSLHandshakeException("ECH Mismatch without config") private val echRetryConfig = EchRetryConfig( configList = "retry config".encodeUtf8(), publicHostname = "public.tls-ech.dev", ) + private val echDisabledConfig = + EchRetryConfig( + configList = null, + publicHostname = "public.tls-ech.dev", + ) @RegisterExtension val clientTestRule = OkHttpClientTestRule() @@ -63,7 +72,11 @@ class RetryConnectionTest { platform = object : Platform() { override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = - if (exception === echRetryException) echRetryConfig else null + when { + exception === echRetryException -> echRetryConfig + exception === echDisabledException -> echDisabledConfig + else -> null + } }, ) @@ -91,14 +104,7 @@ class RetryConnectionTest { } @Test fun echRetryConfigIsUsedOnceWithoutTlsFallback() { - val verifiedHostnames = mutableListOf() - val address = - factory.newHttpsAddress( - hostnameVerifier = { hostname, _ -> - verifiedHostnames += hostname - true - }, - ) + val address = newEchAddress() val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) @@ -115,8 +121,11 @@ class RetryConnectionTest { assertThat(attempt1.isTlsFallback).isFalse() assertThat(verifiedHostnames).isEqualTo(listOf(echRetryConfig.publicHostname)) + verifiedHostnames.clear() val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, retryableException) assertThat(attempt2).isNull() + // An ordinary handshake failure doesn't verify a public hostname. + assertThat(verifiedHostnames).isEmpty() socket.close() } @@ -144,7 +153,7 @@ class RetryConnectionTest { ) }.toTypedArray(), ) - val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> true }) + val address = newEchAddress() val routePlanner = factory.newRoutePlanner(client, address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS) val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2) @@ -160,12 +169,13 @@ class RetryConnectionTest { assertThat(attempt1).isNotNull() assertThat(attempt1!!.route.socketAddress.address).isEqualTo(originalAddresses[0]) assertThat(attempt1.route.socketAddress.address).isNotEqualTo(newAddress) + assertThat(verifiedHostnames).isEqualTo(listOf(echRetryConfig.publicHostname)) dns.assertRequests(hostname) socket.close() } @Test fun untrustedEchRetryConfigIsNotRetried() { - val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> false }) + val address = newEchAddress(verified = false) val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) @@ -179,6 +189,69 @@ class RetryConnectionTest { val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) assertThat(attempt1).isNull() + assertThat(verifiedHostnames).isEqualTo(listOf(echRetryConfig.publicHostname)) + socket.close() + } + + /** + * A server that offers no retry config has securely disabled ECH, so we retry without it. + * + * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 + */ + @Test fun missingEchRetryConfigIsRetriedWithout() { + val address = newEchAddress() + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address).withEchConfigList("stale config".encodeUtf8()) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echDisabledException) + + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.echConfigList).isNull() + assertThat(attempt1.isTlsFallback).isFalse() + assertThat(verifiedHostnames).isEqualTo(listOf(echDisabledConfig.publicHostname)) + + // Having disabled ECH once, we don't do it again. + verifiedHostnames.clear() + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, echDisabledException) + assertThat(attempt2).isNull() + assertThat(verifiedHostnames).isEmpty() + socket.close() + } + + /** + * A retry config in response to a retry config signals a misconfigured server, but the server may + * still securely disable ECH. + * + * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 + */ + @Test fun echRetryConfigIsRetriedOnceOnly() { + val address = factory.newHttpsAddress(hostnameVerifier = { _, _ -> true }) + val routePlanner = factory.newRoutePlanner(client, address) + val route = factory.newRoute(address).withEchConfigList("stale config".encodeUtf8()) + val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) + val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val attempt0 = + routePlanner + .planConnectToRoute(route) + .planWithCurrentOrInitialConnectionSpec(connectionSpecs, socket) + + val attempt1 = attempt0.nextConnectionSpec(connectionSpecs, socket, echRetryException) + assertThat(attempt1).isNotNull() + assertThat(attempt1!!.route.echConfigList).isEqualTo(echRetryConfig.configList) + + // A second retry config is not honored. + assertThat(attempt1.nextConnectionSpec(connectionSpecs, socket, echRetryException)).isNull() + + // But securely disabling ECH is. + val attempt2 = attempt1.nextConnectionSpec(connectionSpecs, socket, echDisabledException) + assertThat(attempt2).isNotNull() + assertThat(attempt2!!.route.echConfigList).isNull() socket.close() } @@ -230,11 +303,30 @@ class RetryConnectionTest { // sslV3 is not used because SSLv3 is not enabled on the socket. } + /** Records each hostname the [newEchAddress] verifier was asked to verify. */ + private val verifiedHostnames = mutableListOf() + + private fun newEchAddress(verified: Boolean = true) = + factory.newHttpsAddress( + hostnameVerifier = { hostname, _ -> + verifiedHostnames += hostname + verified + }, + ) + private fun createSocketWithEnabledProtocols(vararg tlsVersions: TlsVersion): SSLSocket = (handshakeCertificates.sslSocketFactory().createSocket() as SSLSocket).apply { enabledProtocols = javaNames(*tlsVersions) } + private fun Route.withEchConfigList(echConfigList: ByteString): Route = + Route( + address = address, + proxy = proxy, + socketAddress = socketAddress, + echConfigList = echConfigList, + ) + private fun assertEnabledProtocols( socket: SSLSocket, vararg required: TlsVersion, From e0db22e85ac9c02a3025c70182e1aa38242be4ca Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Fri, 31 Jul 2026 11:12:08 +0100 Subject: [PATCH 6/9] Fix comment formatting in ConnectPlan.kt --- .../kotlin/okhttp3/internal/connection/ConnectPlan.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index 5c92f2bd7503..30f20ad174c1 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -534,7 +534,7 @@ class ConnectPlan internal constructor( socketAddress = route.socketAddress, echConfigList = offeredEchRetryConfig.configList, ), - // echRetryConfig.configList is possibly null to retry with ECH disabled + // echRetryConfig.configList is possibly null to retry with ECH disabled echRetryConfig = offeredEchRetryConfig, ) } From cb44c95b4515bfd87261f63452d76b4ec7266bff Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sat, 1 Aug 2026 09:30:54 +0100 Subject: [PATCH 7/9] use tls 1.3 --- .../internal/connection/RetryConnectionTest.kt | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt index 7755bb49ae4c..83e2cf6df7f7 100644 --- a/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt +++ b/okhttp/src/jvmTest/kotlin/okhttp3/internal/connection/RetryConnectionTest.kt @@ -108,7 +108,7 @@ class RetryConnectionTest { val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) - val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val socket = createEchSocket() val attempt0 = routePlanner .planConnectToRoute(route) @@ -156,7 +156,7 @@ class RetryConnectionTest { val address = newEchAddress() val routePlanner = factory.newRoutePlanner(client, address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS) - val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2) + val socket = createEchSocket() val attempt0 = routePlanner .planConnect() @@ -179,7 +179,7 @@ class RetryConnectionTest { val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) - val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val socket = createEchSocket() val attempt0 = routePlanner .planConnectToRoute(route) @@ -203,7 +203,7 @@ class RetryConnectionTest { val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address).withEchConfigList("stale config".encodeUtf8()) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) - val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val socket = createEchSocket() val attempt0 = routePlanner .planConnectToRoute(route) @@ -235,7 +235,7 @@ class RetryConnectionTest { val routePlanner = factory.newRoutePlanner(client, address) val route = factory.newRoute(address).withEchConfigList("stale config".encodeUtf8()) val connectionSpecs = listOf(ConnectionSpec.MODERN_TLS, ConnectionSpec.COMPATIBLE_TLS) - val socket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_2, TlsVersion.TLS_1_1) + val socket = createEchSocket() val attempt0 = routePlanner .planConnectToRoute(route) @@ -314,6 +314,13 @@ class RetryConnectionTest { }, ) + /** + * ECH is only carried by TLS 1.3, so every ECH attempt needs it enabled. + * + * https://www.rfc-editor.org/rfc/rfc9849.html#section-1 + */ + private fun createEchSocket(): SSLSocket = createSocketWithEnabledProtocols(TlsVersion.TLS_1_3, TlsVersion.TLS_1_2) + private fun createSocketWithEnabledProtocols(vararg tlsVersions: TlsVersion): SSLSocket = (handshakeCertificates.sslSocketFactory().createSocket() as SSLSocket).apply { enabledProtocols = javaNames(*tlsVersions) From 693f04e6b2a366119d1c550c18e34360d958ed73 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sat, 1 Aug 2026 09:31:24 +0100 Subject: [PATCH 8/9] Bessify the docs --- .../internal/platform/Android10Platform.kt | 5 +++-- .../kotlin/okhttp3/internal/dns/EchRetryConfig.kt | 15 ++++++++------- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index bb34cced1823..f7e7f7eb4e2b 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -95,11 +95,12 @@ class Android10Platform : if (Build.VERSION.SDK_INT < 37 || exception !is EchConfigMismatchException) return null // From https://cs.android.com/android/platform/superproject/+/android-latest-release:external/conscrypt/platform/src/main/java/org/conscrypt/Platform.java;bpv=0 - // we can get neither, publicHostname only, or both + // we can get neither, publicHostname only, or both. Conscrypt only hands us an EchConfigList + // if it is non-empty and self-consistent; BoringSSL does the real validation (version checks + // and such) when we hand the list back to it. return EchRetryConfig( publicHostname = exception.publicHostname ?: return null, // An absent retry config list is how a server securely disables ECH. - // TODO can Conscrypt hand us an empty list, and does it mean the same thing? configList = exception.retryConfigList ?.toBytes() diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt index fff5ff1aa437..3e5ed626ba0f 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/dns/EchRetryConfig.kt @@ -18,19 +18,20 @@ package okhttp3.internal.dns import okio.ByteString /** - * ECH Retry config. Generally sent by a server when there is a - * mismatch between A/AAAA and HTTPS Records. + * ECH retry config. Sent by a server when the ECH configuration we offered has fallen out of sync + * with the one it accepts: its TTL expired, or the server rotated to a new configuration. (For + * example, Cloudflare publishes one configuration at a time and rotates it hourly, honoring the + * previous one for a further 4 hours. A configuration cached past that grace period earns a retry + * config.) * * If a new [configList] is present, the server securely replaced our ECH configuration, and it - * must only be used when [publicHostname] can be validated against the certificate - * from the SSLSession (the outer client hello). + * must only be used when [publicHostname] can be validated against the certificate from the + * SSLSession (the outer client hello). Authenticating the public name is what makes this safe: + * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.7 * * A null [configList] means the server offered no usable retry configuration, which securely * disables ECH. Retry without ECH. * - * The SSL Session is valid using the outer client hello, so it's safe. - * Conscrypt guarantees this is safe if we verify the publicHostname on the session. - * * https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.6 */ internal data class EchRetryConfig( From 959eae02c93878413a0a9f1796871802e209db11 Mon Sep 17 00:00:00 2001 From: Yuri Schimke Date: Sat, 1 Aug 2026 09:36:10 +0100 Subject: [PATCH 9/9] Fix the test ports --- .../java/okhttp/android/test/EchTest.kt | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 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 c97de41d4eae..ae60bb358d9b 100644 --- a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -70,18 +70,23 @@ class EchTest( fun tlsEchDevUsesEch() { val body = client.get("https://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") } + /** Port 444, because port 443 is the plain tls-ech.dev server. */ @Test fun staleEchConfigIsRetried() { - val body = client.get("https://stale.tls-ech.dev/") + val body = client.get("https://stale.tls-ech.dev:444/") + assertThat(body).contains("

stale.tls-ech.dev

") assertThat(body).contains("You are using ECH") assertThat(body).doesNotContain("not using ECH") } + /** Port 445, because port 443 is the plain tls-ech.dev server. */ @Test fun differentPublicHostnameIsVerifiedBeforeRetry() { // The outer certificate authenticates public.tls-ech.dev, @@ -98,18 +103,23 @@ class EchTest( } .build() - val body = client.get("https://wrong.tls-ech.dev/") + val body = client.get("https://wrong.tls-ech.dev:445/") + assertThat(body).contains("

wrong.tls-ech.dev

") assertThat(body).contains("You are using ECH") assertThat(verifiedHostnames).contains("public.tls-ech.dev") } /** * TLS 1.2 cannot carry ECH. + * + * Port 446, because port 443 is the plain tls-ech.dev server. */ @Test fun tls12OffersNothingToRetryWith() { - assertThat(client.echRejectionFrom("https://tls12.tls-ech.dev/").hasRetryConfigList()).isFalse() + val rejection = client.echRejectionFrom("https://tls12.tls-ech.dev:446/") + + assertThat(rejection.hasRetryConfigList()).isFalse() } /**