JWT 是一个很小的东西,却影响深远。它是你各个服务之间身份的证明,而对它签名的算法往往是系统中最难更改的部分,因为每一个签发方和每一个验证方都必须同时切换。正因如此,在真正需要之前就了解这个选项的存在是值得的。
sgcWebSockets 2026.10 将 RFC 9964 的后量子 JWT 算法添加到 JWT 客户端和服务器:ML-DSA-44、ML-DSA-65 和 ML-DSA-87。它们作为同一属性的三个新增取值,与 HS、RS 和 ES 并列,而你令牌的其他一切都保持不变。
签发令牌
在头部选择算法,为组件提供一个私钥,然后签名:
oJWT := TsgcHTTP_JWT_Client.Create(nil);
oJWT.JWTOptions.Header.alg := jwtMLDSA65;
oJWT.JWTOptions.Algorithms.MLDSA.PrivateKey.Text := vPrivatePEM;
oJWT.JWTOptions.Payload.iss := 'my-service';
oJWT.JWTOptions.Payload.sub := 'user-1';
vToken := oJWT.Sign;
私钥是一个普通的 PKCS#8 PEM。RFC 9881 定义的三种形式都可以被读取,因此无论密钥以 32 字节种子形式存储、以展开密钥形式存储,还是两者兼有,都可以正常工作。
验证令牌
服务器端的形式相同,只是启用的是公钥和对应的算法族:
oServer := TsgcHTTP_JWT_Server.Create(nil);
oServer.JWTOptions.Algorithms.MLDSA.Enabled := True;
oServer.JWTOptions.Algorithms.MLDSA.PublicKey.Text := vPublicPEM;
if oServer.Validate(vToken, vHeader, vPayload, vError) then
ShowMessage(vPayload);
每个算法族都是单独开关的,因此只签发 ML-DSA 令牌的部署可以关闭其他算法族,而当收到的令牌 alg 被设置为其他值时,会被直接拒绝而不是被检查。
参数集必须匹配
RFC 9964 中有一条规则值得明确指出,因为这是一个原本很容易被忽视的错误:密钥必须属于 alg 所指定的参数集。不匹配的密钥会被拒绝。用错误的密钥签名会引发异常,而用错误的密钥验证则会返回 false 而不是引发异常,因此攻击者无法利用这种差异获取任何信息。
以 JSON Web Keys 形式存在的密钥
RFC 9964 为这些算法注册了一种密钥类型,AKP,库可以对其进行读写,当密钥是发布在 JWKS 端点而不是放在文件里时,这正是你需要的:
uses
sgcHTTP_JWT_MLDSA;
var
vJWK, vPublicPEM, vPrivatePEM: string;
begin
// {"kty":"AKP","alg":"ML-DSA-65","pub":"...","priv":"..."}
vJWK := sgcMLDSA_ExportPrivateJWK(mldsa65, oSeed);
// and back again, as the PEM the components read
sgcMLDSA_ImportJWKAsPEM(vJWK, vPublicPEM, vPrivatePEM);
end;
私有成员是 RFC 所定义的 32 字节种子,读取器对此要求严格:各成员必须是长度恰好正确的规范 base64url 编码,alg 是必需的,并且当存在私钥时,它必须与同一个 JWK 中的公钥相符。任何一项不满足的 JWK 都会被拒绝,而不是被部分加载。
已验证的互操作性
一种签名格式只有在别人也能生成相同字节时才有用。以下两项检查以测试的形式提供,而不仅仅是口头声明:
- RFC 9964 中发布的示例被逐字节复现。签名器采用确定性变体,因此相同的密钥和相同的输入总会得到相同的签名,这使得 RFC 附录中的内容成为可以用断言验证的对象。
- 由独立实现生成的令牌,在此以 Python 库 dilithium-py 为例,能在这里通过验证;而根据 RFC 9881 手工编写的密钥容器,经过往返读写后也能保持不变。
顺带一提:二进制 HMAC 密钥
在同一轮改动中,一个不相关的限制也被消除了。HS 算法此前将密钥作为文本接收,而真正的 HMAC 密钥是随机字节,其中大部分根本不是合法文本。现在有了第二个属性,可以将密钥以 base64url 形式提供,这与 oct JWK 中 k 成员所使用的编码相同:
oJWT.JWTOptions.Algorithms.HS.SecretBase64URL := 'AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ...';
一旦设置了它,无论在客户端还是服务器端,它都会优先于文本密钥。
他人写入的声明(Claims)
如果你接受来自其他签发方的令牌,还有一个修复值得了解。一个 JSON 字符串既可以写成原始 UTF-8,也可以将每个非 ASCII 字符都转义,不同的库会做出不同的选择。现在这两种形式都会解析为相同的文本,因此带重音符号的名字无论由哪个签发方生成都能被正确读取,而以转义形式写出的 alg 头部也会被识别为其所表示的算法。
升级
这三种算法是添加在现有列表末尾的新增项,因此已存储的序号含义保持不变,你目前所做的任何事情都不会受到影响。使用 ML-DSA 签名需要 sgcCrypto 包,如果构建中缺少该包,会以明确的信息拒绝这些令牌,而不是以令人费解的方式失败。
延伸阅读
观看视频
在eSeGeCe 频道上有一个关于此内容的简短视频。
有疑问、反馈,或者需要迁移方面的帮助?与我们联系—你会收到编写这些代码的人的回复。
