经过加密的 Client Hello (ECH) 是一种 TLS 扩展,用于加密客户端握手消息中的服务器名称指示 (SNI) 字段。在 Android 17(API 级别 37)及更高版本中,默认支持 ECH。ECH 可防止网络中介看到应用连接到的主机名,从而帮助用户保护网络流量的隐私。
面向应用开发者
如需在应用中采用 ECH,请执行以下操作:
- 检查您的网络库是否支持 ECH:确保您使用的是支持 ECH 的 库版本。OkHttp 和 HttpEngine 即将提供支持。
- 配置网络安全配置:默认情况下,如果您的库支持 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,请启用 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 记录解析:您必须解析来自 DNS
响应的原始字节载荷,以提取
rawQueryEchConfigList。 - 处理记录不匹配:您必须处理 A/AAAA 和 HTTPS 查询之间的不一致情况。
- 竞态条件:您必须同步并行 DNS 查找的结果。如果一个查询在另一个查询之前解析,或者 HTTPS 查询超时,您必须适当地回退(例如,如果 HTTPS 查询失败,则尝试不使用 ECH 的标准 TLS 连接;如果政策启用了 ECH GREASE,则使用 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,特别是了解 为何需要对公共名称进行身份验证。