加密客户端问候(ECH)
加密客户端问候(RFC 9849)填补了 TLS 握手中最后一个大的明文泄露点:服务器名称指示(SNI)。没有它,每次 TLS 1.3 连接都会以明文形式宣告您正在访问的主机名,任何路径上的观察者都能读取。ECH 将真实的 ClientHello——包括主机名及所有内容——包裹在一个外层 ClientHello 中,后者仅指明一个共享的、公开的“掩护”主机名。
ssl_ech_file 和 $ssl_ech_status / $ssl_ech_outer_server_name 变量在标准 nginx 包、nginx-mod 和 edge 中均可用——所有 GetPageSpeed 构建均链接到 openssl35(携带 ECH 反向移植的 OpenSSL 3.5 LTS)。无需对 NGINX 进行修补;这些指令来自 NGINX 本身,并在 TLS 库支持 ECH 时启用。
在构建之前先试用
ech-test.getpagespeed.com 在公共 443 端口上运行这一完全相同的技术栈,密钥由下面文档中所述的同一个 nginx-mod-ech 包每日轮换。它会报告您自己的浏览器协商了什么,因此您可以在修改自己的配置之前,区分客户端 DNS 问题与服务器端问题。
ECH 真的对您有帮助吗?
在部署之前,请诚实地面对威胁模型。
ECH 隐藏的是您在共享同一服务器的名称中请求了哪一个。它不隐藏您进行了连接这一事实,也不隐藏 IP 地址。
- 多租户托管、CDN、共享前端——真实且显著的收益。观察者只能得知您访问了一台托管数千个站点的服务器,仅此而已。
- 专用 IP 上的单个站点——收益甚微。仅凭地址就能识别站点,反向 DNS 或证书透明度查询即可完成识别。ECH 仍然移除了 SNI 字符串,这在对抗粗粒度的关键字匹配审查时有一定价值,但不要过度吹嘘。
ECH 的隐私性来自公开名称背后的匿名集大小。仅被一个站点使用的掩护名称保护不了任何东西。
配置
server {
listen 443 ssl;
listen 443 quic;
http2 on;
http3 on;
server_name secret.example.com;
ssl_certificate /etc/pki/tls/certs/secret.example.com.crt;
ssl_certificate_key /etc/pki/tls/private/secret.example.com.key;
ssl_protocols TLSv1.3;
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem;
}
在存在 ssl_ech_file 之前,ECH 处于非活动状态,因此添加该包不会改变现有部署的任何行为。
ssl_ech_file 在 http 和 server 上下文中均有效。将其放在 http 级别会将其应用于所有继承它的 TLS 服务器,这通常正是您想要的——而且对纯 HTTP 服务器无害,因为 NGINX 只为具有证书的服务器读取 ECH 密钥。
多个密钥,以及为什么顺序很重要
ssl_ech_file 可以重复。顺序并非无关紧要:
ssl_ech_file /etc/nginx/ech/ech-20260825T000000Z.pem; # 对外宣告
ssl_ech_file /etc/nginx/ech/ech-20260824T000000Z.pem; # 仅解密
ssl_ech_file /etc/nginx/ech/ech-20260823T000000Z.pem; # 仅解密
NGINX 在 ECH 重试配置中仅宣告第一个文件。之后的每个文件仅加载用于解密。这正是密钥轮换安全的原因:客户端从缓存的 HTTPS 记录中获取了较旧的 ECHConfigList,并使用较旧的密钥加密,而该密钥必须仍然在存储中,否则握手将失败。
密钥在 NGINX 解析其配置时被读取,因此新生成的密钥在 nginx -s reload 之前不会生效。
公开名称需要证书
嵌入 ECHConfig 中的 public_name 是客户端放在外层明文 SNI 中的掩护主机名。选择一个您控制的名称,将其指向同一台服务器,并确保您的证书覆盖该名称。当客户端的 ECH 尝试失败时——密钥过期、记录损坏、中间盒干扰——它会回退到针对该名称的普通握手,而那里的证书错误将是用户会看到的严重故障。
观察它
log_format ech '$remote_addr "$host" ech=$ssl_ech_status '
'outer=$ssl_ech_outer_server_name';
access_log /var/log/nginx/access.log ech;
$ssl_ech_status 报告连接中 ECH 尝试的结果。$ssl_ech_outer_server_name 给出客户端使用的掩护名称,这对于确认客户端确实在使用您的 ECHConfig 而非 GREASE 非常有用。
发布 HTTPS DNS 记录
在客户端能够找到您的 ECHConfigList 之前,ECH 毫无价值。它携带在 HTTPS 资源记录 的 ech= 参数中:
secret.example.com. 300 IN HTTPS 1 . alpn="h3,h2" ech="AD7+DQA65wAg..."
nginx-ech-keygen --print-dns 为当前宣告的密钥打印精确的 ech="..." 值。
人们常搞错的两个要求
区域必须是纯 DNS。 如果记录位于代理 CDN 之后——例如 Cloudflare 的橙色云记录——该提供商会终止 TLS 并发布它自己的 HTTPS 记录,携带它自己的 ECH 密钥。客户端获得的是提供商的 ECH,而不是您的;您的 ssl_ech_file 永远不会被使用,因为提供商才是 TLS 端点。这不一定不好(提供商的匿名集非常庞大),但那是他们的,不是您的。要提供您自己的 ECH,记录必须是灰色云 / 纯 DNS。
客户端需要加密 DNS。 通过明文 53 端口获取的 HTTPS 记录会将主机名泄露给 ECH 本要对抗的观察者,因此浏览器仅在记录通过 DoH 或 DoT 到达时才使用 ECH。没有加密 DNS 的用户无法获得 ECH——您在服务器上配置的任何内容都无法改变这一点。
Cloudflare 示例
curl -sS -X POST \
"https://api.cloudflare.com/client/v4/zones/$ZONE_ID/dns_records" \
-H "Authorization: Bearer $CF_API_TOKEN" \
-H "Content-Type: application/json" \
--data @- <<EOF
{
"type": "HTTPS",
"name": "secret.example.com",
"ttl": 300,
"proxied": false,
"data": {
"priority": 1,
"target": ".",
"value": "alpn=\"h3,h2\" $(nginx-ech-keygen --print-dns)"
}
}
EOF
保持 TTL 较短,并舒适地低于您的密钥保留窗口,这样已轮换出去的密钥永远不会是缓存记录唯一指向的密钥。您只需手动创建一次记录——之后,nginx-ech-publish 会在每次轮换时保持其最新(见下文)。
自动密钥轮换
ECH 密钥本应短生命周期。轮换工具随每个构建一起提供:标准 nginx 包使用 nginx-ech,NGINX-MOD 使用 nginx-mod-ech:
dnf install nginx-ech # 标准 nginx
dnf install nginx-mod-ech # NGINX-MOD
它提供 nginx-ech-keygen、一个 nginx-ech-rotate systemd 定时器,以及 /etc/sysconfig/nginx-ech-rotate。在您配置之前,不会运行任何内容。
# 1. 设置掩护主机名。
vi /etc/sysconfig/nginx-ech-rotate # ECH_PUBLIC_NAME="ech.example.com"
# 2. 创建第一个密钥。写入 /etc/nginx/conf.d/ech-keys.conf 并重新加载。
nginx-ech-keygen --init
# 3. 在您的 HTTPS 记录中发布其打印的内容。
nginx-ech-keygen --print-dns
# 4. 让轮换自行重新发布 DNS(Cloudflare 已内置;请参阅
# 下面的“让 DNS 与轮换保持同步”)。
vi /etc/sysconfig/nginx-ech-publish # ECH_PUBLISH_PROVIDER="cloudflare"
# 5. 将轮换交给定时器(默认每日)。
systemctl enable --now nginx-ech-rotate.timer
| 命令 | 效果 |
|---|---|
--init |
首个密钥,生成 include 文件,重新加载 NGINX |
--rotate |
新密钥,淘汰超出 ECH_RETAIN 的任何密钥,重新加载 |
--print-dns |
宣告密钥的 ech="..." 值 |
--list |
当前密钥,标记为宣告 / 仅解密 |
关于 EDGE
相同的工具以 edge-ech 形式提供,并重新命名为 EDGE 自己的路径:edge-ech-keygen、edge-ech-rotate 定时器、/etc/sysconfig/edge-ech-rotate,以及 /etc/edge/ech 下的密钥。上述每个步骤在替换名称后均适用。
ECH_RETAIN(默认 3)控制保留多少代密钥。每日轮换时,这大约为持有缓存 HTTPS 记录的客户端提供三天的宽限期。将其设置为高于您的记录 TTL,而不是低于。
让 DNS 与轮换保持同步
仅轮换会让 DNS 落后:客户端加密所用的 ECHConfigList 来自 HTTPS 记录,而非服务器,因此记录在定时器首次触发时就会过期。不会发生故障——持有过期值的客户端会得到 ECH: failed+retry-configs,仍然通过掩护名称连接,并从重试配置中自我修复。但首次握手永远不会获得 ECH,这会悄悄丢弃大部分收益。
nginx-ech-publish 弥补了这一循环(自 1.30.4-61 起随 nginx-mod-ech 提供,自 1.30.4-6 起随 edge-ech 提供;尚未包含在标准 nginx-ech 包中)。它作为一次性 nginx-ech-rotate.service 的第二个 ExecStart 运行,因此仅在成功轮换后触发:它从 --print-dns 读取宣告值(仅公开的 ECHConfigList,绝不读取私钥)并在 HTTPS 记录中重新发布。它默认处于非活动状态——未配置提供商时直接退出,不做任何操作。编辑一个 sysconfig 文件即可启用:
# /etc/sysconfig/nginx-ech-publish
ECH_PUBLISH_PROVIDER="cloudflare"
ECH_ZONE_ID="..." # 域名概览页面中的区域 ID
ECH_RECORD_NAME="www.example.com" # 客户端访问的内部名称,而非掩护名称
| 变量 | 含义 |
|---|---|
ECH_PUBLISH_PROVIDER |
要运行的提供商插件;为空表示不执行任何操作 |
ECH_ZONE_ID |
提供商的区域标识符 |
ECH_RECORD_NAME |
HTTPS 记录的 FQDN——启用 ECH 的内部名称 |
ECH_ALPN |
与 ech= 一起宣告的 alpn= SvcParam(默认 h3,h2) |
ECH_TTL |
记录 TTL(秒)(默认 300) |
ECH_PUBLISH_CREDENTIALS |
凭据文件(默认 /root/.cloudflare.ini) |
Cloudflare 提供商读取 certbot 的 dns-cloudflare ini 格式(dns_cloudflare_email / dns_cloudflare_api_key),因此会复用现有的 certbot 凭据文件,而不是复制。它就地更新记录,记录查找失败是硬性停止,绝不会被视为“无记录”——因此不会创建重复的 HTTPS 记录。
提供商是 /usr/libexec/nginx-ech-publish/ 下的可执行插件。调度器将 ECH_B64、ECH_ZONE_ID、ECH_RECORD_NAME、ECH_ALPN、ECH_TTL 和 ECH_PUBLISH_CREDENTIALS 导出到提供商的环境中,因此支持 Route 53、deSEC 或任何其他 DNS API 只需将一个脚本放入该目录——无需修改调度器。
对于您在该机制之外自行编写脚本的提供商,传统钩子仍然有效:向 nginx-ech-rotate.service 添加您自己的 ExecStart 插件,读取 nginx-ech-keygen --print-dns,并将该值 PUT 到 HTTPS 记录中。无论哪种方式,轮换都会保留 ECH_RETAIN 代密钥,因此重新加载与 DNS 传播之间的间隙在设计上已被覆盖——之前的密钥仍然加载用于解密。
证书:不要将内部名称放在掩护名称上
掩护名称的证书在外层握手中呈现,而 ECH 本要对抗的观察者可以读取它。如果一份 SAN 列表同时覆盖掩护名称和您要隐藏的名称,那么该证书恰好交还了 ECH 刚刚隐藏的内容。
分别签发:一份证书用于公开名称,每个内部名称各一份。
窗口过短的实际代价
事实证明,并非宕机。我们针对这些包测量了所有三种情况:
客户端缓存的 ECHConfigList |
结果 |
|---|---|
| 宣告的密钥 | ECH 成功,请求路由到内部名称 |
| 保留的仅解密密钥 | ECH 成功,请求路由到内部名称 |
已超出 ECH_RETAIN |
ECH: failed+retry-configs,连接仍然完成 |
在第三种情况下,NGINX 无法解密,因此回退到外层 ClientHello,提供公开名称的服务器块,并返回携带当前密钥的重试配置。客户端获取后自行恢复。代价是一次浪费的往返,而不是页面损坏。
这也是公开名称的证书如此重要的原因:每个过期客户端都会落在它上面。证书配置错误会让轮换从可恢复的重试变成证书错误。
/etc/nginx/conf.d/ech-keys.conf 在每次轮换时生成。不要编辑它;它会被重写以匹配磁盘上的内容。密钥位于 /etc/nginx/ech,权限 0640 root:nginx,目录权限 0750——NGINX 主进程以 root 身份解析配置,因此工作进程永远不需要读取它们。
比每日更频繁的轮换是合理的,也是 ECH 设计者所建议的,但请记住每次轮换都会产生一次 nginx -s reload 的开销。使用插件覆盖,而不是编辑随附的单元文件:
systemctl edit nginx-ech-rotate.timer # [Timer] / OnCalendar=hourly
客户端支持
已针对这些包进行端到端验证,通过 DoH 针对真实的 ech= HTTPS 记录,达到 $ssl_ech_status 成功:
| 客户端 | 结果 |
|---|---|
| Chrome / Chromium | 已协商 ECH |
| Firefox 147 | 已协商 ECH |
openssl35 s_client -ech_config_list |
已协商 ECH |
不支持 ECH 的客户端不受影响:它们发送正常的 ClientHello,NGINX 正常为其提供服务。
您可以针对我们的公共演示主机 ech-test.getpagespeed.com 检查任何客户端——它会报告您的浏览器实际协商了什么,并在 /status.json 以 JSON 形式提供相同数据。
当支持 ECH 的浏览器不使用它时
几乎总是 DNS 问题,而非 TLS 问题。Firefox 特别会在测试设置容易落入的几种情况下拒绝使用 ECH,而每种情况看起来都像是互操作失败,但实际上并非如此。我们的浏览器曾因完全相同的原因报告 GREASE。
- HTTPS 记录不是通过 DoH 获取的。 Firefox 仅使用来自可信递归解析器(TRR)的
ECHConfig。单独启用“DNS over HTTPS”是不够的——必须选择提供商。如果设置了network.trr.mode但network.trr.uri为空,Firefox 会使用原生解析并发送 GREASE。 - 系统代理挡在中间。 Firefox 默认遵循操作系统代理配置,包括 PAC 文件。如果 DoH 通过该代理路由而代理不愿承载,TRR 会静默失败,每次查找都会回退到原生 DNS——或挂起。同一台机器上的
curl和openssl s_client不受影响,这使得这个问题看起来非常像“服务器故障”。 hosts条目覆盖了该名称。 以这种方式解析的名称根本不会获取其ECHConfig。- 地址是 RFC 1918 私有地址。 包含私有地址的 TRR 答案默认被丢弃(
network.trr.allow-rfc1918),作为反 DNS 重绑定措施——这也会连同丢弃携带ech=的 HTTPS 记录。 - 在 macOS 上,必须启用 DoH 才能使用 ECH。
针对指向公共地址的公开可解析名称进行测试,显式选择 DoH 提供商,无代理,无 hosts 条目。about:networking#dns 会显示答案是否来自 TRR。
限制
- 不支持拆分模式。 NGINX 实现的是共享模式服务器,即终止 ECH 的服务器同时服务内部名称。拆分模式(前端解密 ECH 并转发到独立后端)不在上游 NGINX 中。
- ECH 需要 TLS 1.3。 TLS 1.2 连接没有 ECH。
- 需要重新加载 新密钥才能生效。
另请参阅
- HTTP/3 (QUIC) — ECH 与 HTTP/3 天然搭配
- NGINX-MOD — 这些指令同样包含在的增强构建
- RFC 9849 — TLS 加密客户端问候
- RFC 9460 — HTTPS 和 SVCB 资源记录