Encrypted Client Hello

ECH encrypts the ClientHello — including the SNI and the ALPN list — under a public key the server publishes in DNS. Testing it takes a whole chain rather than a client: a resolver answering HTTPS records, a server holding the keys, and on Android, an emulator new enough to apply a config list. That is more infrastructure than a normal build can carry, which is why it is here.

Where ECH works today

One row per server, one column per way of reaching it. The suites make the same requests and differ only in what does the TLS, so the columns are readable against each other: the gap between OkHttp as shipped and OkHttp + the missing call is one call to Conscrypt.setEchConfigList, and nothing else.

Loading public-server results…

A failed result here is a finding, not a broken build. Every case calls a server someone else operates, so these suites record their results rather than gating — see the status page for the run they came from, and test servers for who runs what.

Why it lives here

A ClientHello is only meaningfully encrypted if every link holds: the resolver returns an HTTPS record, the record carries an ech parameter, the TLS stack offers that configuration, and the server accepts it. A test that stubs any of them stops testing the thing that actually breaks. So the suite here runs the resolver and the origin for real, in containers, with the keys deliberately in and out of sync — and the public deployments below stay in the picture as the interoperability check a fixture can't be.

ECH also has failure modes that only appear against a live server: a stale configuration earns a retry config from the server, which the client may only use after authenticating the public name against the certificate from the outer handshake, and a server offering no usable retry config securely disables ECH for that attempt. Both paths are tested against servers that deliberately serve stale and mismatched configurations.

What runs today

The android-ech suite tests ECH end to end against fixture containers rather than against the internet, so a result is about the client rather than about somebody else's uptime. Two containers stand behind it, both built from one small Go program in ech-fixture: an origin that holds the ECH keys and reports whether the handshake it accepted used ECH, and a DoH resolver answering HTTPS records carrying that origin's config list. Three hostnames give the three outcomes:

HostnamePublished configExpected outcome
green.secret.test The config the origin holds Accepted on the first handshake.
retry.secret.test Stale; the origin offers a retry config Rejected, then retried with the origin's config and accepted — still using ECH.
disabled.secret.test Stale; the origin offers nothing Falls back to a handshake without ECH rather than failing.

Results against the fixture test servers

Every Android platform in the workflow is shown. Older releases deliberately exercise the no-ECH path; API 37 exercises acceptance, retry config, and fallback.

Loading fixture results…

ECH is applied on Android only — JVM platforms accept the config list and ignore it — so these are instrumentation tests on an API 37 emulator, where android.net.ssl.EchConfigList arrived. The device has no Docker, so the containers run on the host and the device reaches them over adb reverse. The suite tests 5.5.0-SNAPSHOT rather than the pinned release because it needs DnsOverHttps.Builder.includeServiceMetadata, and no release has it: 5.4.0 resolves A and AAAA records only, so there is no HTTPS record to carry a config list at all.

The same emulator also runs PublicEncryptedClientHelloTest, which is the JVM suite's cases against tls-ech.dev, defo.ie and cloudflare-ech.com. The fixture suite says whether the client is right; this one makes the JVM and Android rows of the status page comparable, since they are then the same assertions against the same servers with only the TLS stack differing. It reports rather than gates, for the reason everything calling those servers does.

The GREASE case

The failure most likely to reach a real user is not ECH breaking. A client that supports ECH sends the encrypted_client_hello extension even with no configuration for the name, so that a handshake using ECH and one not using it look alike on the wire — and a middlebox that objects to the extension then breaks handshakes from clients that were never trying to use ECH at all.

EchGreaseTest asks the unglamorous half: an ordinary client, nothing configured, fetching from each public server that speaks ECH. It runs on every platform rather than only where ECH works — the JVM cannot do ECH today, and that must not stop it reaching servers that can. Each case also requires the server to say ECH was not used (sni=plaintext, SSL_ECH_STATUS: not attempted, "You are not using ECH"), so a run where ECH quietly started working fails rather than passing while measuring something else.

The local half is the offer itself: test-server's /tls reports the ClientHello's extension IDs in order, names GREASE values rather than leaving them as hex, and calls out 0xfe0d on its own. Whether OkHttp offered it is recorded and never asserted — today's JVM offers nothing, and pinning that would turn the feature arriving into a failed test.

All ECH test cases

This is the complete result set, including fixture and public-server connections, GREASE compatibility, DNS hand-off through Conscrypt, and the local ClientHello byte and extension checks. Each column is one concrete OkHttp version, TLS mechanism, and JDK or Android API level.

Loading ECH test cases…

