技术文章

Delphi 里按 RFC 4055 编码 RSASSA-PSS-params

PDFium Component 3.114.20 修好了三个 PAdES 签名后端——Windows CNG、macOS Keychain 和 PKCS#11——里的 RSASSA-PSS-params 编码。RFC 4055 §3.1 给 RSASSA-PSS-params 的每个字段都指定了显式的上下文相关标签,从 [0] 到 [3];而后端却把 saltLength 当成一个裸的通用 INTEGER 发出去,同时还写出了一个等于默认值的 trailerField。签名字节一直都是对的,描述它们的 AlgorithmIdentifier 不是——而光这一点就足够让校验器拒绝这个签名

最让人恼火的是这个 bug 藏在哪。一份 CMS 签名有两半:密码学运算,以及告诉校验器这次运算是怎么做的那些 ASN.1。第一半对了、第二半错了,结果就是一份任何遵循规范的工具都无法与伪造品区分开的文档。本文只讲第二半:RSASSA-PSS-params 必须怎么打标签、三个后端如何以同一种方式弄错,以及修正后的 DER 用 TDerWriter 的话说长什么样

字节明明正确的 RSASSA-PSS 签名,校验器为什么拒绝?

因为在所有 RSA 方案里,只有 RSASSA-PSS 让校验器无法从签名本身恢复出参数。PKCS#1 v1.5 的填充完全由 OID sha256WithRSAEncryption 决定,所以它的参数就是一个裸 NULL,没什么可弄错的。而 PSS 由三样东西参数化:一个哈希函数、一个自带哈希的掩码生成函数、一个盐长度——RFC 8017 §A.2.3 把这三样全部留空。签名方选它们,AlgorithmIdentifier 携带它们,而校验器必须一模一样地复现它们,EMSA-PSS-VERIFY 才能开始跑

所以当 PDFium Component 用 SHA-256、基于 SHA-256 的 MGF1 和 32 字节盐签名时,这三件事必须活过这边的 DER 编码,也活过另一套实现那边的 DER 解码。校验器解析不了的参数块,会让验证在任何模幂运算发生之前就结束;而校验器解析成另一种样子的参数块更糟,因为 RFC 4055 §3.1 给 saltLength 的默认值是 20。一个跳过不认识字段的解码器会落到那个默认值上,拿 20 字节的盐去对一个用 32 字节算出来的签名跑 EMSA-PSS-VERIFY,然后报「签名无效」,半点没提问题出在元数据而不是密钥上。这两种结局都是 3.114.19 那版编码会产生的,取决于校验器有多严格,而它们谁都没有指到 AlgorithmIdentifier

RFC 4055 §3.1 到底要求 RSASSA-PSS-params 做到什么

RFC 4055 §3.1 把 RSASSA-PSS-params 定义成一个四字段的 SEQUENCE,每个字段带一个显式的上下文相关标签和一个 DEFAULT 值:

// RSASSA-PSS-params ::= SEQUENCE {
//   hashAlgorithm      [0] HashAlgorithm      DEFAULT sha1,
//   maskGenAlgorithm   [1] MaskGenAlgorithm   DEFAULT mgf1SHA1,
//   saltLength         [2] INTEGER            DEFAULT 20,
//   trailerField       [3] TrailerField       DEFAULT trailerFieldBC
// }

DER 里的显式打标签意味着每个字段都被一个构造型的上下文相关 TLV 包起来:[0] 用 A0、[1] 用 A1、[2] 用 A2、[3] 用 A3,里面嵌着值的通用编码。之所以每个字段都打标签,正是因为每个字段都能靠默认值省略。没有标签,解码器就分不清一个只装着一个 AlgorithmIdentifier 的 SEQUENCE 携带的到底是 hashAlgorithm 还是 maskGenAlgorithm——两者都是 SEQUENCE 类型;有了标签,无论哪些邻居在场,标签号都能确定字段是谁。PDFium Component 发出的取值遵循 ETSI TS 119 312 §7 里的配置:SHA-256、基于 SHA-256 的 MGF1、盐长度等于摘要长度;而它们与每个平台签名调用被告知的内容逐一对应:NCryptSignHash 用的 BCRYPT_PSS_PADDING_INFO 里 cbSalt 为 32,PKCS#11 机制用的 CK_RSA_PKCS_PSS_PARAMS 里 sLen 为 32,以及 Security 框架里用 SHA-256 摘要签名的 PSS 算法

