Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,4 @@ import okio.ByteString
class FakeNetworkEchRejectedException(
val publicName: String,
val nextEchConfigList: ByteString?,
) : SSLException("ECH rejected")
) : SSLException("Encrypted Client Hello (ECH) rejected")
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import javax.net.ssl.TrustManager
import javax.net.ssl.X509KeyManager
import javax.net.ssl.X509TrustManager
import okhttp3.Protocol
import okhttp3.internal.dns.EchRetryConfig
import okhttp3.internal.dns.EchRetryPlan
import okhttp3.internal.platform.Platform
import okhttp3.tls.internal.TlsUtil.newKeyManager
import okio.ByteString
Expand Down Expand Up @@ -91,11 +91,11 @@ class FakeNetworkPlatform : Platform() {
return sslContext.socketFactory
}

override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? =
override fun echRetryPlan(exception: SSLException): EchRetryPlan? =
when (exception) {
is FakeNetworkEchRejectedException -> {
EchRetryConfig(
publicHostname = exception.publicName,
EchRetryPlan.getOrNull(
publicName = exception.publicName,
configList = exception.nextEchConfigList,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ 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.dns.EchRetryPlan
import okhttp3.internal.platform.AndroidPlatform.Companion.Tag
import okhttp3.internal.platform.android.Android10SocketAdapter
import okhttp3.internal.platform.android.Android17SocketAdapter
Expand Down Expand Up @@ -91,20 +91,13 @@ class Android10Platform :
}

@SuppressLint("NewApi")
override fun getEchRetryConfig(exception: SSLException): EchRetryConfig? {
override fun echRetryPlan(exception: SSLException): EchRetryPlan? {
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. 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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs now in the EchRetry class

return EchRetryConfig(
publicHostname = exception.publicHostname ?: return null,
// An absent retry config list is how a server securely disables ECH.
configList =
exception.retryConfigList
?.toBytes()
?.toByteString(),
val publicName = exception.publicHostname ?: return null
return EchRetryPlan.getOrNull(
publicName = publicName,
configList = exception.retryConfigList?.toBytes()?.toByteString(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,13 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(OkHttpInternalApi::class)

package okhttp3.internal.connection

import java.io.IOException
import java.net.ConnectException
import java.net.HttpURLConnection
import java.net.NoRouteToHostException
import java.net.ProtocolException
import java.net.Proxy
import java.net.Socket as JavaNetSocket
Expand All @@ -35,11 +36,12 @@ import okhttp3.Handshake.Companion.handshake
import okhttp3.Protocol
import okhttp3.Request
import okhttp3.Route
import okhttp3.internal.OkHttpInternalApi
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.dns.EchRetryPlan
import okhttp3.internal.http.ExchangeCodec
import okhttp3.internal.http1.Http1ExchangeCodec
import okhttp3.internal.platform.Platform
Expand Down Expand Up @@ -75,7 +77,7 @@ class ConnectPlan internal constructor(
private val tunnelRequest: Request?,
internal val connectionSpecIndex: Int,
internal val isTlsFallback: Boolean,
private val echRetryConfig: EchRetryConfig? = null,
private val echRetryPlan: EchRetryPlan? = null,
) : RoutePlanner.Plan,
ExchangeCodec.Carrier {
/** True if this connect was canceled; typically because it lost a race. */
Expand Down Expand Up @@ -106,7 +108,7 @@ class ConnectPlan internal constructor(
tunnelRequest: Request? = this.tunnelRequest,
connectionSpecIndex: Int = this.connectionSpecIndex,
isTlsFallback: Boolean = this.isTlsFallback,
echRetryConfig: EchRetryConfig? = this.echRetryConfig,
echRetryPlan: EchRetryPlan? = this.echRetryPlan,
): ConnectPlan =
ConnectPlan(
taskRunner = taskRunner,
Expand All @@ -125,7 +127,7 @@ class ConnectPlan internal constructor(
tunnelRequest = tunnelRequest,
connectionSpecIndex = connectionSpecIndex,
isTlsFallback = isTlsFallback,
echRetryConfig = echRetryConfig,
echRetryPlan = echRetryPlan,
)

override fun connectTcp(): ConnectResult {
Expand Down Expand Up @@ -503,26 +505,15 @@ class ConnectPlan internal constructor(
): ConnectPlan? {
if (!retryOnConnectionFailure) return null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For follow-up, I believe we need to reorder these so we do an ECH retry even when this is false.


val offeredEchRetryConfig = Platform.get().getEchRetryConfig(sslException)
if (offeredEchRetryConfig != null) {
// TODO should we emit an event that we considered ech retry?

// 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
// If this was an ECH retry, don't retry again.
if (echRetryPlan != null) return null

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix: previously we’d permit one retry with a EchConfigList once, and another retry with no EchConfigList. I believe we should be limited to one retry total.


// Validate the publicHostname against the session certificate
// The session is protected by the outer client hello (e.g. cloudflare-ech.com)
// not the origin server
val nextEchRetryPlan = Platform.get().echRetryPlan(sslException)
if (nextEchRetryPlan != null) {
// Validate the publicHostname against the session certificate. 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)) {
if (!hostnameVerifier.verify(nextEchRetryPlan.publicName, sslSocket.session)) {
return null
}

Expand All @@ -532,16 +523,14 @@ class ConnectPlan internal constructor(
address = route.address,
proxy = route.proxy,
socketAddress = route.socketAddress,
echConfigList = offeredEchRetryConfig.configList,
echConfigList = nextEchRetryPlan.configList,
),
// echRetryConfig.configList is possibly null to retry with ECH disabled
echRetryConfig = offeredEchRetryConfig,
echRetryPlan = nextEchRetryPlan,
)
}

// If this was already in response to an ech retry, we are done for this
// connection
if (echRetryConfig != null || !retryTlsHandshake(sslException)) return null
// If the exception is not recoverable, don't retry.
if (!retryTlsHandshake(sslException)) return null

return nextCompatibleConnectionSpec(connectionSpecs, sslSocket)
}
Expand Down Expand Up @@ -619,7 +608,7 @@ class ConnectPlan internal constructor(
tunnelRequest = tunnelRequest,
connectionSpecIndex = connectionSpecIndex,
isTlsFallback = isTlsFallback,
echRetryConfig = echRetryConfig,
echRetryPlan = echRetryPlan,
)

fun closeQuietly() {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/*
* 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 okhttp3.internal.OkHttpInternalApi
import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.toCanonicalHost
import okio.ByteString

/**
* A plan to retry after a failed Encrypted Client Hello TLS handshake.
*
* The [publicName] comes from the failed attempt's ECH config list. This is available locally by
* unpacking [okhttp3.Dns.Record.ServiceMetadata.echConfigList].
*
* The [configList] comes from the server that couldn't successfully handshake with ECH. It will be
* null if the server has directed us to securely disable ECH.
*
* See RFC 9849, section 6.1.6.
*/
@OkHttpInternalApi
class EchRetryPlan private constructor(

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed from EchRetryConfig

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's grown on me already

/** The client-facing server's name from `ECHConfig.contents.public_name`. */
val publicName: String,
/** The ECH config list to retry with, or null to retry without ECH. */
val configList: ByteString?,
) {
companion object {
private val INVALID_PUBLIC_NAME =
"(\\..*)|(.*\\.)|((.*\\.)?[0-9]+)|((.*\\.)?0[xX][0-9a-fA-F]*)".toRegex()

/** Returns a new config if the inputs are valid, and null otherwise. */
fun getOrNull(
publicName: String,
configList: ByteString?,
): EchRetryPlan? {
val canonicalHost = publicName.toCanonicalHost() ?: return null
if (canonicalHost.canParseAsIpAddress()) return null
if (INVALID_PUBLIC_NAME.matches(canonicalHost)) return null
return EchRetryPlan(
publicName,
configList,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import okhttp3.Dns
import okhttp3.OkHttpClient
import okhttp3.Protocol
import okhttp3.internal.OkHttpInternalApi
import okhttp3.internal.dns.EchRetryConfig
import okhttp3.internal.dns.EchRetryPlan
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import okhttp3.internal.readFieldOrNull
import okhttp3.internal.tls.BasicCertificateChainCleaner
Expand Down Expand Up @@ -135,8 +135,8 @@ open class Platform {
) {
}

/** Returns the ECH retry configuration carried by [exception]. */
open fun getEchRetryConfig(exception: SSLException): EchRetryConfig? = null
/** Returns a plan to recover when a handshake that failed due to Encrypted Client Hello. */
open fun echRetryPlan(exception: SSLException): EchRetryPlan? = null

/** Called after the TLS handshake to release resources allocated by [configureTlsExtensions]. */
open fun afterHandshake(sslSocket: SSLSocket) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* 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.
*/
@file:OptIn(OkHttpInternalApi::class)

package okhttp3.internal.dns

import assertk.assertThat
import assertk.assertions.isNotNull
import assertk.assertions.isNull
import kotlin.test.Test
import okhttp3.internal.OkHttpInternalApi

/**
* Confirm we correctly validate the requirements of RFC 9849 public names. We're stricter with
* these than on regular DNS names, because we expect the DNS servers will reject those for us.
*
* https://www.rfc-editor.org/rfc/rfc9849.html#section-6.1.7
*/
class EchRetryPlanTest {
@Test
fun `valid public name`() {
assertValid("ech.example.com")
assertValid("ECH.EXAMPLE.COM")
assertValid("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijk.example.com")
assertValid("0xG")
assertValid("0xG.example.com")
assertValid("0XG")
assertValid("0XG.example.com")
}

@Test
fun `invalid public name`() {
assertInvalid("1:2::3:4")
assertInvalid("10.20.30.40")
assertInvalid("ech.example.com.")
assertInvalid(".ech.example.com")

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Our existing hostname checks permit this

assertInvalid("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijkl.example.com")
assertInvalid("ech.example.123")
assertInvalid("ech.example.0x123")
assertInvalid("123")
assertInvalid("0xab")
assertInvalid("0XAB")
assertInvalid("0X")
}

private fun assertValid(publicName: String) {
assertThat(
EchRetryPlan.getOrNull(
publicName = publicName,
configList = null,
),
).isNotNull()
}

private fun assertInvalid(publicName: String) {
assertThat(
EchRetryPlan.getOrNull(
publicName = publicName,
configList = null,
),
).isNull()
}
}
Loading
Loading