采用加密客户端 Hello (ECH)

经过加密的 Client Hello (ECH) 是一种 TLS 扩展,用于加密客户端握手消息中的服务器名称指示 (SNI) 字段。在 Android 17(API 级别 37)及更高版本中,默认支持 ECH。ECH 可防止网络中介看到应用连接到的主机名,从而帮助用户保护网络流量的隐私。

面向应用开发者

如需在应用中采用 ECH,请执行以下操作:

  1. 检查您的网络库是否支持 ECH:确保您使用的是支持 ECH 的 库版本。OkHttp 和 HttpEngine 即将提供支持。
  2. 配置网络安全配置:默认情况下,如果您的库支持 ECH,则会为所有 网域启用 ECH。如果您需要停用或强制执行 ECH, 请在您的 网络安全配置中配置 domainEncryption 元素。
  3. 更新目标 SDK 级别:ECH 仅适用于 Android 17(API 级别 37)及更高版本。

面向库开发者

如果您要开发自定义 HTTP 网络库或扩展现有库,则应通过与平台 API 交互来实现 ECH 支持。

检查网域加密政策

在查询 ECH 配置或发起连接之前,请通过调用 NetworkSecurityPolicy.getDomainEncryptionMode检查应用的 网域加密政策

根据返回的模式,按如下方式处理 ECH:

  • DOMAIN_ENCRYPTION_MODE_DISABLEDDOMAIN_ENCRYPTION_MODE_UNKNOWN:不提取 ECH 配置 也不尝试 ECH。
  • DOMAIN_ENCRYPTION_MODE_ENABLEDDOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC:强制执行 ECH。检索 ECH 配置,并在服务器支持 ECH 时使用 ECH。如果服务器不支持 ECH,请启用 ECH GREASE。

检索 ECH 配置

如需使用 ECH 进行连接,您必须解析包含 ECH 配置的服务器 HTTPS DNS 记录。当应用使用系统 DNS 时,可以使用以下两种方法之一检索此数据:

方法 1:使用高级 DnsResolver.query API

如果您的库不需要自定义 DNS 解析机制,则可以使用 平台的高级 DnsResolver.query API。此 API 会并行 查询 A/AAAA/HTTPS 记录,并将结果合并到 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 */ }
    });

方法 2:使用 getAllByNameDnsResolver.rawQuery

对于管理自己的套接字连接和 DNS 解析流水线的库,您可能更喜欢使用标准 API 解析 IP 地址,同时单独提取 HTTPS 记录:

  1. 使用 InetAddress.getAllByName 解析 默认网络的 A/AAAA 记录,或使用 Network.getAllByName 解析。
  2. 使用 DnsResolver.rawQuery并行检索原始 HTTPS 记录。将 DnsResolver.TYPE_HTTPS 指定为 查询类型。
开发者责任和极端情况

如果您选择方法 2,则您的库需要承担额外的责任,并需要考虑极端情况。

  • DNS 记录解析:您必须解析来自 DNS 响应的原始字节载荷,以提取 rawQueryEchConfigList
  • 处理记录不匹配:您必须处理 A/AAAA 和 HTTPS 查询之间的不一致情况。
  • 竞态条件:您必须同步并行 DNS 查找的结果。如果一个查询在另一个查询之前解析,或者 HTTPS 查询超时,您必须适当地回退(例如,如果 HTTPS 查询失败,则尝试不使用 ECH 的标准 TLS 连接;如果政策启用了 ECH GREASE,则使用 ECH GREASE)。

配置 TLS

库从 HttpsRecord 检索 ECH 配置列表 (EchConfigList) 后,请在开始 TLS 握手之前使用 SSLSocketsSSLEngines 实用程序 API 传入此列表。

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();
}

处理重试流程

如果服务器的 ECH 配置不同步,握手将失败并显示 EchConfigMismatchExceptionjavax.net.ssl.SSLException 的子类)。服务器可能会在其拒绝中包含更新后的 ECH 配置,这些配置应用于建立新连接。如果服务器提供了有效的重试配置,但未尝试重试,则库必须向调用应用报告错误。

如需处理 ECH 重试,请捕获异常并执行以下步骤:

  1. EchConfigMismatchException.getPublicHostname调用 异常。
  2. 使用 HostnameVerifier. 验证返回的公共主机名。如果为 null,则中止连接。
  3. 如果主机名验证成功,请检查更新后的配置 使用 EchConfigMismatchException.getRetryConfigList
  4. 如果有更新后的配置,请使用 新的 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
    }
}

如需详细了解重试流程,请参阅 RFC 9849,特别是了解 为何需要对公共名称进行身份验证