What it would take on the JVM

network:echTest runs the same cases against the public servers on a JVM, and its route assertions pass while its assertions about what the server saw fail. That is a true result and a slightly misleading one: it reads as "OkHttp cannot do ECH on the JVM", where the accurate version is that three separate things are in the way and only one of them is OkHttp's.

WhatWhereStatus
A TLS stack that can encrypt a client hello Conscrypt google3-export Exists, unpublished. Built and cached by this repository.
Handing the config list to that stack OkHttp ConscryptPlatform.configureTlsExtensions Takes an echConfigList and ignores it. One call, next to the ALPN one.
Reading a server's retry config back Conscrypt OpenJDK Platform Discards the retry configs and the public name. A Conscrypt change, not an OkHttp one.

network:echConscryptTest supplies the first and leaves the rest to OkHttp, so the gap between it and echTest is the second row and nothing else. Under it, EchClientHelloTest reads the client hello off a local socket that answers nothing and asserts the name is not in the bytes — no DNS, no server, no network — which makes it the one ECH result that is never about somebody else's uptime.

The third row is why stale.tls-ech.dev has no JVM counterpart. On Android a rejected config arrives as an EchConfigMismatchException carrying the config the server offered instead; on OpenJDK the same path throws the retry configs away, and nothing public exposes them. A stale config can be detected there and not recovered from, whatever OkHttp does.

Specifications

DocumentTitleWhy it matters here
RFC 9849 TLS Encrypted Client Hello ECH itself. Standards Track, March 2026, from draft-ietf-tls-esni. Retry behaviour is §6.1.6; authenticating the public name before using a retry config is §6.1.7.
RFC 9848 Bootstrapping TLS Encrypted ClientHello with DNS Service Bindings The ech SvcParam: how the configuration reaches the client. OkHttp's Dns.Record exposes it as echConfigList.
RFC 9460 Service Binding and Parameter Specification via the DNS (SVCB and HTTPS RRs) The record type that carries the ECH configuration, the ALPN list and the address hints.
RFC 9180 Hybrid Public Key Encryption (HPKE) The construction ECH encrypts the inner ClientHello with.
RFC 8446 The Transport Layer Security (TLS) Protocol Version 1.3 ECH is a TLS 1.3 extension. A connection that negotiates TLS 1.2 cannot carry it — a case the testbed asserts rather than assumes.
RFC 8484 DNS Queries over HTTPS (DoH) How the testbed fetches HTTPS records portably, via DnsOverHttps with includeServiceMetadata(true).
RFC 8744 Issues and Requirements for SNI Encryption in TLS The problem statement ECH answers, and the attacks a naive design fails against.

Public test sites

The fixture covers the protocol; these cover interoperability with real deployments, which a fixture can't. They are servers operated by others, which is why a red result against one is not automatically an OkHttp bug: a site can rotate a key, go down, or change its response text. OkHttp's own Android suite drives them, and they are the first place to check by hand when a fixture result and the real world disagree.

EndpointOperatorWhat it exercisesSuccess looks like
cloudflare-ech.com/cdn-cgi/trace Cloudflare The straightforward path: HTTPS record with ech, offered and accepted. sni=encrypted
crypto.cloudflare.com/cdn-cgi/trace Cloudflare The same trace endpoint on a host where ECH is disabled by policy — the client offers, the server declines. sni=plaintext
tls-ech.dev tls-ech.dev Baseline acceptance, with a page that names the server it reached. You are using ECH
stale.tls-ech.dev tls-ech.dev Deliberately stale configuration: the server rejects and returns a retry config, and the connection must succeed on the second route with ECH still in use. Two routes, both with an ECH config, page reports ECH
wrong.tls-ech.dev tls-ech.dev Certificate and hostname handling on the inner name; the page redirects to a port nothing listens on, so the redirect must not be followed blindly. 302 with the inner hostname verified
tls12.tls-ech.dev tls-ech.dev TLS 1.2 only. ECH cannot be carried, so the client must fall back cleanly rather than fail. Second route without ECH, page reports no ECH
defo.ie/ech-check.php DEfO (OpenSSL ECH) A second, independent implementation — an OpenSSL-based server rather than Cloudflare's stack. SSL_ECH_STATUS: success

Reading a result

An ECH failure has three plausible causes, and they are worth separating before filing anything upstream:

The android-ech suite has its own workflow and its own daily schedule: it boots an emulator, so it is slower and more failure-prone than the container jobs, and its colour should not mask theirs. Both feed the status page, keyed by the OkHttp version each was testing.