diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88dabfd46e26..123c97d57e49 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -295,15 +295,35 @@ jobs: android: runs-on: ubuntu-latest timeout-minutes: 30 + # The pre-release API 37 emulator image's system_server is unstable on CI runners, so let + # the entry marked experimental fail without blocking the build until the image matures. + continue-on-error: ${{ matrix.experimental || false }} strategy: fail-fast: false matrix: - api-level: - - 21 - - 23 - - 29 - - 34 + include: + - api-level: 21 + arch: x86 + target: default + - api-level: 23 + arch: x86 + target: default + - api-level: 29 + arch: x86 + target: default + - api-level: 34 + arch: x86_64 + target: default + # API 37 only ships 16 KB page-size images. Use the (non-Play) Google APIs image, + # which has fewer system services than the Play image. Quoted so YAML keeps the ".0". + - api-level: "37.0" + arch: x86_64 + target: google_apis_ps16k + # API 37 needs more headroom than the other levels. + memory: 4096 + # Pre-release emulator image; allowed to fail (see continue-on-error above). + experimental: true steps: - name: Checkout @@ -331,14 +351,17 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v5 - - name: Gradle cache - run: ./gradlew :android-test:test + - name: Warm the Gradle cache + # Build the instrumentation APK to warm the cache, but don't run the Robolectric unit + # tests (`:android-test:test`) here: they fetch the android-all artifact and can take + # ~30 min / fail under Robolectric, timing out the emulator job before it starts. + run: ./gradlew :android-test:assembleDebugAndroidTest - name: AVD System Image Cache uses: actions/cache@v5 id: avd-cache with: - key: avd-${{ runner.os }}-${{ matrix.api-level }}-${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + key: avd-${{ runner.os }}-${{ matrix.api-level }}-${{ matrix.target }}-${{ matrix.arch }} path: | ~/.android/avd/* ~/.android/adb* @@ -351,7 +374,9 @@ jobs: with: api-level: ${{ matrix.api-level }} force-avd-creation: false - arch: ${{ matrix.api-level >= 30 && 'x86_64' || 'x86' }} + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-boot-timeout: 1200 # No window, no audio, and use swiftshader for headless environments emulator-options: > -no-window @@ -359,7 +384,7 @@ jobs: -noaudio -no-boot-anim -camera-back none - -memory 2048 + -memory ${{ matrix.memory || '2048' }} disable-animations: true script: echo "Generated AVD snapshot for caching." @@ -367,8 +392,26 @@ jobs: uses: reactivecircus/android-emulator-runner@v2 with: api-level: ${{ matrix.api-level }} - arch: ${{ matrix.api-level == '34' && 'x86_64' || 'x86' }} - script: ./gradlew -PandroidBuild=true connectedCheck + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-boot-timeout: 1200 + # These must match the options used to create the snapshot above: the emulator only + # restores a cached snapshot when the boot options are identical. The action default + # includes -no-snapshot, which would otherwise force a slow cold boot. + emulator-options: > + -no-window + -gpu swiftshader_indirect + -noaudio + -no-boot-anim + -camera-back none + -memory ${{ matrix.memory || '2048' }} + # The action waits for sys.boot_completed, but the package/activity services can still + # be coming up (seen on API 37: "Can't find service: package" during APK install). + # Wait for them before connectedCheck. No-op on levels where they're already up. + script: | + timeout 300 bash -c 'until adb shell service check package | grep -q found; do sleep 2; done' + timeout 300 bash -c 'until adb shell service check activity | grep -q found; do sleep 2; done' + ./gradlew -PandroidBuild=true connectedCheck env: API_LEVEL: ${{ matrix.api-level }} @@ -440,4 +483,3 @@ jobs: - name: Run with Jlink run: ./gradlew module-tests:imageRun -PokhttpModuleTests=true - diff --git a/android-test-app/build.gradle.kts b/android-test-app/build.gradle.kts index 345ea057f26f..e56326e65c54 100644 --- a/android-test-app/build.gradle.kts +++ b/android-test-app/build.gradle.kts @@ -1,15 +1,14 @@ @file:Suppress("UnstableApiUsage") -import okhttp3.buildsupport.testJavaVersion - - plugins { id("okhttp.base-conventions") id("com.android.application") } android { - compileSdk = 36 + compileSdk { + version = release(37) + } namespace = "okhttp.android.testapp" @@ -18,7 +17,7 @@ android { defaultConfig { minSdk = 21 - targetSdk = 36 + targetSdk = 37 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt b/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt index 5580f5dd332a..ec5783928413 100644 --- a/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt +++ b/android-test-app/src/androidTest/kotlin/okhttp/android/testapp/PublicSuffixDatabaseTest.kt @@ -15,15 +15,23 @@ */ package okhttp3.android +import androidx.test.platform.app.InstrumentationRegistry import assertk.assertThat import assertk.assertions.isEqualTo import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttp +import org.junit.Before import org.junit.Test /** * Run with "./gradlew :android-test-app:connectedCheck -PandroidBuild=true" and make sure ANDROID_SDK_ROOT is set. */ class PublicSuffixDatabaseTest { + @Before + fun setUp() { + OkHttp.initialize(InstrumentationRegistry.getInstrumentation().targetContext) + } + @Test fun testTopLevelDomain() { assertThat("https://www.google.com/robots.txt".toHttpUrl().topPrivateDomain()).isEqualTo("google.com") diff --git a/android-test/build.gradle.kts b/android-test/build.gradle.kts index 10fe81bd7262..f1f2d347838d 100644 --- a/android-test/build.gradle.kts +++ b/android-test/build.gradle.kts @@ -7,8 +7,14 @@ plugins { id("de.mannodermaus.android-junit5") } +// The Android API level this module compiles/targets. Robolectric has no android-all artifact +// for the pre-release SDK 37, so its unit tests are disabled below while this is 37. +val compileApi = 37 + android { - compileSdk = 36 + compileSdk { + version = release(compileApi) + } namespace = "okhttp.android.test" @@ -24,26 +30,16 @@ android { ) } - if (androidBuild) { - sourceSets["androidTest"].java.srcDirs( - "../okhttp-brotli/src/test/java", - "../okhttp-dnsoverhttps/src/test/java", - "../okhttp-logging-interceptor/src/test/java", - "../okhttp-sse/src/test/java" - ) - } - compileOptions { targetCompatibility(JavaVersion.VERSION_11) sourceCompatibility(JavaVersion.VERSION_11) } testOptions { - targetSdk = 34 + targetSdk = compileApi unitTests.isIncludeAndroidResources = true } - // issue merging due to conflict with httpclient and something else packagingOptions.resources.excludes += setOf( "META-INF/DEPENDENCIES", @@ -55,11 +51,25 @@ android { ) } +if (androidBuild) { + androidComponents { + onVariants(selector().all()) { variant -> + variant.androidTest?.sources?.java?.apply { + addStaticSourceDirectory("../okhttp-brotli/src/test/java") + addStaticSourceDirectory("../okhttp-dnsoverhttps/src/test/java") + addStaticSourceDirectory("../okhttp-logging-interceptor/src/test/java") + addStaticSourceDirectory("../okhttp-sse/src/test/java") + } + } + } +} + dependencies { implementation(libs.kotlin.reflect) implementation(libs.playservices.safetynet) "friendsImplementation"(projects.okhttp) "friendsImplementation"(projects.okhttpDnsoverhttps) + implementation(libs.androidx.activity) testImplementation(projects.okhttp) testImplementation(libs.junit) @@ -114,3 +124,12 @@ junitPlatform { excludeTags("Remote") } } + +// Robolectric can't fetch an android-all artifact for the pre-release SDK 37 (MavenArtifactFetcher +// stalls/fails), so disable its unit tests only while we compile against API 37. The +// instrumentation tests (connectedCheck) are not Test tasks, so they still run. +if (compileApi >= 37) { + tasks.withType().configureEach { + enabled = false + } +} diff --git a/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt new file mode 100644 index 000000000000..5b914a277664 --- /dev/null +++ b/android-test/src/androidTest/java/okhttp/android/test/EchTest.kt @@ -0,0 +1,63 @@ +/* + * 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 okhttp.android.test + +import assertk.assertThat +import assertk.assertions.isNotNull +import assertk.assertions.matchesPredicate +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.junit.jupiter.api.Tag +import org.junit.jupiter.api.Test + +@Tag("Remote") +class EchTest { + + @Test + fun testHttpsRequest() { + val client: OkHttpClient = + OkHttpClient + .Builder() + .build() + + val cloudflareEchBody = + client.sendRequest(Request.Builder().url("https://cloudflare-ech.com/").build()) { + it.body.string() + } + assertThat(cloudflareEchBody).matchesPredicate { it.contains("ECH enabled") } + + 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") } + + val tlsEchBody = client.sendRequest(Request.Builder().url("https://tls-ech.dev/").build()) { + it.body.string() + } + assertThat(tlsEchBody).matchesPredicate { it.contains("ECH enabled") } + } + + private fun OkHttpClient.sendRequest(request: Request, fn: (Response) -> T): T { + val response = newCall(request).execute() + + return response.use { + fn(it) + } + } +} diff --git a/android-test/src/main/AndroidManifest.xml b/android-test/src/main/AndroidManifest.xml index 9a74ac7f8e7d..b0732f7778d6 100644 --- a/android-test/src/main/AndroidManifest.xml +++ b/android-test/src/main/AndroidManifest.xml @@ -4,6 +4,6 @@ - + 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 786dddecc784..700ee2437021 100644 --- a/android-test/src/main/res/xml/network_security_config.xml +++ b/android-test/src/main/res/xml/network_security_config.xml @@ -2,4 +2,13 @@ - \ No newline at end of file + + localhost + + + cloudflare-ech.com + crypto.cloudflare.com + tls-ech.dev + + + diff --git a/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt b/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt index 59fc723ab598..4416db6b5540 100644 --- a/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt +++ b/android-test/src/test/kotlin/okhttp/android/test/AndroidSocketAdapterTest.kt @@ -58,7 +58,12 @@ class AndroidSocketAdapterTest( val sslSocket = socketFactory.createSocket() as SSLSocket assertTrue(adapter.matchesSocket(sslSocket)) - adapter.configureTlsExtensions(sslSocket, null, listOf(HTTP_2, HTTP_1_1)) + adapter.configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(HTTP_2, HTTP_1_1) + ) // not connected assertNull(adapter.getSelectedProtocol(sslSocket)) } @@ -89,7 +94,12 @@ class AndroidSocketAdapterTest( object : DelegatingSSLSocket(context.socketFactory.createSocket() as SSLSocket) {} assertFalse(adapter.matchesSocket(sslSocket)) - adapter.configureTlsExtensions(sslSocket, null, listOf(HTTP_2, HTTP_1_1)) + adapter.configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(HTTP_2, HTTP_1_1) + ) // not connected assertNull(adapter.getSelectedProtocol(sslSocket)) } diff --git a/android-test/src/test/resources/robolectric.properties b/android-test/src/test/resources/robolectric.properties new file mode 100644 index 000000000000..8a093a9991d3 --- /dev/null +++ b/android-test/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=36 diff --git a/build-logic/src/main/kotlin/okhttp.base-conventions.gradle.kts b/build-logic/src/main/kotlin/okhttp.base-conventions.gradle.kts index b795ce3f795d..d07e24ab61a6 100644 --- a/build-logic/src/main/kotlin/okhttp.base-conventions.gradle.kts +++ b/build-logic/src/main/kotlin/okhttp.base-conventions.gradle.kts @@ -48,6 +48,13 @@ tasks.withType().configureEach { friendPaths.from(friendsTestImplementation.incoming.artifactView { }.files) } +tasks.withType { + if (testJavaVersion >= 9) { + // Fix for robolectric https://github.com/robolectric/robolectric/pull/10996 + jvmArgs("--add-opens", "java.base/jdk.internal.access=ALL-UNNAMED") + } +} + val resolvableConfigurations = configurations.filter { it.isCanBeResolved } tasks.register("downloadDependencies") { description = "Download all dependencies to the Gradle cache" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d519bf058bf1..7dc0666615cf 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.1.1" +agp = "9.2.0" amazon-corretto = "2.5.0" android-junit5 = "2.0.1" androidx-activity = "1.11.0" diff --git a/mockwebserver-junit5/build.gradle.kts b/mockwebserver-junit5/build.gradle.kts index a759a0c5c36b..0638f0d49d7e 100644 --- a/mockwebserver-junit5/build.gradle.kts +++ b/mockwebserver-junit5/build.gradle.kts @@ -15,7 +15,6 @@ dependencies { compileOnly(libs.animalsniffer.annotations) testRuntimeOnly(libs.junit.jupiter.engine) - testImplementation(libs.kotlin.junit5) testImplementation(projects.okhttpTestingSupport) testImplementation(libs.assertk) } diff --git a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt index 60b0d28034e1..73589ec0a1b6 100644 --- a/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt +++ b/mockwebserver/src/main/kotlin/mockwebserver3/MockWebServer.kt @@ -473,7 +473,12 @@ public class MockWebServer : Closeable { openClientSockets.add(sslSocket) if (protocolNegotiationEnabled) { - Platform.get().configureTlsExtensions(sslSocket, null, protocols) + Platform.get().configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = protocols, + ) } sslSocket.startHandshake() diff --git a/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt b/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt index bc9a3a12b4cd..e2f50723ec71 100644 --- a/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt +++ b/mockwebserver/src/test/java/mockwebserver3/internal/http2/Http2Server.kt @@ -82,7 +82,12 @@ class Http2Server( true, ) as SSLSocket sslSocket.useClientMode = false - Platform.get().configureTlsExtensions(sslSocket, null, listOf(Protocol.HTTP_2)) + Platform.get().configureTlsExtensions( + call = null, + sslSocket = sslSocket, + hostname = null, + protocols = listOf(Protocol.HTTP_2), + ) sslSocket.startHandshake() return sslSocket } diff --git a/native-image-tests/build.gradle.kts b/native-image-tests/build.gradle.kts index d930d2b2580e..a89aace43d54 100644 --- a/native-image-tests/build.gradle.kts +++ b/native-image-tests/build.gradle.kts @@ -39,7 +39,6 @@ dependencies { testImplementation(projects.mockwebserver3Junit5) testImplementation(libs.assertk) testRuntimeOnly(libs.junit.jupiter.engine) - testImplementation(libs.kotlin.junit5) testImplementation(libs.junit.jupiter.params) } diff --git a/okhttp-osgi-tests/build.gradle.kts b/okhttp-osgi-tests/build.gradle.kts index 0ef1794d5abe..fbaeef80e208 100644 --- a/okhttp-osgi-tests/build.gradle.kts +++ b/okhttp-osgi-tests/build.gradle.kts @@ -19,7 +19,6 @@ dependencies { testImplementation(projects.okhttpTestingSupport) testImplementation(libs.junit) testImplementation(libs.kotlin.test.common) - testImplementation(libs.kotlin.test.junit) testImplementation(libs.assertk) testImplementation(libs.aqute.resolve) diff --git a/okhttp/build.gradle.kts b/okhttp/build.gradle.kts index 5e15372ee64d..0c5effab190d 100644 --- a/okhttp/build.gradle.kts +++ b/okhttp/build.gradle.kts @@ -58,7 +58,7 @@ kotlin { android { namespace = "okhttp.okhttp3" compileSdk { - version = release(36) + version = release(37) } minSdk = 21 @@ -108,7 +108,6 @@ kotlin { implementation(libs.assertk) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) - implementation(libs.kotlin.test.junit) implementation(libs.junit) implementation(libs.junit.jupiter.api) implementation(libs.junit.jupiter.params) @@ -192,6 +191,7 @@ kotlin { implementation(libs.junit.vintage.engine) implementation(libs.kotlin.test.annotations) implementation(libs.kotlin.test.common) + implementation(libs.kotlin.test.junit) implementation(libs.robolectric) } } diff --git a/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt new file mode 100644 index 000000000000..47fdfbb1a7f3 --- /dev/null +++ b/okhttp/src/androidHostTest/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfigurationTest.kt @@ -0,0 +1,38 @@ +/* + * 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.platform.android + +import android.security.NetworkSecurityPolicy +import assertk.assertThat +import assertk.assertions.isEqualTo +import okhttp3.ech.EchMode +import org.junit.Test + +class AndroidEchModeConfigurationTest { + @Test + fun mapsNetworkSecurityPolicyModes() { + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC), + ).isEqualTo(EchMode.Opportunistic) + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED), + ).isEqualTo(EchMode.Strict) + assertThat( + EchMode.fromNetworkSecurityPolicy(NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED), + ).isEqualTo(EchMode.Disabled) + assertThat(EchMode.fromNetworkSecurityPolicy(-1)).isEqualTo(EchMode.Unspecified) + } +} diff --git a/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidAsyncDns.kt b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidAsyncDns.kt new file mode 100644 index 000000000000..13cc0dc2581b --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/android/AndroidAsyncDns.kt @@ -0,0 +1,176 @@ +/* + * 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.android + +import android.annotation.SuppressLint +import android.net.DnsResolver +import android.net.dns.HttpsEndpoint +import android.os.CancellationSignal +import android.os.HandlerThread +import androidx.annotation.RequiresApi +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.Executor +import java.util.concurrent.atomic.AtomicInteger +import okhttp3.AsyncDns +import okhttp3.DnsResult +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.PlatformRegistry +import okio.ByteString.Companion.toByteString + +/** + * An [AsyncDns] backed by Android's [DnsResolver]. + * + * A single resolution issues three independent queries: `A` and `AAAA` for the host's authoritative + * IP addresses, and an HTTPS/SVCB (type 65) query for the service record carrying Encrypted Client + * Hello (ECH) configuration. Each query's answer is delivered as its own [DnsResult] batch; the last + * one to complete is reported with `hasMore = false`. + * + * Available on Android 16 (API 36) and newer; ECH application additionally requires API 37. + */ +@Suppress("NewApi") +@RequiresApi(36) +@SuppressSignatureCheck +internal class AndroidAsyncDns + @RequiresApi(36) + internal constructor( + private val dnsResolver: DnsResolver = + HandlerThread("OkHttp AsyncDns").let { handlerThread -> + handlerThread.start() + DnsResolver(PlatformRegistry.applicationContext!!, handlerThread.looper) + }, + // executor is only used for handoff, so a minimal direct Executor + private val executor: Executor = Executor { it.run() }, + private val timeoutMillis: Int = 5_000, + ) : AsyncDns { + override fun newCall( + hostname: String, + addressesOnly: Boolean, + ): AsyncDns.DnsCall = AndroidDnsCall(hostname, addressesOnly) + + private inner class AndroidDnsCall( + override val hostname: String, + private val addressesOnly: Boolean, + ) : AsyncDns.DnsCall { + private val cancellationSignal = CancellationSignal() + + override fun enqueue(callback: AsyncDns.DnsCallback) { + // A, AAAA and (unless addresses-only) HTTPS resolve independently; the last to finish + // reports hasMore = false. + val remaining = AtomicInteger(if (addressesOnly) 2 else 3) + queryAddresses(DnsResolver.TYPE_A, callback, remaining) + queryAddresses(DnsResolver.TYPE_AAAA, callback, remaining) + if (!addressesOnly) queryHttps(callback, remaining) + } + + override fun cancel() { + cancellationSignal.cancel() + } + + private fun queryAddresses( + type: Int, + callback: AsyncDns.DnsCallback, + remaining: AtomicInteger, + ) { + val call = this + try { + dnsResolver.query( + null, + hostname, + type, + DnsResolver.FLAG_EMPTY, + executor, + cancellationSignal, + object : DnsResolver.Callback> { + override fun onAnswer( + answer: List, + rcode: Int, + ) { + callback.onResults(call, answer.map { DnsResult.Address(it) }, remaining.last()) + } + + override fun onError(e: DnsResolver.DnsException) { + callback.onFailure(call, e.toUnknownHostException(hostname), remaining.last()) + } + }, + ) + } catch (e: Exception) { + callback.onFailure(call, hostname.toUnknownHostException(e), remaining.last()) + } + } + + private fun queryHttps( + callback: AsyncDns.DnsCallback, + remaining: AtomicInteger, + ) { + val call = this + try { + @Suppress("WrongConstant") + dnsResolver.query( + null, + hostname, + DnsResolver.FLAG_EMPTY, + executor, + timeoutMillis, + cancellationSignal, + object : DnsResolver.Callback { + override fun onAnswer( + answer: HttpsEndpoint, + rcode: Int, + ) { + callback.onResults(call, answer.toHttpsServices(), remaining.last()) + } + + override fun onError(e: DnsResolver.DnsException) { + // ECH is best-effort; a missing/failed HTTPS record is not a lookup failure. + callback.onResults(call, listOf(), remaining.last()) + } + }, + ) + } catch (e: Exception) { + callback.onResults(call, listOf(), remaining.last()) + } + } + } + } + +/** Decrements the outstanding-query counter and returns whether further batches will follow. */ +private fun AtomicInteger.last(): Boolean = decrementAndGet() > 0 + +@SuppressLint("NewApi") +private fun HttpsEndpoint.toHttpsServices(): List = + httpsRecords.map { record -> + val ech = + try { + record.echConfigList?.toBytes()?.toByteString() + } catch (e: IllegalArgumentException) { + // The platform can throw on a malformed or absent ECH parameter. + // https://issuetracker.google.com/issues/319957694 + null + } + DnsResult.HttpsService(ech = ech) + } + +@SuppressLint("NewApi") +private fun DnsResolver.DnsException.toUnknownHostException(hostname: String): UnknownHostException = + UnknownHostException("DNS lookup failed for $hostname").apply { + initCause(this@toUnknownHostException) + } + +private fun String.toUnknownHostException(cause: Throwable): UnknownHostException = + UnknownHostException("DNS lookup failed for $this").apply { + initCause(cause) + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt index 671427de4e67..109502dbb59e 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android10Platform.kt @@ -26,6 +26,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.AndroidPlatform.Companion.Tag @@ -40,7 +41,7 @@ import okhttp3.internal.tls.TrustRootIndex /** Android 10+ (API 29+). */ @SuppressSignatureCheck -class Android10Platform : +open class Android10Platform : Platform(), ContextAwarePlatform { override var applicationContext: Context? = null @@ -72,6 +73,7 @@ class Android10Platform : } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, @@ -79,7 +81,12 @@ class Android10Platform : // No TLS extensions if the socket class is custom. socketAdapters .find { it.matchesSocket(sslSocket) } - ?.configureTlsExtensions(sslSocket, hostname, protocols) + ?.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt new file mode 100644 index 000000000000..88c0b042f1cb --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/Android17Platform.kt @@ -0,0 +1,144 @@ +/* + * 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.platform + +import android.annotation.SuppressLint +import android.content.Context +import android.net.DnsResolver +import android.os.Build +import android.os.StrictMode +import android.security.NetworkSecurityPolicy +import android.util.CloseGuard +import android.util.Log +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocket +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.X509TrustManager +import okhttp3.AsyncDns +import okhttp3.AsyncDns.Companion.asBlocking +import okhttp3.Call +import okhttp3.Dns +import okhttp3.Protocol +import okhttp3.android.AndroidAsyncDns +import okhttp3.ech.EchModeConfiguration +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.platform.AndroidPlatform.Companion.Tag +import okhttp3.internal.platform.android.Android17SocketAdapter +import okhttp3.internal.platform.android.AndroidCertificateChainCleaner +import okhttp3.internal.platform.android.AndroidEchModeConfiguration +import okhttp3.internal.tls.CertificateChainCleaner +import okhttp3.internal.tls.TrustRootIndex + +/** + * Android 17+ (API 37+). + * + * This platform uses the post-API 36 Android TLS and DNS APIs directly, including domain + * encryption policy, HTTPS/SVCB DNS records from [DnsResolver], and Encrypted Client Hello (ECH) + * configuration on TLS sockets. + */ +@SuppressSignatureCheck +class Android17Platform + @RequiresApi(37) + internal constructor() : + Platform(), + ContextAwarePlatform { + override var applicationContext: Context? = null + + private val socketAdapter by lazy { + Android17SocketAdapter.buildIfSupported()!! + } + + override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = socketAdapter.trustManager(sslSocketFactory) + + override fun newSSLContext(): SSLContext { + StrictMode.noteSlowCall("newSSLContext") + + return super.newSSLContext() + } + + override fun buildTrustRootIndex(trustManager: X509TrustManager): TrustRootIndex { + StrictMode.noteSlowCall("buildTrustRootIndex") + + return super.buildTrustRootIndex(trustManager) + } + + override fun configureTlsExtensions( + call: Call?, + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + socketAdapter.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) + } + + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = socketAdapter.getSelectedProtocol(sslSocket) + + @RequiresApi(36) + override fun getStackTraceForCloseable(closer: String): Any = CloseGuard().apply { open(closer) } + + @RequiresApi(36) + override fun logCloseableLeak( + message: String, + stackTrace: Any?, + ) { + (stackTrace as CloseGuard).warnIfOpen() + } + + @SuppressLint("NewApi") + override fun isCleartextTrafficPermitted(hostname: String): Boolean = + NetworkSecurityPolicy.getInstance().isCleartextTrafficPermitted(hostname) + + @SuppressLint("NewApi") + internal override val echModeConfiguration: EchModeConfiguration = AndroidEchModeConfiguration() + + override fun buildCertificateChainCleaner(trustManager: X509TrustManager): CertificateChainCleaner = + AndroidCertificateChainCleaner.buildIfSupported(trustManager)!! + + override fun log( + message: String, + level: Int, + t: Throwable?, + ) { + if (level == WARN) { + Log.w(Tag, message, t) + } else { + Log.i(Tag, message, t) + } + } + + @Suppress("NewApi") + private val asyncDns by lazy { AndroidAsyncDns() } + + @SuppressLint("NewApi") + override fun platformDns(): Dns = asyncDns.asBlocking() + + @SuppressLint("NewApi") + override fun platformAsyncDns(): AsyncDns = asyncDns + + companion object { + val isSupported: Boolean = (isAndroid && Build.VERSION.SDK_INT >= 37) + + @ChecksSdkIntAtLeast(37) + fun buildIfSupported(): Platform? = if (isSupported) Android17Platform() else null + } + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt index 4f94d192b034..47bee577e84d 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/AndroidPlatform.kt @@ -31,6 +31,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.android.AndroidCertificateChainCleaner @@ -90,6 +91,7 @@ class AndroidPlatform : ?.trustManager(sslSocketFactory) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -97,7 +99,12 @@ class AndroidPlatform : // No TLS extensions if the socket class is custom. socketAdapters .find { it.matchesSocket(sslSocket) } - ?.configureTlsExtensions(sslSocket, hostname, protocols) + ?.configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = hostname, + protocols = protocols, + ) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt index 4c912f5e61c1..4a62c4ecc89a 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/PlatformRegistry.kt @@ -25,7 +25,8 @@ actual object PlatformRegistry { AndroidLog.enable() val androidPlatform = - Android10Platform.buildIfSupported() + Android17Platform.buildIfSupported() + ?: Android10Platform.buildIfSupported() ?: AndroidPlatform.buildIfSupported() if (androidPlatform != null) return androidPlatform diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt index 83a3f4f41cbb..0d0e479f4b1d 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android10SocketAdapter.kt @@ -21,6 +21,7 @@ import android.os.Build import java.io.IOException import java.lang.IllegalArgumentException import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck import okhttp3.internal.platform.Platform @@ -54,6 +55,7 @@ class Android10SocketAdapter : SocketAdapter { @SuppressLint("NewApi") override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt new file mode 100644 index 000000000000..c87dc7317b82 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/Android17SocketAdapter.kt @@ -0,0 +1,98 @@ +/* + * 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.platform.android + +import android.annotation.SuppressLint +import android.net.ssl.SSLSockets +import android.os.Build +import androidx.annotation.ChecksSdkIntAtLeast +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLSocket +import okhttp3.Call +import okhttp3.Protocol +import okhttp3.internal.SuppressSignatureCheck +import okhttp3.internal.connection.RealCall +import okhttp3.internal.platform.Android17Platform +import okhttp3.internal.platform.Platform +import okhttp3.internal.platform.Platform.Companion.isAndroid + +/** + * Socket adapter for Android 17+ platform TLS APIs. + * + * Unlike the older Android socket adapters, this calls public platform APIs directly instead of + * using reflection or Conscrypt-specific hooks. It configures session tickets, ALPN, and ECH on + * Android's `SSLSocket` implementation. + * + * These API assumptions make it unsuitable for earlier Android versions; use + * [Android17Platform] to select this adapter only when the runtime SDK supports it. + */ +@SuppressLint("NewApi") +@SuppressSignatureCheck +class Android17SocketAdapter + @RequiresApi(36) + internal constructor() : SocketAdapter { + override fun matchesSocket(sslSocket: SSLSocket): Boolean = SSLSockets.isSupportedSocket(sslSocket) + + override fun isSupported(): Boolean = Companion.isSupported() + + override fun getSelectedProtocol(sslSocket: SSLSocket): String? = + // SSLSocket.getApplicationProtocol returns "" if application protocols values will not + // be used. Observed if you didn't specify SSLParameters.setApplicationProtocols + when (val protocol = sslSocket.applicationProtocol) { + null, "" -> null + else -> protocol + } + + override fun configureTlsExtensions( + call: Call?, + sslSocket: SSLSocket, + hostname: String?, + protocols: List, + ) { + SSLSockets.setUseSessionTickets(sslSocket, true) + + val sslParameters = sslSocket.sslParameters + + // Enable ALPN. + sslParameters.applicationProtocols = Platform.alpnProtocolNames(protocols).toTypedArray() + + sslSocket.sslParameters = sslParameters + + if (hostname != null) { + val realCall = call as? RealCall ?: return + val client = realCall.client + + val echModeConfiguration = client.echModeConfiguration + + val echMode = + realCall.echMode + ?: echModeConfiguration.echMode(hostname).also { realCall.echMode = it } + + if (echMode.attempt) { + // echConfig was resolved during DNS (RealCall.resolveAddresses); just apply it here. + echModeConfiguration.applyEch(sslSocket, echMode, hostname, realCall.echConfig) + } + } + } + + @SuppressSignatureCheck + companion object { + fun buildIfSupported(): SocketAdapter? = if (isSupported()) Android17SocketAdapter() else null + + @ChecksSdkIntAtLeast(api = 36) + fun isSupported() = isAndroid && Build.VERSION.SDK_INT >= 36 + } + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt new file mode 100644 index 000000000000..e1c237870bf1 --- /dev/null +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidEchModeConfiguration.kt @@ -0,0 +1,81 @@ +/* + * 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.platform.android + +import android.annotation.SuppressLint +import android.net.ssl.EchConfigList +import android.net.ssl.EchConfigMismatchException +import android.net.ssl.InvalidEchDataException +import android.net.ssl.SSLSockets +import android.security.NetworkSecurityPolicy +import androidx.annotation.RequiresApi +import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocket +import okhttp3.ech.EchConfig +import okhttp3.ech.EchMode +import okhttp3.ech.EchModeConfiguration +import okio.IOException + +/** + * Android implementation of [EchModeConfiguration] for API 37+. + * + * This bridges OkHttp's platform-neutral ECH policy to Android's native ECH APIs: + * [NetworkSecurityPolicy] supplies the per-host domain encryption policy, the resolved + * [EchConfig] carries the HTTPS/SVCB ECH configuration list, and [SSLSockets] applies it to the + * TLS socket. + */ +@RequiresApi(37) +internal class AndroidEchModeConfiguration : EchModeConfiguration { + @Suppress("NewApi") + override fun echMode(host: String): EchMode { + val domainEncryptionMode = NetworkSecurityPolicy.getInstance().getDomainEncryptionMode(host) + return EchMode.fromNetworkSecurityPolicy(domainEncryptionMode) + } + + @SuppressLint("NewApi") + override fun isEchConfigError(e: SSLException): Boolean = e is EchConfigMismatchException + + @Suppress("NewApi") + override fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + host: String, + echConfig: EchConfig?, + ) { + val echConfigList = + echConfig?.let { + try { + EchConfigList.fromBytes(it.config.toByteArray()) + } catch (e: InvalidEchDataException) { + null + } + } + + if (echConfigList != null) { + SSLSockets.setEchConfigList(sslSocket, echConfigList) + } else if (echMode.require) { + throw IOException("Unable to apply required ECH config for $host") + } + } +} + +internal fun EchMode.Companion.fromNetworkSecurityPolicy(domainEncryptionMode: Int): EchMode = + when (domainEncryptionMode) { + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC -> EchMode.Opportunistic + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_ENABLED -> EchMode.Strict + NetworkSecurityPolicy.DOMAIN_ENCRYPTION_MODE_DISABLED -> EchMode.Disabled + else -> EchMode.Unspecified + } diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt index 9adf56dba2b2..baf09963cd57 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/AndroidSocketAdapter.kt @@ -19,6 +19,7 @@ import android.os.Build import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.AndroidPlatform import okhttp3.internal.platform.Platform @@ -45,6 +46,7 @@ open class AndroidSocketAdapter( override fun matchesSocket(sslSocket: SSLSocket): Boolean = sslSocketClass.isInstance(sslSocket) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt index 6d2b8beb0e62..77d78bd05d17 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/BouncyCastleSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.Platform import org.bouncycastle.jsse.BCSSLSocket @@ -38,6 +39,7 @@ class BouncyCastleSocketAdapter : SocketAdapter { } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt index 006e593b7bcf..b699a05bbf2c 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/ConscryptSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.platform.Platform import org.conscrypt.Conscrypt @@ -36,6 +37,7 @@ class ConscryptSocketAdapter : SocketAdapter { } override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt index 10b619dec831..62cce3742759 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/DeferredSocketAdapter.kt @@ -16,6 +16,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol /** @@ -36,11 +37,12 @@ class DeferredSocketAdapter( override fun matchesSocket(sslSocket: SSLSocket): Boolean = socketAdapterFactory.matchesSocket(sslSocket) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, ) { - getDelegate(sslSocket)?.configureTlsExtensions(sslSocket, hostname, protocols) + getDelegate(sslSocket)?.configureTlsExtensions(call, sslSocket, hostname, protocols) } override fun getSelectedProtocol(sslSocket: SSLSocket): String? = getDelegate(sslSocket)?.getSelectedProtocol(sslSocket) diff --git a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt index 40776f6bfb17..df9ce411d347 100644 --- a/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt +++ b/okhttp/src/androidMain/kotlin/okhttp3/internal/platform/android/SocketAdapter.kt @@ -18,6 +18,7 @@ package okhttp3.internal.platform.android import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol interface SocketAdapter { @@ -30,6 +31,7 @@ interface SocketAdapter { fun matchesSocketFactory(sslSocketFactory: SSLSocketFactory): Boolean = false fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/AsyncDns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/AsyncDns.kt new file mode 100644 index 000000000000..2b692910ff58 --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/AsyncDns.kt @@ -0,0 +1,90 @@ +/* + * 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 + +import okhttp3.internal.BlockingAsyncDns +import okio.IOException + +/** + * An asynchronous domain name service that resolves a host name to [DnsResult]s. + * + * Unlike [Dns], which returns only `List`, an `AsyncDns` delivers richer DNS data — + * including HTTPS/SVCB service records carrying connection hints and Encrypted Client Hello (ECH) + * configuration. Because the data is delivered by value through [DnsCallback], an `AsyncDns` that + * wraps and forwards another `AsyncDns` preserves all of it without extra work. + * + * Typical implementations are backed by Android's `DnsResolver`, OkHttp's DnsOverHttps, or other + * resolver libraries. Implementations must be safe for concurrent use. + */ +internal fun interface AsyncDns { + /** + * Returns a new, cold [DnsCall] for [hostname]. No work is performed until the call is enqueued. + * + * When [addressesOnly] is true the caller needs only IP addresses, so the resolver may skip + * HTTPS/SVCB queries (and the ECH configuration they carry). Resolvers that ignore this flag and + * always return everything are still correct. + */ + fun newCall( + hostname: String, + addressesOnly: Boolean, + ): DnsCall + + /** A single in-flight DNS resolution. */ + interface DnsCall { + /** The host name being resolved. */ + val hostname: String + + /** + * Starts resolution and delivers results to [callback]. The callback may be invoked more than + * once; the final invocation has `hasMore = false`. + */ + fun enqueue(callback: DnsCallback) + + /** Best-effort cancellation of in-flight queries. Safe to call more than once. */ + fun cancel() + } + + /** Receives the results of a [DnsCall]. */ + interface DnsCallback { + /** + * A batch of [results]. When [hasMore] is true further batches will arrive for this call (for + * example `A` records now and `AAAA`/HTTPS records later), so consumers may begin connecting + * before resolution completes. + */ + fun onResults( + call: DnsCall, + results: List, + hasMore: Boolean, + ) + + /** + * A failure for this call. When [hasMore] is true other batches may still succeed. + */ + fun onFailure( + call: DnsCall, + e: IOException, + hasMore: Boolean, + ) + } + + companion object { + /** + * Adapts this [AsyncDns] to the blocking [Dns] interface. Only [DnsResult.Address] values are + * returned; HTTPS/SVCB metadata such as ECH is dropped because [Dns] cannot carry it. + */ + fun AsyncDns.asBlocking(): Dns = BlockingAsyncDns(this) + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsResult.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsResult.kt new file mode 100644 index 000000000000..f2c3ba1ec02b --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/DnsResult.kt @@ -0,0 +1,61 @@ +/* + * 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 + +import java.net.Inet4Address +import java.net.Inet6Address +import java.net.InetAddress +import okio.ByteString + +/** + * A single result of a DNS query. + * + * Results are carried by value through [AsyncDns.DnsCallback], so a [Dns] or [AsyncDns] that + * decorates another resolver and forwards its results preserves all of this data automatically. + * This is intentional: connection metadata such as Encrypted Client Hello (ECH) must not be lost + * when a resolver is wrapped. + */ +internal sealed interface DnsResult { + /** A resolved IP address from an `A` or `AAAA` record. This is the authoritative address source. */ + class Address( + val address: InetAddress, + ) : DnsResult + + /** + * An HTTPS (SVCB) service record for the host, as defined by + * [RFC 9460](https://www.rfc-editor.org/rfc/rfc9460.html). + * + * A record is in ServiceMode when [svcPriority] is greater than 0 and AliasMode when it is 0. + * Address hints ([ipv4Hints]/[ipv6Hints]) are an optimization only; `A`/`AAAA` records remain + * the authoritative address source (RFC 9460 §7.3). + */ + class HttpsService( + /** The serialized ECHConfigList for this endpoint, or null if the record carries no ECH. */ + val ech: ByteString? = null, + /** SvcPriority; 0 selects AliasMode, any other value selects ServiceMode. */ + val svcPriority: Int = 1, + /** The TargetName the record points at, or the empty string for the origin host ("."). */ + val targetName: String = "", + /** ALPN protocol identifiers advertised by the `alpn` SvcParam. */ + val alpn: List = listOf(), + /** The `port` SvcParam, or null when absent. */ + val port: Int? = null, + /** IPv4 address hints (`ipv4hint` SvcParam). */ + val ipv4Hints: List = listOf(), + /** IPv6 address hints (`ipv6hint` SvcParam). */ + val ipv6Hints: List = listOf(), + ) : DnsResult +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt index 4c61fc4b7bf6..1d1e13ff9dae 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/Handshake.kt @@ -21,6 +21,7 @@ import java.security.cert.Certificate import java.security.cert.X509Certificate import javax.net.ssl.SSLPeerUnverifiedException import javax.net.ssl.SSLSession +import okhttp3.ech.EchConfig import okhttp3.internal.toImmutableList /** @@ -40,6 +41,7 @@ class Handshake internal constructor( @get:JvmName("cipherSuite") val cipherSuite: CipherSuite, /** Returns a possibly-empty list of certificates that identify this peer. */ @get:JvmName("localCertificates") val localCertificates: List, + internal val echConfig: EchConfig? = null, // Delayed provider of peerCertificates, to allow lazy cleaning. peerCertificatesFn: () -> List, ) { @@ -194,7 +196,11 @@ class Handshake internal constructor( localCertificates: List, ): Handshake { val peerCertificatesCopy = peerCertificates.toImmutableList() - return Handshake(tlsVersion, cipherSuite, localCertificates.toImmutableList()) { + return Handshake( + tlsVersion = tlsVersion, + cipherSuite = cipherSuite, + localCertificates = localCertificates.toImmutableList(), + ) { peerCertificatesCopy } } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt index bea47f62e899..d2bb40fae0db 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/OkHttpClient.kt @@ -30,6 +30,7 @@ import javax.net.ssl.X509TrustManager import kotlin.time.Duration as KotlinDuration import okhttp3.Protocol.HTTP_1_1 import okhttp3.Protocol.HTTP_2 +import okhttp3.ech.EchModeConfiguration import okhttp3.internal.asFactory import okhttp3.internal.checkDuration import okhttp3.internal.concurrent.TaskRunner @@ -183,6 +184,12 @@ open class OkHttpClient internal constructor( @get:JvmName("dns") val dns: Dns = builder.dns + /** + * The asynchronous DNS resolver, or null to resolve with [dns] only. When set, the connection + * path can use HTTPS/SVCB records it returns (including Encrypted Client Hello configuration). + */ + internal val asyncDns: AsyncDns? = builder.asyncDns + @get:JvmName("proxy") val proxy: Proxy? = builder.proxy @@ -273,6 +280,8 @@ open class OkHttpClient internal constructor( builder.connectionPool = it } + internal val echModeConfiguration: EchModeConfiguration = builder.echModeConfiguration + constructor() : this(Builder()) init { @@ -597,7 +606,8 @@ open class OkHttpClient internal constructor( internal var followSslRedirects = true internal var cookieJar: CookieJar = CookieJar.NO_COOKIES internal var cache: Cache? = null - internal var dns: Dns = Dns.SYSTEM + internal var dns: Dns = Platform.get().platformDns() + internal var asyncDns: AsyncDns? = Platform.get().platformAsyncDns() internal var proxy: Proxy? = null internal var proxySelector: ProxySelector? = null internal var proxyAuthenticator: Authenticator = Authenticator.NONE @@ -618,6 +628,7 @@ open class OkHttpClient internal constructor( internal var minWebSocketMessageToCompress = RealWebSocket.DEFAULT_MINIMUM_DEFLATE_SIZE internal var routeDatabase: RouteDatabase? = null internal var taskRunner: TaskRunner? = null + internal var echModeConfiguration: EchModeConfiguration = Platform.get().echModeConfiguration internal constructor(okHttpClient: OkHttpClient) : this() { this.dispatcher = okHttpClient.dispatcher @@ -633,6 +644,7 @@ open class OkHttpClient internal constructor( this.cookieJar = okHttpClient.cookieJar this.cache = okHttpClient.cache this.dns = okHttpClient.dns + this.asyncDns = okHttpClient.asyncDns this.proxy = okHttpClient.proxy this.proxySelector = okHttpClient.proxySelector this.proxyAuthenticator = okHttpClient.proxyAuthenticator @@ -653,6 +665,7 @@ open class OkHttpClient internal constructor( this.minWebSocketMessageToCompress = okHttpClient.minWebSocketMessageToCompress this.routeDatabase = okHttpClient.routeDatabase this.taskRunner = okHttpClient.taskRunner + this.echModeConfiguration = okHttpClient.echModeConfiguration } /** diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt new file mode 100644 index 000000000000..2186eaeaa993 --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchConfig.kt @@ -0,0 +1,31 @@ +/* + * 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.ech + +import okio.ByteString + +/** + * Configuration for Encrypted Client Hello (ECH). + * + * This contains the parameters required for a client to encrypt its ClientHello message, + * protecting sensitive fields such as the Server Name Indication (SNI) from passive observers. + * These parameters are typically retrieved from DNS via HTTPS or SVCB records, and platform + * implementations may carry additional native objects needed to configure TLS sockets. + */ +internal interface EchConfig { + /** The serialized ECH configuration list from DNS. */ + val config: ByteString +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt new file mode 100644 index 000000000000..f1de08904ea5 --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchMode.kt @@ -0,0 +1,70 @@ +/* + * 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.ech + +/** + * Configures the behavior of Encrypted Client Hello (ECH) for TLS connections. + */ +internal enum class EchMode( + /** True if OkHttp should attempt to configure ECH for the TLS connection. */ + val attempt: Boolean, + /** True if the connection must fail when ECH cannot be configured or negotiated. */ + val require: Boolean, + /** True if OkHttp should retry without ECH when the server rejects the ECH configuration. */ + val fallback: Boolean = false, +) { + /** + * The ECH mode is not specified. ECH will not be attempted or required. + */ + Unspecified(attempt = false, require = false), + + /** ECH is disabled. */ + Disabled( + attempt = false, + require = false, + ), + + /** + * Attempt ECH if configuration is available, but fall back to standard TLS if it fails. + */ + Opportunistic( + attempt = true, + require = false, + fallback = true, + ), + + /** + * Attempt ECH if the configuration is available. + */ + Strict( + attempt = true, + require = false, + ), + + /** + * Attempt ECH and fail the connection if it cannot be established. + */ + FailClosed(attempt = true, require = true), + + /** + * Retry with ECH disabled. + */ + Fallback(attempt = false, require = false), + ; + + /** Companion for extension functions and Java interop. */ + companion object +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt new file mode 100644 index 000000000000..a65f40530a3d --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/ech/EchModeConfiguration.kt @@ -0,0 +1,78 @@ +/* + * 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.ech + +import javax.net.ssl.SSLException +import javax.net.ssl.SSLSocket + +/** + * Configuration and management for Encrypted Client Hello (ECH). + * + * This interface provides the mechanism to determine the ECH strategy for a given host, + * apply ECH parameters to an [SSLSocket], and identify ECH-specific connection failures. + */ +internal interface EchModeConfiguration { + /** + * Determines the [EchMode] strategy to be used for the specified [host]. + * + * @param host the hostname for which the ECH strategy is requested. + * @return the [EchMode] to be applied during the connection process. + */ + fun echMode(host: String): EchMode + + /** + * Configures [sslSocket] with the already-resolved [echConfig] for [host]. If [echMode] requires + * ECH and [echConfig] is null (or can't be applied), this throws an [java.io.IOException]. + */ + fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + host: String, + echConfig: EchConfig?, + ) + + /** + * Returns true if [e] indicates a failure due to an invalid or expired ECH configuration. + * + * This typically occurs when the server's ECH public key has rotated. When this returns + * true, the client may use the server-provided "retry_config" to update its configuration + * and attempt the connection again. + * + * @param e the exception thrown during the SSL handshake. + */ + fun isEchConfigError(e: SSLException): Boolean = false + + /** Built-in [EchModeConfiguration] instances. */ + companion object { + /** + * A default implementation of [EchModeConfiguration] that performs no ECH-related actions + * and always returns [EchMode.Unspecified]. + */ + val Unspecified = + object : EchModeConfiguration { + override fun echMode(host: String): EchMode = EchMode.Unspecified + + override fun applyEch( + sslSocket: SSLSocket, + echMode: EchMode, + host: String, + echConfig: EchConfig?, + ) { + check(!echMode.attempt) + } + } + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/BlockingAsyncDns.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/BlockingAsyncDns.kt new file mode 100644 index 000000000000..ec037b89532b --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/BlockingAsyncDns.kt @@ -0,0 +1,74 @@ +/* + * 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 + +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.CountDownLatch +import okhttp3.AsyncDns +import okhttp3.Dns +import okhttp3.DnsResult +import okio.IOException + +/** + * Adapts an [AsyncDns] to the blocking [Dns] interface, waiting for the final result batch and + * returning its addresses. HTTPS/SVCB metadata is not representable in [Dns] and is discarded. + */ +internal class BlockingAsyncDns( + private val asyncDns: AsyncDns, +) : Dns { + override fun lookup(hostname: String): List { + val addresses = mutableListOf() + val failures = mutableListOf() + val latch = CountDownLatch(1) + + // Dns can only carry addresses, so skip the HTTPS/SVCB query. + asyncDns.newCall(hostname, addressesOnly = true).enqueue( + object : AsyncDns.DnsCallback { + override fun onResults( + call: AsyncDns.DnsCall, + results: List, + hasMore: Boolean, + ) { + synchronized(addresses) { + for (result in results) { + if (result is DnsResult.Address) addresses += result.address + } + } + if (!hasMore) latch.countDown() + } + + override fun onFailure( + call: AsyncDns.DnsCall, + e: IOException, + hasMore: Boolean, + ) { + synchronized(failures) { failures += e } + if (!hasMore) latch.countDown() + } + }, + ) + + latch.await() + + synchronized(addresses) { + if (addresses.isNotEmpty()) return addresses.toList() + } + + throw synchronized(failures) { failures.firstOrNull() } + ?: UnknownHostException("$asyncDns returned no addresses for $hostname") + } +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Resolve.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Resolve.kt new file mode 100644 index 000000000000..ce126b8d264d --- /dev/null +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/Resolve.kt @@ -0,0 +1,93 @@ +/* + * 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 + +import java.net.InetAddress +import java.net.UnknownHostException +import java.util.concurrent.CountDownLatch +import okhttp3.AsyncDns +import okhttp3.Dns +import okhttp3.DnsResult +import okhttp3.ech.EchConfig +import okhttp3.internal.connection.RealCall +import okio.ByteString +import okio.IOException + +/** + * Resolves [hostname] for this call. + * + * This is the internal, call-aware entry point for DNS. When the platform provides an [AsyncDns] + * it is used (and any HTTPS/SVCB Encrypted Client Hello configuration is captured onto + * [RealCall.echConfig]); otherwise the call is dropped and the public, call-less [dns] is used. + * That keeps ECH attached to the resolution even though the public [Dns] interface can't carry it. + */ +internal fun RealCall.resolveAddresses( + dns: Dns, + hostname: String, +): List { + val asyncDns = client.asyncDns ?: return dns.lookup(hostname) + + val echMode = echMode ?: client.echModeConfiguration.echMode(hostname).also { echMode = it } + + val addresses = mutableListOf() + val failures = mutableListOf() + var echBytes: ByteString? = null + val latch = CountDownLatch(1) + + asyncDns.newCall(hostname, addressesOnly = !echMode.attempt).enqueue( + object : AsyncDns.DnsCallback { + override fun onResults( + call: AsyncDns.DnsCall, + results: List, + hasMore: Boolean, + ) { + synchronized(addresses) { + for (result in results) { + when (result) { + is DnsResult.Address -> addresses += result.address + is DnsResult.HttpsService -> if (echBytes == null) echBytes = result.ech + } + } + } + if (!hasMore) latch.countDown() + } + + override fun onFailure( + call: AsyncDns.DnsCall, + e: IOException, + hasMore: Boolean, + ) { + synchronized(failures) { failures += e } + if (!hasMore) latch.countDown() + } + }, + ) + + latch.await() + + echConfig = + echBytes?.let { bytes -> + object : EchConfig { + override val config: ByteString = bytes + } + } + + synchronized(addresses) { + if (addresses.isNotEmpty()) return addresses.toList() + } + throw synchronized(failures) { failures.firstOrNull() } + ?: UnknownHostException("$asyncDns returned no addresses for $hostname") +} diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt index e90421ccdc44..aebc11b47564 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/ConnectPlan.kt @@ -345,7 +345,12 @@ class ConnectPlan internal constructor( var success = false try { if (connectionSpec.supportsTlsExtensions) { - Platform.get().configureTlsExtensions(sslSocket, address.url.host, address.protocols) + Platform.get().configureTlsExtensions( + call = call, + sslSocket = sslSocket, + hostname = address.url.host, + protocols = address.protocols, + ) } // Force handshake. This can throw! @@ -378,9 +383,10 @@ class ConnectPlan internal constructor( val handshake = Handshake( - unverifiedHandshake.tlsVersion, - unverifiedHandshake.cipherSuite, - unverifiedHandshake.localCertificates, + tlsVersion = unverifiedHandshake.tlsVersion, + cipherSuite = unverifiedHandshake.cipherSuite, + localCertificates = unverifiedHandshake.localCertificates, + echConfig = call.echConfig, ) { certificatePinner.certificateChainCleaner!!.clean( unverifiedHandshake.peerCertificates, @@ -406,6 +412,8 @@ class ConnectPlan internal constructor( protocol = if (maybeProtocol != null) Protocol.get(maybeProtocol) else Protocol.HTTP_1_1 success = true } finally { + // ECH rejection is surfaced as an SSLException by the platform. Let it propagate so + // RetryAndFollowUpInterceptor can classify it with EchModeConfiguration.isEchConfigError(). Platform.get().afterHandshake(sslSocket) if (!success) { sslSocket.closeQuietly() diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt index 9c7721fc5978..26d4d07e385f 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RealCall.kt @@ -35,6 +35,8 @@ import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.ech.EchConfig +import okhttp3.ech.EchMode import okhttp3.internal.assertLockNotHeld import okhttp3.internal.cache.CacheInterceptor import okhttp3.internal.closeQuietly @@ -105,6 +107,14 @@ class RealCall( internal var interceptorScopedExchange: Exchange? = null private set + /** + * Encrypted Client Hello (ECH) state for this call. [echMode] is computed once and may be + * downgraded to [EchMode.Fallback] when a connection is retried without ECH; [echConfig] is the + * configuration that was applied during the TLS handshake, surfaced on the resulting [Handshake]. + */ + internal var echMode: EchMode? = null + internal var echConfig: EchConfig? = null + // These properties are guarded by `this`. They are typically only accessed by the thread executing // the call, but they may be accessed by other threads for duplex requests. diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt index 548d2bac48b4..1bc348aa7360 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/connection/RouteSelector.kt @@ -26,6 +26,7 @@ import okhttp3.HttpUrl import okhttp3.Route import okhttp3.internal.canParseAsIpAddress import okhttp3.internal.immutableListOf +import okhttp3.internal.resolveAddresses import okhttp3.internal.toImmutableList /** @@ -174,7 +175,7 @@ class RouteSelector internal constructor( } else { call.eventListener.dnsStart(call, socketHost) - val result = address.dns.lookup(socketHost) + val result = call.resolveAddresses(address.dns, socketHost) if (result.isEmpty()) { throw UnknownHostException("${address.dns} returned no addresses for $socketHost") } diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt index 458c3eaad396..673f32c3e4ed 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/http/RetryAndFollowUpInterceptor.kt @@ -30,17 +30,20 @@ import java.net.ProtocolException import java.net.Proxy import java.net.SocketTimeoutException import java.security.cert.CertificateException +import javax.net.ssl.SSLException import javax.net.ssl.SSLHandshakeException import javax.net.ssl.SSLPeerUnverifiedException import okhttp3.Interceptor import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.Response +import okhttp3.ech.EchMode import okhttp3.internal.canReuseConnectionFor import okhttp3.internal.closeQuietly import okhttp3.internal.connection.Exchange import okhttp3.internal.connection.RealCall import okhttp3.internal.http2.ConnectionShutdownException +import okhttp3.internal.platform.Platform import okhttp3.internal.stripBody import okhttp3.internal.withSuppressed @@ -138,6 +141,21 @@ class RetryAndFollowUpInterceptor : Interceptor { ): Boolean { val requestSendStarted = e !is ConnectionShutdownException + if (e is SSLException) { + val echModeConfiguration = call.client.echModeConfiguration + val echMode = echModeConfiguration.echMode(call.request().url.host) + if ( + call.echMode != EchMode.Fallback && + echMode.fallback && + echModeConfiguration.isEchConfigError(e) + ) { + // Mark this call so the next connection attempt skips ECH. Without this guard a fallback + // connection that also fails with an ECH-classified SSLException could retry indefinitely. + Platform.get().log("Should retry here with ECH disabled") + call.echMode = EchMode.Fallback + } + } + // The application layer has forbidden retries. if (!chain.retryOnConnectionFailure) return false diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt index 6247d485331f..f3d362cd37ad 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Jdk9Platform.kt @@ -20,6 +20,7 @@ import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import okhttp3.internal.SuppressSignatureCheck @@ -32,6 +33,7 @@ import okhttp3.internal.SuppressSignatureCheck open class Jdk9Platform : Platform() { @SuppressSignatureCheck override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, diff --git a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt index f33d97972b94..7127e7c0c41f 100644 --- a/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt +++ b/okhttp/src/commonJvmAndroid/kotlin/okhttp3/internal/platform/Platform.kt @@ -31,8 +31,12 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.AsyncDns +import okhttp3.Call +import okhttp3.Dns import okhttp3.OkHttpClient import okhttp3.Protocol +import okhttp3.ech.EchModeConfiguration import okhttp3.internal.publicsuffix.PublicSuffixDatabase import okhttp3.internal.readFieldOrNull import okhttp3.internal.tls.BasicCertificateChainCleaner @@ -113,6 +117,7 @@ open class Platform { * Configure TLS extensions on `sslSocket` for `route`. */ open fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -160,6 +165,9 @@ open class Platform { open fun isCleartextTrafficPermitted(hostname: String): Boolean = true + internal open val echModeConfiguration: EchModeConfiguration + get() = EchModeConfiguration.Unspecified + /** * Returns an object that holds a stack trace created at the moment this method is executed. This * should be used specifically for [java.io.Closeable] objects and in conjunction with @@ -179,7 +187,8 @@ open class Platform { ) { var logMessage = message if (stackTrace == null) { - logMessage += " To see where this was allocated, set the OkHttpClient logger level to " + + logMessage += + " To see where this was allocated, set the OkHttpClient logger level to " + "FINE: Logger.getLogger(OkHttpClient.class.getName()).setLevel(Level.FINE);" } log(logMessage, WARN, stackTrace as Throwable?) @@ -201,10 +210,20 @@ open class Platform { } } + open fun platformDns(): Dns = Dns.SYSTEM + + /** + * Returns a platform-specific [AsyncDns] capable of resolving HTTPS/SVCB records (including ECH + * configuration), or null if the platform has no such resolver. Used as the default + * [OkHttpClient.asyncDns]. + */ + internal open fun platformAsyncDns(): AsyncDns? = null + override fun toString(): String = javaClass.simpleName companion object { - @Volatile private var platform = findPlatform() + @Volatile + private var platform = findPlatform() const val INFO = 4 const val WARN = 5 diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt index 83e2e57ee61b..780d17d55d26 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/BouncyCastlePlatform.kt @@ -22,6 +22,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import org.bouncycastle.jsse.BCSSLSocket import org.bouncycastle.jsse.provider.BouncyCastleJsseProvider @@ -56,6 +57,7 @@ class BouncyCastlePlatform private constructor() : Platform() { ) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -69,7 +71,7 @@ class BouncyCastlePlatform private constructor() : Platform() { sslSocket.parameters = sslParameters } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } } diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt index 51ef4ca4b3c2..3bfab099ccb8 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/ConscryptPlatform.kt @@ -25,6 +25,7 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol import org.conscrypt.Conscrypt import org.conscrypt.ConscryptHostnameVerifier @@ -75,6 +76,7 @@ class ConscryptPlatform private constructor() : Platform() { override fun trustManager(sslSocketFactory: SSLSocketFactory): X509TrustManager? = null override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -87,7 +89,7 @@ class ConscryptPlatform private constructor() : Platform() { val names = alpnProtocolNames(protocols) Conscrypt.setApplicationProtocols(sslSocket, names.toTypedArray()) } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } } diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt index 5ae0567cd212..c536cdefab3c 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/Jdk8WithJettyBootPlatform.kt @@ -20,6 +20,7 @@ import java.lang.reflect.InvocationTargetException import java.lang.reflect.Method import java.lang.reflect.Proxy import javax.net.ssl.SSLSocket +import okhttp3.Call import okhttp3.Protocol /** OpenJDK 8 with `org.mortbay.jetty.alpn:alpn-boot` in the boot class path. */ @@ -31,6 +32,7 @@ class Jdk8WithJettyBootPlatform( private val serverProviderClass: Class<*>, ) : Platform() { override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List, diff --git a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt index e4d50a391144..1ea8ad0d13cf 100644 --- a/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt +++ b/okhttp/src/jvmMain/kotlin/okhttp3/internal/platform/OpenJSSEPlatform.kt @@ -22,6 +22,7 @@ import javax.net.ssl.SSLSocket import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManagerFactory import javax.net.ssl.X509TrustManager +import okhttp3.Call import okhttp3.Protocol /** @@ -60,6 +61,7 @@ class OpenJSSEPlatform private constructor() : Platform() { ) override fun configureTlsExtensions( + call: Call?, sslSocket: SSLSocket, hostname: String?, protocols: List<@JvmSuppressWildcards Protocol>, @@ -75,7 +77,7 @@ class OpenJSSEPlatform private constructor() : Platform() { sslSocket.sslParameters = sslParameters } } else { - super.configureTlsExtensions(sslSocket, hostname, protocols) + super.configureTlsExtensions(call, sslSocket, hostname, protocols) } }