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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions .github/workflows/android-ech.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,17 @@ 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
# `google_apis` image — and API 21, which predates those, the `default` one.
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"}]}'
) }}

Expand All @@ -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 }}
Expand Down
25 changes: 18 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions android-ech/run-ech-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Throwable>(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<SSLContext, X509TrustManager> {
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"
}
}
10 changes: 10 additions & 0 deletions android-ech/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />

<application
android:networkSecurityConfig="@xml/network_security_config"
tools:targetApi="24" />
</manifest>
16 changes: 16 additions & 0 deletions android-ech/src/main/res/xml-v36/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!--
Android 16 has CT but defaults it off. These two names use the same private fixture CA and the
same leaf certificate. Explicitly enabling CT also forces it for a custom trust anchor; the
opt-out is the control that proves the certificate and connection are otherwise valid.
-->
<domain-config>
<domain includeSubdomains="false">ct-enforced.test</domain>
<certificateTransparency enabled="true" />
</domain-config>
<domain-config>
<domain includeSubdomains="false">ct-opt-out.test</domain>
<certificateTransparency enabled="false" />
</domain-config>
</network-security-config>
13 changes: 13 additions & 0 deletions android-ech/src/main/res/xml-v37/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Android 17 defaults CT on. Leaving it unset for this name is the behavior under test. -->
<domain-config>
<domain includeSubdomains="false">ct-enforced.test</domain>
</domain-config>

<!-- The same certificate must connect when the application uses Android 17's opt-out. -->
<domain-config>
<domain includeSubdomains="false">ct-opt-out.test</domain>
<certificateTransparency enabled="false" />
</domain-config>
</network-security-config>
3 changes: 3 additions & 0 deletions android-ech/src/main/res/xml/network_security_config.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Certificate Transparency was added in API 36. Older devices need a config they can parse. -->
<network-security-config />
4 changes: 4 additions & 0 deletions ech-fixture/src/main/resources/ech-fixture/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -64,6 +66,8 @@ func runTarget() {
retryPublicName,
disabledName,
disabledPublicName,
ctEnforcedName,
ctOptOutName,
)
dohCertPEM, dohKeyPEM := newLeaf(caCert, caKey, dohName)
targetCertificate, err := tls.X509KeyPair(targetCertPEM, targetKeyPEM)
Expand Down
Loading