采用加密客户端 Hello (ECH)

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

面向应用开发者的说明

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

  1. 检查您的网络库是否支持 ECH:确保您使用的是支持 Android 上 ECH 的库版本:
    • OkHttp:从 OkHttp 5.5.0 开始,您可以通过在 OkHttpClient.Builder 上配置 AndroidDns 或 DnsOverHttps 来启用 ECH 支持。如需了解详情,请参阅变更日志。
    • HttpEngine:Android 17 QPR2(API 级别 37.2)即将支持此功能。 无需进行特殊配置。
    • WebView:未来版本将添加支持。
  2. 配置网络安全配置:默认情况下,如果您的库支持 ECH,则会为所有网域启用 ECH。如果您需要停用或强制执行 ECH,请在网络安全配置中配置 domainEncryption 元素。
  3. 更新目标 SDK 级别:ECH 仅适用于 Android 17(API 级别 37)及更高版本。

面向库开发者

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

检查网域加密政策

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

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

  • DOMAIN_ENCRYPTION_MODE_DISABLED 和 DOMAIN_ENCRYPTION_MODE_UNKNOWN:不提取 ECH 配置或尝试 ECH。
  • DOMAIN_ENCRYPTION_MODE_ENABLED 和 DOMAIN_ENCRYPTION_MODE_OPPORTUNISTIC:强制执行 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:使用 getAllByName 和 DnsResolver.rawQuery

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

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

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

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

配置 TLS

一旦库从 HttpsRecord 中检索到 ECH 配置列表 (EchConfigList),请在开始 TLS 握手之前,使用 SSLSockets 或 SSLEngines 实用程序 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 配置已不同步,握手会失败并显示 EchConfigMismatchException(javax.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,尤其是为何需要针对公开名称进行身份验证。