PDFium Component 中 RFC 4055 规定的 RSASSA-PSS-params 示意图:hashAlgorithm 的 A0、maskGenAlgorithm 的 A1 和 saltLength 的 A2 都带着显式上下文标签与 DEFAULT 值;ETSI 配置发出 SHA-256、基于 SHA-256 的 MGF1 和 32 字节盐;而 trailerField 的 A3 等于 trailerFieldBC,所以 DER 干脆把它省掉
之所以每个字段都打标签,正是因为每个字段都能靠默认值省略,所以无论编码器省掉哪些邻居,标签号都能认出字段

三个后端如何犯下同一个错误

3.114.19 的编码给前两个字段打了标签,后两个留成裸的,而且在 TWinCmsSigner、TKeychainCmsSigner 和 TPkcs11CmsSigner 里一模一样。这种对称不是巧合:三个类都实现 FPdfCms.pas 里的 ICmsSigner 接口,而它们的 GetSignatureAlgorithmParams 函数体是照同一个模板写的。那个模板长这样:

// 3.114.20 之前:[0] 和 [1] 打了标签,[2] 和 [3] 没有
Result := W.Sequence(Concat4(
  W.ContextSpecific(0, W.AlgId(OID_SHA256), True),
  W.ContextSpecific(1, W.Sequence(ConcatBytes(
    W.OID(OID_MGF1), W.AlgId(OID_SHA256))), True),
  W.IntegerOf(32),      // 这里要求的是 [2] EXPLICIT,却写成了裸 INTEGER
  W.IntegerOf(1)));     // trailerField 等于 DEFAULT,必须不出现

遍历这个 SEQUENCE 的解码器看到 A0,读出哈希算法;看到 A1,读出掩码生成函数;接着撞上 02 01 20。那是一个通用 INTEGER,而 RSASSA-PSS-params 里任何位置都没有不带标签的 INTEGER 成员。严格的解码器到此为止。宽松的那个会跳过这个不认识的元素,始终找不到 A2,把 saltLength 赋成默认的 20,然后又撞上第二个游离的 INTEGER 02 01 01,同样的问题再来一遍。两条路都到不了 32 字节的盐。共享模板在写对的时候很高效,在写错的时候同样高效地错上三遍——这就是修复一次性落在三个单元里、以及此后三个方法体在结构上仍然完全一致的原因。将来再写一个后端,应该从这三个里复制那段代码,而不是重新推导一遍,因为错误恰恰就出在推导那一步

PDFium Component 中 3.114.19 那个 DER bug 的示意图:A0 和 A1 之后,参数在本该是 A2 显式标签的位置撞上了裸的 02 01 20;严格解码器就此止步,宽松的则按默认 20 字节盐去验证,而 3.114.20 把 32 字节盐包进了 A2
RSASSA-PSS-params 里没有不带标签的 INTEGER 成员,所以那些游离字节要么导致解析失败,要么悄悄退回默认盐长度,两条路都到不了签名方的 32

trailerField 为什么是省略,而不是打成 [3] 标签?

