diff --git a/.github/workflows/android-ech.yml b/.github/workflows/android-ech.yml index 3c8f834..359ad53 100644 --- a/.github/workflows/android-ech.yml +++ b/.github/workflows/android-ech.yml @@ -58,8 +58,9 @@ jobs: # OkHttp cannot use must produce an ordinary handshake to the real name, not a # failed call. # 35 the same, on a recent release used by devices in the field. - # 37.1 the current stable image, where `android.net.ssl.EchConfigList` exists and ECH - # is actually performed. + # 36 the same ECH fallback, plus Android 16's explicit Certificate Transparency opt-in. + # 37.1 the current stable image, where Certificate Transparency is enforced by default, + # `android.net.ssl.EchConfigList` exists, and ECH is actually performed. # 37.2-beta3 the next quarterly release, exercising the same ECH path before release. # # Play Store images exist only for the newest level, so the older rows take the plain @@ -67,7 +68,7 @@ jobs: matrix: >- ${{ fromJSON( (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') - && '{"include":[{"apiLevel":"21","target":"default","arch":"x86_64"},{"apiLevel":"30","target":"google_apis","arch":"x86_64"},{"apiLevel":"35","target":"google_apis","arch":"x86_64"},{"apiLevel":"37.1","target":"google_apis_playstore_ps16k","arch":"x86_64"},{"apiLevel":"37.2-beta3","target":"google_apis_playstore_ps16k","arch":"x86_64"}]}' + && '{"include":[{"apiLevel":"21","target":"default","arch":"x86_64"},{"apiLevel":"30","target":"google_apis","arch":"x86_64"},{"apiLevel":"35","target":"google_apis","arch":"x86_64"},{"apiLevel":"36","target":"google_apis","arch":"x86_64"},{"apiLevel":"37.1","target":"google_apis_playstore_ps16k","arch":"x86_64"},{"apiLevel":"37.2-beta3","target":"google_apis_playstore_ps16k","arch":"x86_64"}]}' || '{"include":[{"apiLevel":"37.1","target":"google_apis_playstore_ps16k","arch":"x86_64"}]}' ) }} @@ -92,8 +93,9 @@ jobs: # ECH is applied by OkHttp's Android platform through android.net.ssl.EchConfigList, so # only the API 37 rows perform it; the rest assert what a device that cannot do ECH must - # do instead. See the matrix above, and EncryptedClientHelloTest. - - name: Run the ECH suite against the fixture containers + # do instead. API 36+ also runs the deterministic Certificate Transparency fixture cases. + # See the matrix above, EncryptedClientHelloTest, and CertificateTransparencyTest. + - name: Run the Android TLS suite against the fixture containers uses: reactivecircus/android-emulator-runner@v2.38.0 with: api-level: ${{ matrix.apiLevel }} diff --git a/README.md b/README.md index bf174ed..67aab86 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Suites |---------------|-----------------------------|---------------------------------------------------------------------| | `containers` | Docker | SOCKS5 and HTTP proxies, TLS via MockServer, HTTP semantics via go-httpbin, chains that must be rejected, hostile responses, virtual threads (Loom) | | `network` | Outbound network | ALPN and SNI overrides, Let's Encrypt trust, hostile responses in public, ECH on the public servers | -| `android-ech` | Docker, emulators from API 21 to 37 | Encrypted Client Hello over DoH: accepted, retried, and declined, plus the public servers | +| `android-ech` | Docker, emulators from API 21 to 37 | Android TLS policy: Certificate Transparency enforcement, plus Encrypted Client Hello over DoH accepted, retried, and declined | The `network` suites call servers other people operate — Google, Cloudflare, Let's Encrypt, and the ECH test servers at `tls-ech.dev` and `defo.ie`. They came from OkHttp's @@ -471,8 +471,8 @@ and `Dns.Record.ServiceMetadata`, since an ECH config list has nowhere else to c API arrived in 5.5.0, so the suite is left out of the source set below it — the same version gate `EchTest` uses, without the Conscrypt half, because no TLS stack is involved in what DNS said. -The ECH suite -------------- +The Android TLS suite +--------------------- `android-ech` tests Encrypted Client Hello end to end. It needs Docker *and* an emulator, which is why it is its own suite with its own workflow rather than another entry under @@ -507,12 +507,23 @@ It needs a running emulator or a connected device, and what it can assert depend | API level | What the run establishes | |-----------|---------------------------------------------------------------------------------| | 21–28 | The library loads and initializes. Every case skips: the fixture origin is TLS 1.3 only, and Android gained TLS 1.3 in API 29 | -| 29–36 | The fallback. A config list OkHttp cannot apply must produce an ordinary handshake to the real name rather than a failed call | -| 37+ | ECH itself — `android.net.ssl.EchConfigList`, which is how OkHttp's Android platform applies a config list, arrived there | +| 29–35 | The ECH fallback. A config list OkHttp cannot apply must produce an ordinary handshake to the real name rather than a failed call | +| 36 | The ECH fallback, plus explicitly enabled Certificate Transparency enforcement | +| 37+ | Default Certificate Transparency enforcement and ECH itself — `android.net.ssl.EchConfigList`, which is how OkHttp's Android platform applies a config list, arrived there | The cases also skip on a run that didn't come through the script, which is what supplies the fixture's ports and CA. +`CertificateTransparencyTest` uses two names on the same fixture connection, covered by the +same unlogged leaf certificate. `network_security_config.xml` opts one name out of CT, and that +request must succeed. API 36 explicitly enforces CT for the other name; API 37 leaves CT unset to +exercise the platform's target-SDK default. The enforced request must fail with Conscrypt's +CT-policy error. The successful control rules out expiry, hostname, trust-chain, protocol, and +routing failures. Keeping the negative certificate in the fixture also avoids the false positives +caused by public `no-sct` test certificates expiring independently of this repository. The API +37.1 emulator runs on every push and pull request, so the Android 17 default is always covered; the +scheduled matrix also exercises the next API 37 quarterly image. + This suite tests **5.5.0-SNAPSHOT** by default, not the release the other suites pin, and `libs.versions.toml` carries that as a separate `ech-okhttp` version. It has to: the suite needs `DnsOverHttps.Builder.includeServiceMetadata`, and no release has it — 5.4.0 resolves @@ -594,7 +605,7 @@ once. They run on the schedule, and on demand: | `containers (…, JDK 8 · 11 · 17 · 21 · 25)` | the snapshot on 17 and up, the pinned release on all five | schedule and manual runs only | | `network / compile` | the `okhttp` version in `libs.versions.toml` | push and pull request | | `network (…, JDK 8 · 11 · 17 · 21 · 25)` | the snapshot on 17 and up, the pinned release on all five | schedule and manual runs only | -| `android-ech (…, API 21 · 30 · 35 · 37.1 · 37.2-beta3)` | the snapshot | API 37.1 on every event, the rest on schedule and manual runs only | +| `android-ech (…, API 21 · 30 · 35 · 36 · 37.1 · 37.2-beta3)` | the snapshot | API 37.1 on every event, the rest on schedule and manual runs only | What keeps the network module honest between scheduled runs is `network / compile`, which runs on every push and pull request touching `network/**` and calls nobody: it compiles the @@ -619,7 +630,7 @@ commit here and isn't worth asking more than once a day: | Axis | Daily coverage | Why those | |-------------|----------------|--------------------------------------------------------------| | JDK | 8, 11, 17, 21, 25 | 8 is the floor, because it is OkHttp's: JUnit 5, Testcontainers, assertk and OkHttp are all Java 8 bytecode, and the toolchain split above is what stops Gradle's own need for 17 setting the floor instead. 25 is the current LTS and the ceiling. 11 and 17 are the LTS releases applications are still on. 21 earns its place twice over — it is the LTS most builds are on, and the one the Loom finding is about: `BasicLoomTest` is `@EnabledForJreRange(min = JAVA_21)`, and JEP 491 changes its answer on 24+, so the 21 and 25 rows are the before and after of that. Java 26 is out and would work — Kotlin 2.4 targets it — but the LTS ceiling is the one users are on. 8 and 11 test the pinned release only: what they are asked is whether the artifact people can depend on today still works where they are | -| Android API | 21, 30, 35, 37.1, 37.2-beta3 | 21 is the module's `minSdk` and OkHttp 5's. 30 is an older level with TLS 1.3, 35 represents recent devices in the field, and both 37 images exercise ECH. The beta row catches quarterly-release regressions before they become stable. What each level actually establishes is in the table under [The ECH suite](#the-ech-suite) | +| Android API | 21, 30, 35, 36, 37.1, 37.2-beta3 | 21 is the module's `minSdk` and OkHttp 5's. 30 is an older level with TLS 1.3, 35 represents recent devices in the field, 36 exercises the CT opt-in, and both 37 images exercise default CT enforcement and ECH. The beta row catches quarterly-release regressions before they become stable. What each level actually establishes is in the table under [The Android TLS suite](#the-android-tls-suite) | Four scheduled workflows, spread across the day rather than started together — `containers` at 02:17 UTC, `test-server` at 06:41, `network` at 10:43, `android-ech` at 14:47. Each is diff --git a/android-ech/run-ech-test.sh b/android-ech/run-ech-test.sh index 958ce3a..40b070c 100755 --- a/android-ech/run-ech-test.sh +++ b/android-ech/run-ech-test.sh @@ -236,6 +236,13 @@ if [ "$public_status" -ne 0 ]; then echo "PublicEncryptedClientHelloTest failed; recorded, not fatal." >&2 fi +# Certificate Transparency is exercised against the local TLS fixture, not a public test site: +# `no-sct.badssl.com` has repeatedly expired, and accepting its generic certificate failure as a +# CT result creates a false positive. This suite gates because both the server and its CA are ours. +run_suite CertificateTransparencyTest \ + -Pandroid.testInstrumentationRunnerArguments.ct=true \ + -Pandroid.testInstrumentationRunnerArguments.caCertificate="$ca_certificate" + # The fixture suite does gate: it runs against containers this repository starts, so a failure # is about OkHttp or about this repository, and there is nobody else to blame for it. run_suite EncryptedClientHelloTest \ diff --git a/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/CertificateTransparencyTest.kt b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/CertificateTransparencyTest.kt new file mode 100644 index 0000000..8d15303 --- /dev/null +++ b/android-ech/src/androidTest/kotlin/okhttp/testbed/android/ech/CertificateTransparencyTest.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2026 Block, Inc. + * + * 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.testbed.android.ech + +import android.os.Build +import android.util.Base64 +import androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.contains +import assertk.assertions.isEqualTo +import java.io.ByteArrayInputStream +import java.net.InetAddress +import java.security.KeyStore +import java.security.cert.CertificateFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLHandshakeException +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import okhttp3.Dns +import okhttp3.OkHttpClient +import okhttp3.Request +import org.junit.Assert.fail +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test + +/** + * Android's Certificate Transparency enforcement against a deterministic local fixture. + * + * The two names are served by the same endpoint with the same unlogged certificate. The network + * security config opts one name out and enforces CT for the other (explicitly on API 36, by the + * Android 17 default on API 37+). Consequently the opt-out request is a control for every ordinary + * cause of a TLS failure (the CA, validity, hostname, protocol, and route), while the enforced + * request must fail specifically at the CT policy check. This avoids relying on + * `no-sct.badssl.com`, whose certificate can expire or whose behavior can change independently of + * this repository. + * + * CT is available from API 36 and is enabled by default for apps targeting API 37. The qualified + * network configs make the same test cover both policies if an API 36 device is in the matrix. + */ +class CertificateTransparencyTest { + private lateinit var client: OkHttpClient + + @Before + fun setUp() { + assumeTrue("Certificate Transparency requires Android API 36", Build.VERSION.SDK_INT >= 36) + + val arguments = InstrumentationRegistry.getArguments() + assumeTrue("requires the host-side TLS fixture", arguments.getString("ct") == "true") + val caCertificate = Base64.decode(requireNotNull(arguments.getString("caCertificate")), Base64.DEFAULT) + val (sslContext, trustManager) = sslContext(caCertificate) + client = + OkHttpClient + .Builder() + .sslSocketFactory(sslContext.socketFactory, trustManager) + .dns(Dns { listOf(InetAddress.getByName("127.0.0.1")) }) + .build() + } + + @Test + fun unloggedCertificateConnectsWhenCtIsOptedOut() { + get(CT_OPT_OUT_NAME).use { response -> + assertThat(response.code).isEqualTo(200) + assertThat(response.body.string()).contains("\"serverName\":\"$CT_OPT_OUT_NAME\"") + } + } + + @Test + fun unloggedCertificateIsRejectedWhenCtIsEnforced() { + val failure = + try { + get(CT_ENFORCED_NAME).use { response -> + fail("expected CT enforcement, but received HTTP ${response.code}") + } + error("unreachable") + } catch (e: SSLHandshakeException) { + e + } + + val messages = + generateSequence(failure) { it.cause } + .mapNotNull(Throwable::message) + .joinToString("\n") + assertThat(messages).contains("Certificate chain does not conform to required transparency policy.") + } + + private fun get(hostname: String) = + client + .newCall(Request.Builder().url("https://$hostname:$FIXTURE_PORT/").build()) + .execute() + + private companion object { + private fun sslContext(caCertificatePem: ByteArray): Pair { + val certificate = + CertificateFactory + .getInstance("X.509") + .generateCertificate(ByteArrayInputStream(caCertificatePem)) + val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()) + keyStore.load(null) + keyStore.setCertificateEntry("fixture", certificate) + val trustManagerFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()) + trustManagerFactory.init(keyStore) + val trustManager = trustManagerFactory.trustManagers.single() as X509TrustManager + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, arrayOf(trustManager), null) + return sslContext to trustManager + } + + private const val FIXTURE_PORT = 8443 + private const val CT_ENFORCED_NAME = "ct-enforced.test" + private const val CT_OPT_OUT_NAME = "ct-opt-out.test" + } +} diff --git a/android-ech/src/main/AndroidManifest.xml b/android-ech/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b569d25 --- /dev/null +++ b/android-ech/src/main/AndroidManifest.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/android-ech/src/main/res/xml-v36/network_security_config.xml b/android-ech/src/main/res/xml-v36/network_security_config.xml new file mode 100644 index 0000000..6c2b40e --- /dev/null +++ b/android-ech/src/main/res/xml-v36/network_security_config.xml @@ -0,0 +1,16 @@ + + + + + ct-enforced.test + + + + ct-opt-out.test + + + diff --git a/android-ech/src/main/res/xml-v37/network_security_config.xml b/android-ech/src/main/res/xml-v37/network_security_config.xml new file mode 100644 index 0000000..b004902 --- /dev/null +++ b/android-ech/src/main/res/xml-v37/network_security_config.xml @@ -0,0 +1,13 @@ + + + + + ct-enforced.test + + + + + ct-opt-out.test + + + diff --git a/android-ech/src/main/res/xml/network_security_config.xml b/android-ech/src/main/res/xml/network_security_config.xml new file mode 100644 index 0000000..82417d0 --- /dev/null +++ b/android-ech/src/main/res/xml/network_security_config.xml @@ -0,0 +1,3 @@ + + + diff --git a/ech-fixture/src/main/resources/ech-fixture/main.go b/ech-fixture/src/main/resources/ech-fixture/main.go index ea11fbd..3d3c20e 100644 --- a/ech-fixture/src/main/resources/ech-fixture/main.go +++ b/ech-fixture/src/main/resources/ech-fixture/main.go @@ -29,6 +29,8 @@ const ( retryPublicName = "retry.public.test" disabledName = "disabled.secret.test" disabledPublicName = "disabled.public.test" + ctEnforcedName = "ct-enforced.test" + ctOptOutName = "ct-opt-out.test" dohName = "doh.test" ) @@ -64,6 +66,8 @@ func runTarget() { retryPublicName, disabledName, disabledPublicName, + ctEnforcedName, + ctOptOutName, ) dohCertPEM, dohKeyPEM := newLeaf(caCert, caKey, dohName) targetCertificate, err := tls.X509KeyPair(targetCertPEM, targetKeyPEM)