写死 TLS 参数与握手兼容性
Go TLS 默认值修复 48e184652 随 Server 20260916 发布后,issue #154 的报告人在该版本复测,OIDC discovery 仍然报同一条 connection reset by peer。与此同时,上游 Go 社区的同类 issue 将完全一致的症状定位到检查 signature_algorithms_cert 扩展并发送重置的 TLS 中间设备(见上游的同款案例)。本文在《Go 1.27 TLS 默认值与 OIDC Discovery 故障形态》的基础上,补充发布后的复测与机制分析、20260916 修复的覆盖边界、受影响部署的可选解法,以及由此确立的发布与沟通门禁。
先把本质说清楚
TLS 连接的第一条消息是 ClientHello,客户端在其中列出自己支持的算法。许多企业网络会在链路中部署中间设备(WAF、TLS 检查防火墙、负载均衡器)解析这条消息。部分设备在遇到不认识的算法编号时会直接重置连接。
Go 1.27 在这条消息中新增了三个编号——ML-DSA 后量子签名方案——共计 12 个字节。使用 Go 1.27 编译后,SILO 发出的 TLS 1.3 ClientHello 比 Go 1.26 多出这 12 个字节。报告人 IdP 前方的中间设备拒绝了该报文并返回 TCP RST。同一容器内的 curl 之所以正常,是因为 curl 使用 OpenSSL,不会发送这些编号。
Go 针对混合密钥交换提供了兼容开关 GODEBUG=tlsmlkem=0,但该开关有一个生效前提:仅当应用未显式指定曲线清单(Config.CurvePreferences == nil)时生效。 SILO 此前保留了一份从上游继承的显式曲线清单,其中恰好包含一个后量子条目,导致 GODEBUG=tlsmlkem=0 失效。48e184652 修复移除了这些硬编码曲线清单,使该开关恢复生效。
需要注意两者的区别:GODEBUG=tlsmlkem=0 只能关闭后量子密钥交换(ML-KEM),无法关闭后量子签名算法声明(ML-DSA),而 Go 1.27 新增的 12 个字节正是 ML-DSA。Go 标准库未提供关闭 ML-DSA 的开关。如果中间设备是因为 ML-DSA 编号而重置连接,仅移除显式曲线清单无法消除这 12 个字节——这与报告人的复测结果完全吻合。
Go 到底在解决什么问题
后量子密钥交换需要提前迁移。 在“现在截获、未来解密”(harvest-now-decrypt-later)威胁模型下,攻击者可以先录制当下的加密流量,待未来出现具备密码学破解能力的量子计算机后再行解密。因此,密钥交换的机密性迁移必须在量子计算机实用化之前完成。NIST 于 2024 年正式发布 ML-KEM(FIPS 203)与 ML-DSA(FIPS 204),当前部署采用混合模式:X25519MLKEM768 同时运行经典 X25519 与 ML-KEM 并合并双方共享密钥,即便 ML-KEM 存在未知弱点,连接安全性也不低于单独的 X25519。主流浏览器、CDN 与 SSH 实现自 2024 年起已默认启用混合密钥交换,Go 在 1.24 中跟进。
握手签名的时间表则不同。 握手签名无法被事后追溯伪造——未来的量子计算机无法令 2026 年已完成的握手失效。因此目前 TLS 1.3 中的 ML-DSA 仅用于在握手中声明客户端支持验证 ML-DSA 证书。Go 1.27 将 MLDSA44、MLDSA65、MLDSA87 加入默认签名算法列表,新增的 12 个字节正来源于此:
这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到 TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。
这个开关本来就不管显式清单
暴露 SILO 写死曲线列表的那处变更,在 Go 1.27 发布说明中有明确记载:
Post-quantum hybrid key exchanges can now be explicitly enabled in
Config.CurvePreferenceseven if thetlsmlkem=0ortlssecpmlkem=0GODEBUG options are used. Those options were always meant to only apply to the default set used whenConfig.CurvePreferencesis nil.(后量子混合密钥交换现在可以在
Config.CurvePreferences中显式启用,即使设置了tlsmlkem=0或tlssecpmlkem=0。这些选项本来就只适用于Config.CurvePreferences为 nil 时所用的默认集合。)
标准库自 Go 1.24 起就把两条路径写成二选一:
代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。
其中的原则是显式配置应当压过环境变量——否则运维改一个环境变量,就能悄悄推翻开发者写在代码里的安全策略。Go 1.26 及更早版本会把显式清单也过滤一遍,因此 SILO 在 Go 1.26 上「开关有效」其实是依赖了一个 Go 认定的错误行为。
此外,Go 把 GODEBUG 的默认值绑定在主模块的 go 指令上,声明旧版本的模块会保持旧行为直到主动上调。SILO 的 go.mod 写的是 go 1.27.1,等于明确声明采用 1.27 的行为。
SILO 的技术债在哪,以及哪些还没还
出问题的清单是 {X25519MLKEM768, CurveP256, X25519, CurveP384, CurveP521},继承自上游,后来被加入了那个后量子条目。写死它带来的是两头不靠:既享受不到标准库默认值的演进,又用不了标准库提供的兼容开关。
48e184652 在全部 8 个 Server TLS 配置点移除该赋值——外部通用 HTTP transport、复制 transport、带客户端证书的云 transport、节点间 transport、两条 grid 链路、etcd,以及入站 S3/Console 监听器——退役 TLSCurveIDs helper,并补充了跨 5 个出站构造函数、2 个对端 TLS 版本、2 种 GODEBUG 设置的线上级回归测试。证书与主机名校验、密码套件策略、代理处理、HTTP/2 选择均未改变,也没有引入任何失败后降级或重试。此前只改 OIDC 的候选补丁被放弃,是因为同一个 transport 还服务身份插件、通知与 Lambda 可达性检查、审计 webhook 和 S3 云分层。
同一类债在下一层依然存在。 密码套件仍然写死:crypto.TLSCiphers() 与 crypto.TLSCiphersBackwardCompatible() 被赋值在出站 transport、LDAP、etcd、grid 链路和入站监听器上。监听器至少还有 MINIO_API_SECURE_CIPHERS 可以在两套之间切换,所有出站路径一个开关都没有。
本设计确立的通用准则:设 MinVersion,其余交给标准库。 如果确有不兼容的对端需要更窄的配置档位,把它做成产品配置——在 mcli admin config 与支持包里可见——而不是写死在源码里。协议僵化是个老问题:TLS 1.3 不得不在线上伪装成 TLS 1.2,GREASE 的存在正是为了逼中间设备容忍自己不认识的编号。
线上真正的差异
在同一个合成 IdP 夹具前测量,默认设置下:
| 构建 | ClientHello | ML-KEM (4588) | ML-DSA 0x0904-6 | User-Agent |
|---|---|---|---|---|
| 20260804,Go 1.26.5 —— 正常 | 1497 B | 有 | 无 | MinIO (…) |
| 20260903,Go 1.27.1 —— 故障 | 1509 B | 有 | 有 | Silo (…) |
| 20260916,Go 1.27.1 —— 已修复 | 1509 B | 有(默认) | 有 | Silo (…) |
20260804 + tlsmlkem=0 |
275 B | 无 | 无 | MinIO (…) |
20260903 + tlsmlkem=0 |
1509 B | 仍然有 | 有 | Silo (…) |
有两点容易被忽略,但至关重要:
- 能正常工作的版本本来就在发 ML-KEM。 除非运维在升级之前就设过
tlsmlkem=0,否则 ML-KEM 不是新旧版本之间的变量,恢复这个开关也就无法单独解释或修复他们的故障。 - 默认设置下,两个版本之间只有两处变化: 三个 ML-DSA 编号,以及改名带来的 HTTP User-Agent 变化(
MinIO (…)→Silo (…))。后者只有在 RST 发生于握手完成之后时才成立。
影响面,以及症状为什么具有误导性
写死的清单覆盖了所有 TLS 路径的两个方向,但后果差别很大:
| 路径 | 入口不兼容时的后果 | 可见度 |
|---|---|---|
| OIDC discovery / JWKS | 身份初始化阻塞,Console 不启动,节点不提供任何服务 | 立刻致命 |
| KMS/KES、LDAP(S) | 加密或目录认证不可用。这两处本来就用默认曲线,GODEBUG 一直有效 | 立刻可见 |
| 跨站复制目标 | 复制静默积压,只能从指标看出来 | 容易漏 |
| 审计 / 通知 webhook、Lambda 检查 | 审计记录丢失、事件不投递 | 容易漏 |
| S3 分层 / 云后端 | 转冷失败,远端对象读不回 | 容易漏 |
| etcd、节点间 grid | 集群内部流量,一般不经过这类设备 | 低 |
| 入站 S3/Console 监听器 | 客户端连不上;由对端的握手决定 | 取决于客户端 |
只有身份路径会阻塞启动,因此它最先被报告。其余几条会安静降级,所以一个部署完全可能已经受影响而无人提单。
哪些部署有暴露面:
| 情形 | 原因 |
|---|---|
| 出站链路上有 SSL 检查 NGFW、SASE、SWG 或 IDS/IPS | 本次报告的场景,也是已确认存在厂商缺陷的那一类 |
| 出口经过会检查 TLS 的企业 VPN | 同一机制,作用于整条出口 |
| 环境按 JA3/JA4 客户端指纹做白名单 | 握手一变指纹就变,每次工具链升级都可能触发,且同样表现为无提示的重置 |
| 端点前面是老式 TLS 终止器或负载均衡器 | 不容忍未知扩展,或对跨分片的 hello 处理有问题 |
| 从用 Go ≤ 1.22 构建的上游 MinIO 版本迁移过来 | 那份握手既没有后量子密钥交换也没有 ML-DSA——约 275 字节对约 1509 字节。这个群体跨度最大,暴露面也最大 |
| 政策上不允许后量子算法的合规环境 | 需要经典档位是出于政策,而非互操作原因 |
出站链路上没有检查设备的部署不受影响,也不应设置下文任何兼容选项。
四个特性叠加,使这个症状在现场几乎无法诊断:身份初始化以随机 0–3 秒间隔重试;discovery 与 JWKS 抓取没有总超时,启动可以无限期挂住;身份离线期间 /minio/health/live 与 /minio/health/ready 都保持 200,Kubernetes 就绪探针会报成功(只有 /minio/health/cluster 返回 503 并携带 X-Minio-Server-Status: iam-offline);而错误文本 read: connection reset by peer 不说明 TLS 握手是否完成。此时从同一镜像跑 curl 还会成功,因为 curl 用的是另一套 TLS 栈并协商了 HTTP/2。
20260916 自身带来的变化
移除写死清单不会恢复此前的线上格式,而是采用当前 Go 默认值,而它更大:
| 版本 | 实际提供的 supported_groups |
|---|---|
| 20260903 及更早(写死) | X25519MLKEM768, X25519, P256, P384, P521 |
| 20260916(Go 默认) | X25519MLKEM768, SecP256r1MLKEM768, SecP384r1MLKEM1024, X25519, P256, P384, P521 |
因此对于什么都不设置的运维,20260916 会在每一个 TLS 端点(入站与出站)多提供两个后量子编号。对绝大多数部署这无害,但一个按 group 编号做白名单的入口,理论上可能在 20260903 正常而在 20260916 失败。GODEBUG=tlssecpmlkem=0 可以只关掉这两个并保留 X25519MLKEM768。而对于确实设置了 tlsmlkem=0 的运维,20260916 产生的握手比此前任何版本都更小更保守——这正是修复的目的。
三种机制,一个决定性事实
调查复现了三种都能产生所报错误文本的机制,而 20260916 修复覆盖其中一种。
| 分支 | 机制 | 20260916 是否覆盖 |
|---|---|---|
| A | 入口拒绝 ML-KEM,且运维在升级前就设过 tlsmlkem=0 |
是——修复恢复的正是这条 |
| B | 入口拒绝 ML-DSA 签名编号 | 否。只有 TLS 1.2 上限能抑制该 offer,而产品没有暴露这个设置 |
| C | HTTP 层规则在握手成功之后拒绝变更后的 Silo User-Agent |
否,任何 TLS 改动都与之无关 |
还有第四项观察解释了 curl 对照为什么具有误导性:discovery transport 禁用 HTTP/2 且不发 ALPN,而 curl 协商了 h2。这个差异在正常版本和故障版本里都存在。
一个事实能把 A/B 与 C 分开,夹具结果能把 B 与 A 分开: RST 是紧跟 ClientHello 到达,还是在 GET 写出之后才到达。在故障网络位置做一次抓包,或者拿到入口在同一秒的拒绝原因,就能定案。
上游的同款案例
分支 B 不是假设。上游 Go 有两条 issue 描述了它:
- golang/go#81199,标题即 “add a GODEBUG to disable advertising ML-DSA signature algorithms in the ClientHello (Go 1.27 regression against TLS-inspecting middleboxes)",2026-08-28 至今 open。所报症状与 #154 完全一致:经过 TLS 检查防火墙的所有 TLS 1.3 连接
read: connection reset by peer,而同一台主机上的curl、OpenSSL 3.6、Node.js 24 正常,同一程序用 Go 1.26 编译正常,设置MaxVersion: tls.VersionTLS12也正常。其中点名的产品是 Palo Alto Prisma Access,PAN-OS 10.2.10-h37;Go 团队的回复指出该产品已有修复版本。 - golang/go#79626 已关闭,但其中有完整诊断:一位网络管理员与本单位防火墙团队一起定位到,Palo Alto 的一条 IDS/IPS 威胁特征——针对 OpenSSL CVE-2020-1967,该 issue 中记为 PA 威胁 ID 58033,最后更新于 2022-07-12——会检查
signature_algorithms_cert扩展,并对其算法列表不符合预期的连接发送重置。Palo Alto 已于 2026-06-23 发布内容更新禁用该特征。
CVE-2020-1967 当年正是通过畸形的 signature_algorithms_cert 触发的空指针解引用;厂商为它写的检测规则对合法的现代握手触发了重置。这也精确解释了那 12 个字节:Go 1.27 把三个 ML-DSA 编号同时加进了 signature_algorithms 和 signature_algorithms_cert,即两个扩展各 3 × 2 字节。
Go 不会提供开关。 安全负责人在 #81199 的立场是:不可能为 ClientHello 的每一处底层变化都加 GODEBUG;以往加开关的是会改变协商结果的变更;而增加一个签名算法本不应该改变任何东西。提出的 tlsmldsa 设置(golang/go#81307)尚未合入,此外 Chrome 预计将开始对 signature_algorithms_cert 做 GREASE,这会让剩余的问题设备更快暴露。
对 SILO 有两个直接结论:
- SILO 不能依赖上游新增
GODEBUG开关:针对受分支 B 影响的部署,需要在 SILO 自身提供显式的出站 TLS 兼容配置档位。 - 排查出站链路上的 TLS 检查设备:更新防火墙厂商的威胁特征库即可在网络侧直接消除根因。
全部可选解法
按「谁来动手」分组,每一行只解决特定分支:
入口侧——唯一完整的解法。
| 做法 | 分支 | 代价 |
|---|---|---|
| 更新中间设备——威胁特征库与固件都算——使其容忍不认识的算法编号 | A、B、C | 需要设备归属方配合。对已确认的产品,内容更新已经存在,因此这是最短路径 |
绕开它:把 MINIO_IDENTITY_OPENID_CONFIG_URL 指向不经过该设备的端点,或使用 split-horizon DNS。discovery 文档中的 issuer 必须保持不变 |
A、B、C | 证书主机名与 issuer 一致性必须成立 |
| 用 sidecar(stunnel、Envoy)自行终止 TLS 并连接 IdP | A、B、C | 多一个组件及其信任链 |
走 HTTPS_PROXY |
无 | CONNECT 隧道原样转发同一个 ClientHello;只有自行终止 TLS 的代理才有意义 |
部署侧——今天就能用。
| 做法 | 分支 | 代价 |
|---|---|---|
GODEBUG=tlsmlkem=0 |
A | 进程级关闭混合密钥交换,含节点间链路与入站监听器;证书校验不受影响 |
GODEBUG=tlssecpmlkem=0 |
20260916 新增的两个 group | 更窄;保留 X25519MLKEM768 |
GODEBUG=fips140=on |
A 与 B | 不推荐。 FIPS 白名单里没有 ML-KEM 与 ML-DSA,但它同时替换密码套件与曲线,并对整个进程排除 Ed25519/X25519 |
| 停留在 20260804 | A、B、C | 放弃后续全部安全修复;仅作应急 |
在入口放行 Silo User-Agent |
C | 一条规则改动 |
目前无法通过 GODEBUG 关掉 ML-DSA。 Go 没有为签名算法提供 GODEBUG——internal/godebugs/table.go 中 crypto/tls 的条目只有 fips140ems、tlsmaxrsasize、tlsmlkem、tlssecpmlkem、tlssha1——tls.Config 也没有签名算法字段。唯一的杠杆是版本门:这些编号只在 TLS 1.3 下定义,把 MaxVersion 压到 TLS 1.2 即可抑制。
为什么不回退工具链
用 Go 1.26 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。但它仍然是错误的手段,有五条理由:
- 用整个产品的工具链,去替中间设备的缺陷买单。 而那台设备的厂商已有修复。
- 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布,1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
- 上游保持新默认值。 Go 已明确拒绝增加开关,Chrome 也计划对同一扩展启用 GREASE。
- 四个核心仓库均已要求 Go 1.27。 Server、Console、
mcli和silo-pkg均声明go 1.27.1,使用 Go 1.26.7 编译当前源码会被模块要求拒绝,且传递依赖也已要求 1.27。 - 只覆盖单一分支。 如果连接重置源自分支 C 的 HTTP 层规则,工具链版本根本无关。
保持用 Go 1.27 编译而仅下调 go.mod 中的 go 版本指令同样无效:该机制仅控制绑定了 GODEBUG 的行为,而 ML-DSA 声明没有对应的 GODEBUG 开关。
对于正在等待网络设备更新特征库的受影响环境,在过渡期内暂时停留在 20260804 镜像是合理的应急措施。
由此确立的需求
- 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过
httptrace记录连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节,并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为,且可以用一条日志代替抓包来区分 A、B、C 三条分支。 - 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。必须 opt-in、启动时打出醒目警告,并在
mcli admin config与支持包中可见。MINIO_API_SECURE_CIPHERS是既有先例。这是分支 B 唯一的进程内解法。 - 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消,并明确就绪检查是否应当反映身份状态。
- 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数,以及 classical / TLS 1.2 / User-Agent 对照,使运维可以直接定位这类故障。
- 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。
有两个选项被明确否决:
- 发布一个降低了的默认值——在
go.mod写godebug指令,或在镜像里塞ENV GODEBUG=…——会悄悄削弱所有连接的后量子保护,而且仍然解决不了分支 B。 - 握手被 RST 后自动降级重试等于把「注入一个 RST 就能把连接压到 TLS 1.2 加经典曲线」交给主动攻击者;浏览器正是因为这个原因移除了这类 fallback。
发布与沟通门槛
这次修复的兼容前提写在 Server README 的 TLS 小节、Go 1.27 TLS 与 OIDC,以及 20260916 发布说明 中。但在 issue 处置中有三件事没有做到位:
- 未在 issue 中直接说明该修复需要配合设置
GODEBUG=tlsmlkem=0才会生效,以及它针对的是哪条故障分支。 - 该配置要求仅作为变更记录写入说明,而未作为受影响环境升级时的显式操作项列入升级检查清单。
- issue 在补丁合并时即被关闭,未等待报告人复测确认。
当一项修复依赖运维人员显式配置环境变量才能生效时,必须按兼容变更流程执行以下三条门禁:
- 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
- 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭;补丁合并只改变状态标签,不改变 issue 状态。
- 修复无法覆盖所报症状的全部可能分支时,issue 中必须写明:处理的是哪条分支、还剩哪些分支、以及具体需要什么定位证据。
归属
本文在 #154 调查与九月 Go 1.27 栈评审的基础上,补充发布后的复测分析、上游 Go 社区案例、解法空间与流程门槛。Go 行为以 Go 1.27.1 标准库源码与官方发布说明为依据。