← All posts

我的 JWT 签名密钥的三次生命

Authagonal·July 17, 2026
authjwtkey-managementkmsvaultecdsaoidc

我们认证服务器签发的每一个令牌,都由同一把私钥签名。一旦它丢失,攻击者就能为任意用户铸造出有效的令牌,无需密码,而你的日志里显示的却是一次干干净净的登录。它是整个系统中最有价值的那一个秘密。在它的一生中,它搬过两次家:先是出生在应用进程内部,随后被流放到一座再也无法离开的保险库里,最后彻底转世成了另一种密钥。每一次迁移都发生在一个仍在运行的签发方上——彼时令牌已在流转,验证方也在缓存着我们无法控制的状态。本文讲的就是这些迁移究竟要付出什么代价;而其中让我们学到最多的那一次,恰恰是我们没有对外宣布的那一次。

第一世:服务器揣在兜里的密钥

一开始,密钥就住在它被使用的地方。服务器生成一把 RSA-2048 密钥,把它保存在内存里,并用 RS256 为令牌签名。几乎在任何地方,这都是默认做法;而且它一直都挺好——直到你把托管这个词大声说出口的那一刻。任何能读取进程内存、读取其配置、或读取二者备份的东西,都能触及这把私钥。我们希望能够告诉客户:数据库即便泄露,也暴露不出任何有用的东西——可只要签名密钥就摆在离那个数据库仅一个进程之遥的地方,这句话就是谎言。这把密钥必须离开这栋楼。

第二世:搬进一座出不去的保险库的密钥

我们引入了一个接缝——一个服务器每当需要签名时就会调用的 ISignatureProvider——并在它背后放了一套 KMS。如今这把密钥是在 Vault 的 transit 引擎内部生成的,从不导出。应用并不持有它;它把待签名的字节发给保险库,再取回一个签名。它可以使用这把密钥,却无法把它窃取带走

这个区别正是关键所在。一份内存转储、一个泄露的配置文件、一份外泄的备份——它们里面都不再包含这把密钥了,因为密钥从来就没出现在那些地方。攻陷应用如今只能给攻击者换来一种能力:请求保险库为其签名——而这是我们可以限流、可以审计、也可以吊销的。它再也换不来密钥本身;而密钥本身一旦丢了,我们就束手无策了。

下面是我们没有写进发布说明里的部分。把密钥搬进保险库的那同一个提交,还悄悄把我们的 PBKDF2 迭代次数砍了一半,从 100,000 降到了 50,000。一个 diff,两处方向相反的安全改动:标题写的是“在 KMS 里签名”,而藏在底下搭便车的,却是一个被腰斩的口令哈希工作因子——它就在某一行里,可谁都没盯着那行看,因为这次改动讲的是另一码事。两者都是安全相关的代码,于是两者作为同一次“更安全”的改动被一并评审,靠着好的那一半的分量,一路放行了过去。

修复只需一行。教训却不止一行。一个贴着更安全标签的 diff,终究还是一个 diff;而它里面那些对安全敏感的常量——迭代次数、密钥长度、超时时间、算法名称——并不会因为提交信息就自带光环。如今我们对待一个工作因子的数值,就像对待一次算法选型一样:它是一件需要明确声明、并加以核验的事,而不是因为周围那次改动是个好主意,你就顺带信了它。

第三世:为了更快而变小的密钥

一旦签名被搬到 KMS 背后,每一次签名都成了一次网络往返,而 RSA 签名恰恰属于昂贵的那一类。于是密钥换了物种:基于 RSA-2048 的 RS256,变成了基于 P-256 曲线的 ES256。椭圆曲线签名大约比 RSA 便宜一个数量级,一个 P-256 签名是 64 字节,而 RSA-2048 的签名是 256 字节,验证方要下载的那套公钥也随之缩小。更小的密钥、更小的签名、更小的 JWKS、更快的令牌——这一切都只是源于选了一条更好的曲线。

但麻烦在于:你没法在一个仍在运行的签发方上一把切换签名算法。每一个已经签发出去的令牌都是用 RS256 签的,在它过期之前都必须继续能通过验证。每一个验证方都缓存着你的旧公钥。一步就把算法换掉,你就会在同一瞬间让所有在途的令牌、以及所有被缓存的密钥集统统失效——这是一场披着“升级”外衣、自己捅给自己的宕机。

真正奏效的做法,是不再假装从来只有一把密钥。密钥库变得与算法无关:它同时持有那把 RSA 密钥和那把 EC 密钥,并把两者都发布在 JWKS 里,各自挂在自己的密钥 id 和算法之下。另有一个专门的活动密钥选择器,负责决定由哪把密钥为新令牌签名;只要 EC 密钥一存在,它就会自动把签名工作从那把老旧的 RSA 密钥上换下来——不需要配置开关,也不需要专门卡着某次轮换来安排的部署。在整个重叠期里,discovery 文档会同时公告两把公钥,因此无论用哪一把签的令牌都能继续通过验证。等到最后一个 RS256 令牌过期后,discovery 就只公告 ES256,验证也把 ValidAlgorithms = ES256 钉死——这一手同时也把门重重甩在了那些算法混淆攻击的脸上:那些攻击专门想哄骗验证方去接受错误的算法方案。这场迁移,是签发方对自己所做的一次淡入淡出的交叉过渡,而不是谁去扳的一个开关。

提炼出来的教训

一把签名密钥看起来像个常量:一个秘密,设一次,然后被永远引用下去。我们的这把却不是。在它的一生里,它变过托管方式——从进程搬到 KMS;也变过物种——从 RSA 变成椭圆曲线;而这两次迁移都不得不在令牌仍在流转、验证方仍缓存着我们并不拥有的东西的情况下完成。让每一次迁移都得以幸存的那个特性,两次都是同一个:系统从不假设恰好只有一把密钥、一种算法、或一个家。把密钥库建成能同时容纳好几把密钥、并如实公告它究竟持有哪几把——这样一来,无论是密钥住在哪里的轮换,还是密钥属于哪种类型的轮换,都不再是一场需要你排期的宕机,而变成了签发方自行管理的一种特性。

如果你在运营认证系统,那么为你的令牌签名的那个东西,应该是你所拥有的最无聊、最经得起检视、最不耍小聪明的对象。我们的这把,走了三世才走到这一步。这三世,每一世都值。