跳转到主要内容

写死 TLS 参数与握手兼容性

状态,2026-09-17。 Go TLS 默认值修复 48e184652 已随 Server 20260916 发布。 issue #154 的报告人在该版本复测, OIDC discovery 仍然是同一条 connection reset by peer该部署的根因至今未确认。 #154 于 2026-09-11 以「补丁已合并」为由关闭, 而不是以报告人复测通过为准;这个处理是错的。本文记录修复覆盖了什么、覆盖不了什么、以及必须改变什么。

更新,2026-09-17。 上游 Go 有一条 issue 报告了完全相同的症状(另一个产品), 另一条则给出了机制的完整诊断。两者汇总在上游的同款案例。 实际后果是:对受影响的运维,第一个该问的问题是什么设备在检查这条流量, 而不是在 SILO 里设什么。

证据类别。 下文的 Go 行为取自 Go 1.27.1 标准库源码与官方发布说明。 握手字节数与分支复现来自 #154 调查的合成夹具,记录在 Go 1.27 TLS 与 OIDC。 至今没有获得受影响部署的任何取证抓包。

先把本质说清楚

TLS 连接的第一句话叫 ClientHello,客户端在其中列出自己支持的算法。 很多企业网络会在链路中放一台设备——WAF、TLS 检查设备、负载均衡器——去解析这句话。 其中一部分设备在见到不认识的算法编号时不是忽略,而是直接重置连接

Go 1.27 往这句话里新增了三个编号——ML-DSA 后量子签名方案——一共 12 个字节。 用 Go 1.27 重新编译后,SILO 在线上说的话就和 Go 1.26 时不一样了。 报告人 IdP 前面的某个环节不接受它,于是回了 TCP RST。 同一个容器里 curl 却正常,因为 curl 用的是 OpenSSL,不会提供这些编号。

Go 预料到了这类兼容问题,给出了逃生开关 GODEBUG=tlsmlkem=0。 这个开关有一个前提:它只在应用没有自行指定算法清单时生效。 SILO 带着一份从上游继承来的显式曲线清单,而这份清单里恰好有一个后量子条目—— 于是开关对我们失效。2026-09-09 的修复删除了这些显式清单,让开关重新生效。

关键的限制就是全部故事:这个开关能关闭后量子密钥交换(ML-KEM), 关不掉后量子签名(ML-DSA),而多出来的 12 个字节正是 ML-DSA。Go 没有为它提供任何开关。 如果某个部署是被 ML-DSA offer 卡住的,这次修复对它毫无作用——这与复测结果一致。

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 跟进。

签名不在同一条时间表上。 签名不会被追溯伪造——2035 年造出的量子计算机 不会让 2026 年签出的握手失效。所以今天 TLS 1.3 里的 ML-DSA 只是声明支持, 并非实际依赖。Go 1.27 把 MLDSA44MLDSA65MLDSA87 加入默认签名算法列表, 那 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 时将其剔除——如下文所述,这也是应用层对它唯一的杠杆。

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

真正把我们坑到的那处变更范围很窄,而且放在上下文里看是讲得通的。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=0tlssecpmlkem=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 可以在两套之间切换,所有出站路径一个开关都没有。 下次 Go 调整套件默认值时,同样的剧本会再演一遍。

本记录采纳的通用准则:MinVersion,其余交给标准库。 如果确有不兼容的对端需要更窄的配置档位,把它做成产品配置—— 在 mc 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 自身带来的变化

移除写死清单不会恢复此前的线上格式,而是采用当前默认值,而它更大:

版本 实际提供的 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 是否覆盖
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 有两个结论。其一,任何方案都不能寄希望于上游出现开关: 下文需求中的显式兼容档位是必须项,而不是备选项。其二, 对受影响运维最快的诊断问题是什么设备在检查这条流量—— 厂商的一次内容更新可能一步结案,而且那是真正的修复,不是绕过。

全部可选解法

按「谁来动手」分组。每一行只解决特定分支;在拿到定位证据之前,任何一条都是在赌。

入口侧——唯一完整的解法。

