一次 ES256 迁移,三种把有效签名弄坏的方式
算法迁移本身很顺利。我的 JWT 签名密钥的三段生命讲的就是那个故事:在线上签发方上从 RS256 切换到 ES256,重叠期内两把密钥都发布在 JWKS 中,还有一个能自动从旧 RSA 密钥上轮换下来的活动密钥选择器。密钥、轮换、发现,这些听起来很难的部分,全都一次成功。
本文讲的是不顺利的那部分。在那次迁移中,我们的认证服务器有三次分别签发出这样的令牌:它的签名在密码学上是有效的,数学没有问题,正确的密钥签了正确的字节,但世界上没有任何验证器会接受它。三个不同的层各自以自己的方式把字节弄坏了,而从外部看,每次失败都一模一样:invalid signature。不是“格式错误”,不是“长度异常”,就只是:无效。
如果你正在迁移途中盯着这个错误,请直接跳到文末的现场排查指南。解码后签名的长度几乎每次都能点出肇事的那一层。
RSA 把我们惯坏了:一个整数,一种编码
有件事在 ECDSA 迁移之前没人会告诉你:RSA 从来没有这个问题,所以你的直觉一条都用不上。
RSA 签名就是一个整数。线路格式就是这个整数本身,大端序,填充到密钥长度,对 RSA-2048 来说是 256 字节,而且写法基本上只有一种。用任何库签名,用任何别的库验证,字节的含义都相同。
ECDSA 签名则是一对整数 (r, s)。一对整数需要一种编码方式,而整个生态最终形成了两种互不兼容的编码:
- ASN.1 DER:由两个
INTEGER组成的SEQUENCE。长度可变(P-256 通常为 70 至 72 字节),始终以字节0x30开头。OpenSSL、X.509 和 TLS 说的都是这种格式。 - 原始 R‖S(IEEE P1363):
r和s各自左填充到恰好 32 字节后拼接。长度始终恰好为 64 字节。这是 JWS 对 ES256 的强制要求(RFC 7518 §3.4)。
两者编码的是同一个签名,都是“正确”的。把验证器不期望的那种交给它,它不会说“容器格式不对”,而是解码出一堆垃圾整数,然后报出它唯一能报的结论:签名验证不通过。
从你的密钥到线路之间的每一层,对自己说哪种格式都有自己的主张。我们中间有三层,而这三层我们接连全弄错了。
第一个坑:KMS 用 DER 回答
我们的私钥存放在 HashiCorp Vault 的 transit 引擎中,从不离开它:应用把待签名的字节发过去,拿回一个签名。当我们把 transit 密钥从 rsa-2048 切换到 ecdsa-p256 时,签名请求里还带着 RSA 时代的遗留参数:signature_algorithm=pkcs1v15。
这是一个 RSA 的填充参数,对 ECDSA 密钥毫无意义。Vault 没有报错。它忽略了这个无关参数,用曲线密钥完成签名,返回 200 和一个完全有效的 ECDSA 签名,只不过用的是它的默认序列化格式,也就是 DER。
于是整条正常路径端到端全绿:请求被接受,签名被返回,openssl dgst -verify 也会认可它。可每个 JWT 库都拒绝了每一个令牌,因为对第三段做 base64url 解码后,它期望得到 64 字节的 R‖S,却发现了以 0x30 开头的 71 字节。
修复只需一个请求参数:Vault 的 transit 签名端点接受 marshaling_algorithm=jws,直接返回原始 R‖S。但请注意这个陷阱的形状:一个与密钥类型不匹配且被静默忽略的参数。请求看起来是错的,实际也是错的,却照样能跑通,只是“跑通”的定义错了。
第二个坑:“直接把整数拼起来”每 128 个令牌就失败一个
如果你的 KMS 不能原生输出 R‖S,你会忍不住自己转换 DER:解析出那两个 INTEGER,拼接起来,完事。这是三个坑里最阴险的一个,因为它是间歇性正确的。
DER 整数采用最小长度编码,且带符号。这意味着:
- 碰巧数值较小的
r(最高字节为零)会编码成 31 字节或更短,因为 DER 会去掉前导零; - 最高位为 1 的
r会多出一个0x00符号字节,编码成 33 字节。
JWS 格式这两种都不要。它要求每个值始终左填充到恰好 32 字节。直接拼接 DER 整数会随机产生 63、64、65 或 66 字节的签名,其中只有 64 字节的那些能通过验证。如果你处理了符号字节却忘了填充,得到的转换器就会在 r 或 s 以零字节开头时失败,两者各是 256 分之一的概率事件,所以大约每 128 个令牌就有一个长度不足。
这样的版本能通过冒烟测试,能通过代码评审,能上线,然后在生产环境里每小时让一次登录失败,而且你看不出任何规律。我们靠让 Vault 来做转换躲开了它(还是 marshaling_algorithm=jws,让拥有签名操作的一方拥有格式),但同样的陷阱在 .NET 里有一个纯本地的变体:ECDsa.SignData/VerifyData 接受一个 DSASignatureFormat 参数,而框架的不同角落默认值各不相同,SignedXml 和证书那一侧的世界默认按 Rfc3279DerSequence 思考,原始的 ECDsa API 则默认 P1363。我们的本地验证路径现在显式写明 DSASignatureFormat.IeeeP1363FixedFieldConcatenation。经过这次迁移,我们不再让任何签名 API 在任何地方使用默认格式。
第三个坑:修复本身在我们脚下换掉了 base64 方言
Vault 会把每个签名包在一个信封里:vault:v1:<encoded-bytes>。在默认的 DER 序列化下,编码部分是标准 base64,我们的客户端也就尽职地对它调用 Convert.FromBase64String。
切换到 marshaling_algorithm=jws 之后,Vault(按它现在遵循的规范来说,很合理地)改用 base64url 编码签名部分:-/_ 字母表,无填充。一个请求标志一次改变了两件事:我们要求的字节格式,以及它送达时的文本编码。
Convert.FromBase64String 需要带填充的标准 base64。64 字节的签名对应 86 个 base64url 字符,不是 4 的倍数,所以解码在每一个令牌上都抛出 FormatException。从某种意义上说,这是三次失败里最仁慈的一次:它至少在出错的那一行大声崩溃了,而不是产出看似有效的字节再在别处悄悄失败。修复方法是改用 base64url 编解码器来解码(在验证回路上还要重新编码)。
数一数这份讽刺:签名终于用上了正确的字节格式,结果文本层又把它弄坏了。三种编码层层叠加:整数对的编码、定长字段的填充,以及字符编码。每一种都能独立把一个有效签名变成一个被拒绝的令牌。
现场排查指南:长度点出真凶
当 JWT 在算法迁移途中验证失败时,对令牌的第三段做 base64url 解码,看看你得到了什么:
| 解码后的签名 | 弄坏它的是哪一层 |
|---|---|
| 恰好 64 字节 | 线路格式没问题,去别处找(kid 不匹配、JWKS 里的密钥不对、alg 锁定) |
70 至 72 字节,首字节为 0x30 |
DER 泄漏了出来:KMS 或加密库在输出 ASN.1 |
| 63 或 65 字节,且只有部分令牌失败 | 手写的 DER→raw 转换没做定长填充 |
| 解码抛异常或得到乱码 | 下面一层存在 base64 与 base64url 的不匹配 |
还有一个能在几秒内把格式问题和密钥问题分开的交叉验证:用讲 DER 的 OpenSSL 验证同一个签名。如果 OpenSSL 说有效而每个 JWT 库都说无效,你遇到的是编码问题。如果两边都说无效,那就是密钥问题。
教训
“密码学上有效”是数学的属性。“可验证”则是数学与消费方之间每一道编码边界的属性,而我们的签名要跨过四道:KMS 的序列化、它的传输信封、我们客户端的解码,以及 JWS 线路格式。其中三道边界各有主张,而且每一道都恰好与邻居分歧了一次。
在测试上的教训是我们真正为之改变流程的那一条:永远不要用自己的代码做验证来证明签名路径没问题。我们的签名路径和验证路径共享上述每一条假设,所以先签后验的回路全部通过,而每个真实消费方都拒绝了我们。最终钉死这三个 bug 的测试会签发一个真实令牌,用平台自带的 JWT 库对照已发布的 JWKS 做验证(用的是别人的假设,而不是我们自己的),然后在此之上再断言一件极其具体的事:解码后的签名恰好是 64 字节。数学会照顾好自己。你要盯住的是字节。