Skip to content
Open
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
36 changes: 22 additions & 14 deletions android-test/src/androidTest/java/okhttp/android/test/EchTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -77,21 +75,33 @@ 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

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.

Think I got think backwards, re-reading.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Yes, you should only be verifying the public hostname against the certificate, not against the request. Conscrypt isn't able to do the hostname validation itself, which is why it's necessary to do it here in OkHttp.

FYI @tweksteen

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.

Just to confirm, at this point our SSLSession is dead, but we can safely read the certificates that were presented.

But are the certificates on the SSLSession trusted? Does Conscrypt already clean the certs?

val verifiedHostnames = mutableListOf<String>()
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")
}

/**
Expand All @@ -104,8 +114,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 =
Expand Down
2 changes: 1 addition & 1 deletion okhttp-testing-support/src/main/kotlin/okhttp3/FakeDns.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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. */
Expand All @@ -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,
Expand All @@ -120,6 +125,7 @@ class ConnectPlan internal constructor(
tunnelRequest = tunnelRequest,
connectionSpecIndex = connectionSpecIndex,
isTlsFallback = isTlsFallback,
echRetryConfig = echRetryConfig,
)

override fun connectTcp(): ConnectResult {
Expand Down Expand Up @@ -200,11 +206,13 @@ 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)
connectTls(sslSocket, connectionSpec)
try {
connectTls(sslSocket, connectionSpec)
} catch (e: SSLException) {
retryTlsConnection = tlsEquipPlan.nextConnectionSpec(connectionSpecs, sslSocket, e)
throw e
}
call.eventListener.secureConnectEnd(call, handshake)
} else {
javaNetSocket = rawSocket
Expand Down Expand Up @@ -239,10 +247,6 @@ 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
}

return ConnectResult(
plan = this,
nextPlan = retryTlsConnection,
Expand Down Expand Up @@ -479,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," +
Expand All @@ -489,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<ConnectionSpec>,
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<ConnectionSpec>,
sslSocket: SSLSocket,
): ConnectPlan? {
for (i in connectionSpecIndex + 1 until connectionSpecs.size) {
if (connectionSpecs[i].isCompatible(sslSocket)) {
Expand Down Expand Up @@ -561,6 +613,7 @@ class ConnectPlan internal constructor(
tunnelRequest = tunnelRequest,
connectionSpecIndex = connectionSpecIndex,
isTlsFallback = isTlsFallback,
echRetryConfig = echRetryConfig,
)

fun closeQuietly() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,10 +181,14 @@ 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
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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) {
}
Expand Down
19 changes: 5 additions & 14 deletions okhttp/src/jvmTest/kotlin/okhttp3/InterceptorOverridesTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(

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.

this wasn't valid, and after the refactor, isn't effective.

object : EventListener() {
override fun secureConnectEnd(
call: Call,
handshake: Handshake?,
) {
if (first) {
first = false
throw SSLException("")
}
}
},
.sslSocketFactory(
FallbackTestClientSocketFactory(handshakeCertificates.sslSocketFactory()),
handshakeCertificates.trustManager,
).build()

overrideBadImplementation(
Expand Down
Loading
Loading