做法 分支 代价
更新中间设备——威胁特征库与固件都算——使其容忍不认识的算法编号 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 一条规则改动

今天没有任何办法关掉 ML-DSA。 Go 没有为签名算法提供 GODEBUG—— internal/godebugs/table.go 中 crypto/tls 的条目只有 fips140emstlsmaxrsasizetlsmlkemtlssecpmlkemtlssha1——tls.Config 也没有签名算法字段。 唯一的杠杆是版本门:这些编号只在 TLS 1.3 下定义,把 MaxVersion 压到 TLS 1.2 即可抑制。 而 SILO 没有在任何地方暴露这个入口。这就是缺口。

为什么不回退工具链

用 Go 1.26 重新编译确实能恢复到正常版本的那份握手,上游的报告也确认降级有效。 但它仍然是错误的手段,有五条理由。

  1. 用整个产品的工具链,去替别人的缺陷买单。 而那台设备的厂商已有修复。
  2. 它有保质期。 Go 只维护最近两个版本。Go 1.28 一发布, 1.26 构建就运行在不再收安全修复的标准库上,同一个决定会带着更差的选项回来。
  3. 上游不会回头。 Go 已明确拒绝加开关,Chrome 还准备对同一个扩展做 GREASE。 回退是拖延,不是解决。
  4. 这不是换个编译器那么简单。 Server、Console、mcli 和 silo-pkg 都声明 go 1.27.1;用 Go 1.26.7 编译当前源码会被模块要求直接拒绝, 因此回退是跨四个仓库的协同变更,还要处理已经要求 1.27 的传递依赖。
  5. 它只覆盖一条分支。 如果重置来自分支 C 的 HTTP 层规则,工具链根本不相关。

还有一个相邻的想法同样无效:保持用 1.27 编译,只把 go.modgo 指令降下去。 该机制只管有 GODEBUG 的行为,而 ML-DSA 的广告没有开关—— 这样做只会改掉 macOS 根证书之类的无关默认值,握手一个字节都不会变。

回滚发布镜像是另一件事,而且是合理的。 受影响的运维在等待设备补丁期间 停留在 20260804,是稳妥的应急处置;产品为同样的理由冻住编译器,则不是。

由此确立的需求

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

有两个选项被明确否决。发布一个降低了的默认值——在 go.modgodebug 指令, 或在镜像里塞 ENV GODEBUG=…——会悄悄削弱所有连接的后量子保护, 而且仍然解决不了分支 B。握手被 RST 后自动降级重试 等于把「注入一个 RST 就能把连接压到 TLS 1.2 加经典曲线」交给主动攻击者; 浏览器正是因为这个原因移除了这类 fallback。若将来确要实现, 它只能挂在需求 2 的显式开关之后。

发布与沟通门槛

这次修复的兼容前提确实写了——写在 Server README 的 TLS 小节、 Go 1.27 TLS 与 OIDC, 以及 20260916 发布说明 中。 没有做的是以下三件事,每一件都直接导致了当前结果:

  • 从未在 issue 中告诉报告人:修复需要运维设置 GODEBUG 才会生效,以及它针对的是哪条分支。
  • 该要求从未进入升级检查清单。它被归档为「我们改了什么」的记录,而不是「你必须做什么」的动作。
  • issue 以补丁合并为依据关闭,而不是以报告人复测为准。

根子上的定性错误才是教训:这次改动被当作正确性修复(恢复标准库默认值), 而它同时是一次兼容契约变更——其效果取决于运维的动作。 定性正确的话,它会走发布门禁与用户沟通流程,而不是只走文档流程。

由此确立三条门槛,适用于今后所有同类改动:

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

归属

本文在 #154 调查与九月 Go 1.27 栈评审 的基础上,补充发布后的复测结果、机制分析、解法空间与流程门槛。 复现工件与完整证据链保留在文档树之外。可支持的表述仍然是: 合并后的修复在受影响的 8 个 Server 配置点恢复 Go 密钥交换默认值,并经合成负对照验证。 它不诊断任何特定部署;在拿到阶段级证据的复测之前,#154 不做任何根因断言。