Adopting Encrypted Client Hello (ECH)

Encrypted Client Hello (ECH) is a TLS extension that encrypts the Server Name Indication (SNI) field in the client's handshake message. In Android 17 (API level 37) and higher, ECH is supported by default. ECH helps keep users' web traffic private by preventing network intermediaries from seeing the hostnames an app connects to.

For App Developers

To adopt ECH in your application:

  1. Check your networking library for ECH support: Ensure you are using a library version that supports ECH on Android. Support is coming soon in OkHttp and HttpEngine.
  2. Configure Network Security Config: By default, ECH is enabled for all domains if your library supports it. If you need to disable or enforce ECH, configure the domainEncryption element in your Network Security Config.
  3. Update the target SDK level: ECH is only available on Android 17 (API level 37) and higher.

For Library Developers

If you're developing a custom HTTP networking library or extending an existing one, you should implement ECH support by interacting with the platform APIs.

Check domain encryption policy

Before querying ECH configurations or initiating connections, check the app's domain encryption policy by calling NetworkSecurityPolicy.getDomainEncryptionMode.

Depending on the returned mode, handle ECH as follows:

  • DOMAIN_ENCRYPTION_MODE_DISABLED and DOMAIN_ENCRYPTION_MODE_UNKNOWN: Don't fetch ECH configurations or attempt ECH.
  • DOMAIN_ENCRYPTION_MODE_ENABLED and DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC: Enforce ECH. Retrieve ECH configurations and use ECH if the server supports it. If the server doesn't support ECH, enable ECH GREASE.

Retrieve ECH configurations

To connect with ECH, you must resolve the server's HTTPS DNS record containing the ECH configurations. When apps are using the system DNS, this data can be retrieved using one of two methods:

Method 1: Using the high-level DnsResolver.query API

If your library doesn't require custom DNS resolution mechanisms, you can use the platform's high-level DnsResolver.query API. This API makes parallel queries for the A/AAAA/HTTPS records and combines the results into an HttpsEndpoint.

Kotlin

val resolver = DnsResolver(context, looper)
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    object : DnsResolver.Callback<HttpsEndpoint> {
        override fun onAnswer(answer: HttpsEndpoint, rcode: Int) {
            val record = answer.httpsRecords.firstOrNull() ?: return
            val echConfigList = record.echConfigList ?: return
            establishEchConnection(echConfigList)
        }
        override fun onError(error: DnsResolver.DnsException) { /* Handle error */ }
    })

Java

DnsResolver resolver = new DnsResolver(context, looper);
resolver.query(network, hostname, DnsResolver.TYPE_HTTPS, executor,
    DnsResolver.HTTPS_QUERY_WAIT_AUTO, cancellationSignal,
    new DnsResolver.Callback<HttpsEndpoint>() {
        @Override
        public void onAnswer(HttpsEndpoint answer, int rcode) {
            HttpsRecord record = answer.getHttpsRecords().stream().findFirst().orElse(null);
            if (record == null) return;
            EchConfigList echConfigList = record.getEchConfigList();
            if (echConfigList == null) return;
            establishEchConnection(echConfigList);
        }

        @Override
        public void onError(DnsResolver.DnsException error) { /* Handle error */ }
    });

Method 2: Using getAllByName and DnsResolver.rawQuery

For libraries that manage their own socket connections and DNS resolution pipelines, you may prefer to resolve IP addresses using standard APIs while fetching the HTTPS record separately:

  1. Resolve A/AAAA records using InetAddress.getAllByName for the default network or Network.getAllByName.
  2. Retrieve the raw HTTPS record in parallel using DnsResolver.rawQuery. Specify DnsResolver.TYPE_HTTPS as the query type.
Developer responsibility and edge cases

If you choose Method 2, your library has additional responsibilities and edge cases to consider.

  • DNS Record Parsing: You must parse the raw byte payload of the DNS response from rawQuery to extract the EchConfigList.
  • Handling Record Mismatches: You must handle inconsistencies between the A/AAAA and HTTPS queries.
  • Race Conditions: You must synchronize the results of the parallel DNS lookups. If one query resolves before the other or if the HTTPS query times out, you must fall back appropriately (for example, by attempting a standard TLS connection without ECH if the HTTPS query fails, or using ECH GREASE if it's enabled by policy).

Configure TLS

Once the library has retrieved the ECH configuration list (EchConfigList) from the HttpsRecord, pass this list in using either the SSLSockets or SSLEngines utility APIs before starting the TLS handshake.

Kotlin

fun establishEchConnection(echConfigList: EchConfigList) {
    val socket = sslSocketFactory.createSocket(ipAddress, port) as SSLSocket
    SSLSockets.setEchConfigList(socket, echConfigList)
    socket.startHandshake()
}

Java

public void establishEchConnection(EchConfigList echConfigList)
    throws IOException {
    SSLSocket socket =
        (SSLSocket) sslSocketFactory.createSocket(ipAddress, port);
    SSLSockets.setEchConfigList(socket, echConfigList);
    socket.startHandshake();
}

Handle retry flow

If the server's ECH configurations have become out of sync, the handshake fails with an EchConfigMismatchException (a subclass of javax.net.ssl.SSLException). The server may include updated ECH configurations in its rejection, which should be used to establish a new connection. If a retry is not attempted despite the server providing valid retry configurations, the library must report an error to the calling application.

To handle ECH retries, catch the exception and perform these steps:

  1. Call EchConfigMismatchException.getPublicHostname on the exception.
  2. Verify the returned public hostname using your HostnameVerifier. If it's null, abort the connection.
  3. If hostname verification succeeds, check for updated configurations using EchConfigMismatchException.getRetryConfigList.
  4. If updated configurations are available, retry the connection with the new EchConfigList.

Kotlin

try {
    socket.startHandshake()
} catch (e: EchConfigMismatchException) {
    val publicName = e.publicHostname ?: throw e
    if (hostnameVerifier.verify(publicName, socket.session)) {
        val retryConfigList = e.retryConfigList
        if (retryConfigList != null) {
            retryConnection(retryConfigList)
        }
    } else {
        throw e // Hostname mismatch
    }
}

Java

try {
    socket.startHandshake();
} catch (EchConfigMismatchException e) {
    String publicName = e.getPublicHostname();
    if (publicName == null) {
        throw e;
    }
    if (hostnameVerifier.verify(publicName, socket.getSession())) {
        EchConfigList retryConfigList = e.getRetryConfigList();
        if (retryConfigList != null) {
            retryConnection(retryConfigList);
        }
    } else {
        throw e; // Hostname mismatch
    }
}

See more details about the retry flow in RFC 9849, in particular why it's necessary to authenticate for the public name.