跳转到主要内容

写死 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 个字节正来源于此:

// crypto/tls/defaults.go,Go 1.27.1
func defaultSupportedSignatureAlgorithms() []SignatureScheme {
    return []SignatureScheme{
        MLDSA44,        // 0x0904
        MLDSA65,        // 0x0905
        MLDSA87,        // 0x0906
        PSSWithSHA256,
        ECDSAWithP256AndSHA256,
        ...

这些编号只在 TLS 1.3 下定义。isDisabledSignatureAlgorithm 会在配置无法协商到 TLS 1.3 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。

这个开关本来就不管显式清单

暴露 SILO 写死曲线列表的那处变更,在 Go 1.27 发布说明中有明确记载:

Post-quantum hybrid key exchanges can now be explicitly enabled in Config.CurvePreferences even if the tlsmlkem=0 or tlssecpmlkem=0 GODEBUG options are used. Those options were always meant to only apply to the default set used when Config.CurvePreferences is nil.

(后量子混合密钥交换现在可以在 Config.CurvePreferences 中显式启用,即使设置了 tlsmlkem=0 或 tlssecpmlkem=0。这些选项本来就只适用于 Config.CurvePreferences 为 nil 时所用的默认集合。)

标准库自 Go 1.24 起就把两条路径写成二选一:

// CurvePreferences ... If empty, the default will be used.
//
// From Go 1.24, the default includes the [X25519MLKEM768] hybrid
// post-quantum key exchange. To disable it, set CurvePreferences
// explicitly or use the GODEBUG=tlsmlkem=0 environment variable.

代码里就是一个分支:给了显式清单就原样使用,只有为空时才查默认集合,而 GODEBUG 住在默认那条路上。

// crypto/tls/common.go,Go 1.27.1
func (c *Config) supportsCurve(version uint16, x CurveID) bool {
    if c != nil && len(c.CurvePreferences) != 0 {
        if !slices.Contains(c.CurvePreferences, x) { return false }   // 只看你的清单
        ...
    } else {
        if !defaultCurveEnabled(x) { return false }                   // 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 有两个直接结论:

  1. SILO 不能依赖上游新增 GODEBUG 开关:针对受分支 B 影响的部署,需要在 SILO 自身提供显式的出站 TLS 兼容配置档位。
  2. 排查出站链路上的 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 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。但它仍然是错误的手段,有五条理由:

  1. 用整个产品的工具链,去替中间设备的缺陷买单。 而那台设备的厂商已有修复。
  2. 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布,1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
  3. 上游保持新默认值。 Go 已明确拒绝增加开关,Chrome 也计划对同一扩展启用 GREASE。
  4. 四个核心仓库均已要求 Go 1.27。 Server、Console、mcli 和 silo-pkg 均声明 go 1.27.1,使用 Go 1.26.7 编译当前源码会被模块要求拒绝,且传递依赖也已要求 1.27。
  5. 只覆盖单一分支。 如果连接重置源自分支 C 的 HTTP 层规则,工具链版本根本无关。

保持用 Go 1.27 编译而仅下调 go.mod 中的 go 版本指令同样无效:该机制仅控制绑定了 GODEBUG 的行为,而 ML-DSA 声明没有对应的 GODEBUG 开关。

对于正在等待网络设备更新特征库的受影响环境,在过渡期内暂时停留在 20260804 镜像是合理的应急措施。

由此确立的需求

  1. 为 discovery 与 JWKS 抓取加上分阶段诊断。 通过 httptrace 记录连接建立 → 握手开始 → 握手完成(附协商到的版本、套件、group)→ 请求写出 → 首个响应字节,并在返回的错误中标明最后到达的阶段。无需配置、不改协议行为,且可以用一条日志代替抓包来区分 A、B、C 三条分支。
  2. 显式的出站 TLS 兼容档位,至少覆盖身份提供方:经典曲线,以及可选的 TLS 1.2 上限。必须 opt-in、启动时打出醒目警告,并在 mcli admin config 与支持包中可见。MINIO_API_SECURE_CIPHERS 是既有先例。这是分支 B 唯一的进程内解法。
  3. 启动健壮性:为 discovery 与 JWKS 抓取加总期限与 context 取消,并明确就绪检查是否应当反映身份状态。
  4. 受支持的诊断子命令,由调查用探针演化而来:握手分阶段、协商参数,以及 classical / TLS 1.2 / User-Agent 对照,使运维可以直接定位这类故障。
  5. 清理剩余的写死密码套件,至少让出站路径拥有监听器已有的那个开关。

有两个选项被明确否决:

  • 发布一个降低了的默认值——在 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 在补丁合并时即被关闭,未等待报告人复测确认。

当一项修复依赖运维人员显式配置环境变量才能生效时,必须按兼容变更流程执行以下三条门禁:

  1. 任何「效果取决于运维动作」的修复,都要在发布说明和升级检查清单中列为 required action,与协调升级类条目同等待遇。
  2. 外部用户报告的 issue,只有在报告人确认复测通过后才能关闭;补丁合并只改变状态标签,不改变 issue 状态。
  3. 修复无法覆盖所报症状的全部可能分支时,issue 中必须写明:处理的是哪条分支、还剩哪些分支、以及具体需要什么定位证据。

归属

本文在 #154 调查与九月 Go 1.27 栈评审的基础上,补充发布后的复测分析、上游 Go 社区案例、解法空间与流程门槛。Go 行为以 Go 1.27.1 标准库源码与官方发布说明为依据。