因为 X.690 §11.5 规定 DER 编码器不得编码取值等于其 DEFAULT 的组件,而 trailerField 的 DEFAULT 是 trailerFieldBC,也就是整数 1。对旧代码最直观的改法——把裸的 W.IntegerOf(1) 换成 W.ContextSpecific(3, W.IntegerOf(1), True)——产生的块宽松的 BER 解码器会接受,而严格的 DER 解码器有权拒绝。问题不在值上,而在它的存在上。同一条规则也解释了另外三个字段为什么在场:SHA-256 不是默认的 sha1,基于 SHA-256 的 MGF1 不是默认的 mgf1SHA1,32 也不是默认的 20。如果后端当初用 SHA-1 和 20 字节盐签名,RFC 4055 §3.1 会把参数坍缩成一个空 SEQUENCE 30 00,而校验器期待的是那个空 SEQUENCE,不是 NULL。PDFium Component 从不用那些取值签名,所以从不发出这种形状;但正是这种情况会绊住那些以为「没有参数」永远写成 05 00 的人

这就是 DER 与 BER 之别中对签名尤为要紧的那一点。BER 允许编码器写进取默认值的组件;DER 禁止,因为 DER 存在的意义就是让一个值只有唯一一种编码,而对一个存在两种合法编码的结构做的签名,是能被扯皮的签名。CMS signedAttrs 里的一切都因此必须是 DER,而参数块既经由 cmsAlgorithmProtection 属性走在 signedAttrs 里面,也出现在外层的 signatureAlgorithm 里——它没有豁免可言

PDFium Component 在 PSS 修复里体现的 X.690 11.5 规则示意图:等于其 DEFAULT trailerFieldBC 的 trailerField 必须缺席,因为包上 A3 标签正是严格 DER 会拒绝的编码;而 32 的 saltLength 不同于默认的 20,必须以 A2 的形式在场
DER 存在的意义是让一个值只有唯一一种编码;而一个等于默认值的组件早就拥有最短的那种编码了——根本不出现

修正后的 TDerWriter 编码

PDFium Component 现在用 FPdfAsn1.pas 里的 TDerWriter.ContextSpecific 调三次来构建参数,每个非默认字段一次,每次把 Constructed 参数设为 True 以产生显式标签包装,而 trailer 字段一行都没有。下面是 TWinCmsSigner.GetSignatureAlgorithmParams 的函数体,OID 直接写了出来;Keychain 和 PKCS#11 那两个单元用 OID_SHA256、OID_MGF1 和 OID_RSASSA_PSS 表示同样的值:

function TWinCmsSigner.GetSignatureAlgorithmParams: TBytes;
var
  W: TDerWriter;
begin
  if FPaddingScheme = psRsaPss then
  begin
    W := TDerWriter.Create;
    try
      // RFC 4055 3.1 给四个字段都打了标签。saltLength 是 [2];这里写成裸
      // INTEGER 会被当成另一个字段的开头。trailerField 是 [3],DEFAULT 为 1,
      // 而 X.690 11.5 禁止编码等于默认值的取值,
      // 所以它被整个省略
      Result := W.Sequence(Concat3(
        W.ContextSpecific(0, W.AlgId('2.16.840.1.101.3.4.2.1'), True),
        W.ContextSpecific(1, W.Sequence(ConcatBytes(
          W.OID('1.2.840.113549.1.1.8'),          // id-mgf1
          W.AlgId('2.16.840.1.101.3.4.2.1'))), True),
        W.ContextSpecific(2, W.IntegerOf(32), True)));
    finally
      W.Free;
    end;
  end
  else
    Result := nil;   // PKCS#1 v1.5 和 ECDSA:AlgIdWithParams 写入 NULL
end;

