经过加密的 Client Hello (ECH) 是一项 TLS 扩展,用于加密客户端握手消息中的服务器名称指示 (SNI) 字段。在 Android 17(API 级别 37)及更高版本中,默认支持 ECH。ECH 可防止网络中介看到应用连接到的主机名,从而有助于确保用户的网络流量不会泄露。
面向应用开发者的说明
如需在应用中采用 ECH,请执行以下操作:
- 检查您的网络库是否支持 ECH:确保您使用的是支持 Android 上 ECH 的库版本:
- OkHttp:从 OkHttp 5.5.0 开始,您可以通过在
OkHttpClient.Builder上配置AndroidDns或DnsOverHttps来启用 ECH 支持。如需了解详情,请参阅变更日志。 - HttpEngine:Android 17 QPR2(API 级别 37.2)即将支持此功能。 无需进行特殊配置。
- WebView:未来版本将添加支持。
- OkHttp:从 OkHttp 5.5.0 开始,您可以通过在
- 配置网络安全配置:默认情况下,如果您的库支持 ECH,则会为所有网域启用 ECH。如果您需要停用或强制执行 ECH,请在网络安全配置中配置
domainEncryption元素。 - 更新目标 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 记录:
- 使用
InetAddress.getAllByName解析默认网络的 A/AAAA 记录或Network.getAllByName。 - 使用
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 重试,请捕获异常并执行以下步骤:
- 对异常调用
EchConfigMismatchException.getPublicHostname。 - 使用
HostnameVerifier验证返回的公共主机名。 如果为null,则中止连接。 - 如果主机名验证成功,请使用
EchConfigMismatchException.getRetryConfigList检查更新后的配置。 - 如果有更新后的配置,请使用新的
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,尤其是为何需要针对公开名称进行身份验证。