周边机制里有两个细节要紧。TDerWriter.AlgId 产生的 AlgorithmIdentifier 参数为 NULL,这正是 RFC 4055 §2.1 让编码器为嵌套的 hashAlgorithm 和 MGF1 内层哈希生成的形式。另外,FPdfCms.pas 里的 CMS 构建器通过 TDerWriter.AlgIdWithParams 把签名 OID 与这些字节配到一起,而该函数在参数为 nil 时会代之以 NULL;这就是 psRsaPkcs1v15 和 psEcdsa 直接返回 nil、也从未受影响的原因,也是 1.2.840.113549.1.1.10(id-RSASSA-PSS)成为这三个里唯一携带真实参数块的签名 OID 的原因。SHA-256 配置下的最终字节是固定的,而且短到可以用眼睛核验:外层一个 30 34 的 SEQUENCE,里面 A0 0F 包着 15 字节的 SHA-256 AlgorithmIdentifier,A1 1C 包着 28 字节的 MGF1 AlgorithmIdentifier(它自己的参数又是同一个 SHA-256 AlgorithmIdentifier),再加 A2 03 02 01 20 表示盐。如果你 dump 出来的 signatureAlgorithm 在参数 SEQUENCE 的顶层看到 02 01 20,而不是在某个 A2 里面,那你看到的就是 3.114.19 的编码

测试套件为什么没抓住一个畸形的 AlgorithmIdentifier?

因为 PAdES 测试是通过一个假签名器驱动 CMS 构建器的,那个假签名器报告 sha256WithRSAEncryption,并且从 GetSignatureAlgorithmParams 返回 nil,于是 PSS 参数块在测试里压根没被构建过。对必须在没有证书存储、没有 Keychain、没有令牌的情况下运行的测试来说,这是合理设计,但它有一个形状精确的盲区:只有真实后端才产出的东西,也只能由真实后端来检验。第二层更有意思。PDFium Component 还会把签名 AlgorithmIdentifier(含参数)放进 RFC 6211 的 cmsAlgorithmProtection 签名属性里,而校验器会拿这份副本与外层 signatureAlgorithm 比对。两份副本出自同一次调用,所以它们完美吻合,每一条内部一致性检查都通过了。这个编码自洽但错误——这类 bug 是拿结构跟自己比对多少次都揭不出来的;同样的教训换一个结构讲在 CMS signedAttrs 与 DER SET OF 排序那篇里:一个 SET 按一种顺序算哈希、按另一种顺序输出,看上去一切正常,直到外面某个校验器重算哈希

真正能抓住这类 bug 的,是一个不由编码器作者写的解码器,对着真实后端的真实输出跑一遍。PDFium Component 里的 Windows 验证路径走的是 CryptoAPI,而不是这个库自己的 reader,正是在那里被拒的一个 PSS 签名把线索引回了参数块。一套实现发给别的实现去读的任何 ASN.1,都值得至少过一次它控制不了的解码器;而结构里的默认值和标签越多,这一趟就越值

这件事在 PSS 的整个故事里处在什么位置

这次修复与 PAdES 签名里 PSS 另外两个可能出错的地方互不相关,把它们分开看能缩短排查。macOS 后端可能发现某个密钥或较老的系统不接受 PSS,于是降级到 PKCS#1 v1.5,而 AlgorithmIdentifier 必须跟着降级——那是能力问题,用 macOS Keychain 身份签 PAdES那篇有讲。PKCS#11 后端可能递给令牌一个 CK_RSA_PKCS_PSS_PARAMS,而令牌因为整数宽度不匹配把它读成了另一种布局——那是 ABI 问题,CK_ULONG 与 PKCS#11 结构对齐陷阱那篇有讲。本文讲的是第三种失败:密钥愿意干、令牌也算出了正确的字节,而描述结果的 DER 不符合 RFC 4055 §3.1

声明使用 PSS 的签名方承担了一项 v1.5 签名方从来没有的义务:用自己的参数描述出一种形式,让另一套实现解码后得到同样的三个值。RFC 4055 §3.1 定标签,X.690 §11.5 定哪些字段允许出现,ETSI TS 119 312 §7 定该选哪些值。PDFium Delphi 组件的三个后端全部以源码交付,所以上面那段 GetSignatureAlgorithmParams 函数体是你可以读、可以 dump、可以拿去跟你自己的校验器比对的代码,而不是只能相信的东西