技术文章

PDFium Component:signedAttrs 的 DER SET OF 排序

PDFium Component 通过 TDerWriter.SetOfSorted 构建 CMS signedAttrs 块,因为 X.690 11.6 条款要求 SET OF 的成员按各自 DER 编码的升序输出。要是按构建顺序直接拼,你会得到自己的验证器接受、而所有第三方 PAdES 验证器都拒绝的签名

上一句就是这缺陷能躲过完整回归套件的全部原因。签名路径有覆盖、验证路径有覆盖、每个平台上的往返都是绿的,输出却仍然不是合法的 DER。用你的 writer 签名、再用你的 reader 验证的测试,抓不到对编码规则的偏离——因为两半共享同一个偏离

X.690 11.6 条款到底要求什么

SET OF 值的成员必须排序,排序键是每个成员的完整 DER 编码,按无符号八位字节串比较;当一个编码是另一个的前缀时,短的排前面。这是 canonical 约束而不是建议:DER 对一个给定值只承认一种编码,所以未排序的 SET OF 不是同一个值的另一种顺序,它压根就不是 DER 编码。由于 ASN.1 的 tag 在 TLV 里排最前,比较通常在前两个字节内就出结果;而 CMS 属性列表的每个成员都以 30(SEQUENCE tag)开头,于是要等到长度字节才分胜负。因此要求的顺序看起来几乎就是按属性大小排——当你盯着 hex dump 纳闷顺序为什么这么随意时,这点值得记住

PDFium Component 文章的示意图,展示 X.690 11.6 如何对 CMS signedAttrs SET OF 排序:BuildSignedAttrs 按构建顺序追加 content-type、message-digest、signing-certificate-v2 和 cms-algorithm-protection,而合规的 DER 按完整编码 30 18、30 2D、30 2F、30 37 排四个成员
从第一份签名起,构建顺序和编码顺序就对不上,因为四个成员都带着 SEQUENCE tag 30,由长度字节决定比较结果

为什么最小的属性集就已经乱序?

因为从你产出的第一份签名开始,自然的构建顺序就和编码顺序对不上,而且还没涉及任何可选属性。FPdfCms.pas 里的 BuildSignedAttrs 依次追加 content-type、message-digest、signing-certificate-v2、cms-algorithm-protection。在 SHA-256 摘要和 RSA 签名算法下,四个属性 TLV 以这些字节开头

// BuildSignedAttrs 产生的构建顺序
//   content-type              30 18   (24 content octets)
//   message-digest            30 2F   (47)
//   signing-certificate-v2    30 37   (55)
//   cms-algorithm-protection  30 2D   (45)
//
// X.690 11.6 要求的 DER 顺序
//   30 18  content-type
//   30 2D  cms-algorithm-protection
//   30 2F  message-digest
//   30 37  signing-certificate-v2

四个属性里有两个要动。30 2D 的 cms-algorithm-protection 必须排在 30 37 的 signing-certificate-v2 前面,还得排在 30 2F 的 message-digest 前面。所以 PDFium Component 能产出的最小合规 PAdES B-B 属性集,在旧代码下就已经是乱序的——根本不用等谁再往上加 signing-time、commitment-type-indication 或 signature-policy-identifier。从来不存在一个这 bug 不触发的配置

为什么自己的测试全过,外部验证器全挂?

因为两类验证器是在不同的字节上算消息摘要。RFC 5652 5.4 条款说,签名操作的输入是 SignedAttributes 值的 DER 编码——IMPLICIT [0] tag 换成 SET tag 31——而定胜负的那句话是 the DER encoding,不是 the octets you received。经由 CryptVerifyDetachedMessageSignature 到达的 Windows 平台验证器拿收到的字节、换掉开头的 tag、按原样哈希,所以顺序根本进不了它的视野,乱序的 SET OF 算出来的摘要和签名者算的完全一致。而 OpenSSL 和 BouncyCastle——那些广泛部署的欧洲验证器就是建在这两家之上的——先把属性集解析成结构体、再重新编码后才去算摘要。重新编码应用了排序,摘要就和签名时对不上了,于是签名被报告为「损坏」而不是「编码错误」,害你翻遍密钥材料和证书链,去找一个其实住在比较函数里的缺陷

PDFium Component 关于 sorted SET OF bug 背后验证器分叉的示意图:签名者对带 SET tag 31 的 DER 编码做哈希,Windows 的 CryptVerifyDetachedMessageSignature 路径对收到的字节算摘要,顺序无所谓,乱序签名也能过;而 OpenSSL 和 BouncyCastle 会重新编码、应用 X.690 排序,把签名报为损坏
CryptVerifyDetachedMessageSignature 按收到的字节原样算摘要,乱序集合也能通过验证;OpenSSL 和 BouncyCastle 则先重新编码再算,把签名报为损坏而非编码错误

把这件事从这一个 bug 抽象出来看,因为贵的是这部分。只要你的代码两半共享同一处对互操作规范的偏离,同源往返就对此免疫;它只能证明你的 writer 和 reader 互相认可,而这偏偏不是谁在乎的性质。凡是会以线格式离开进程的东西,唯一有意义的回归测试就是拿规范钉住字节,或者让一个外部实现跑你的输出。OpenSSL CMS 验证后端在这里值得再记一功,原因不止可移植性:它是你自家 DER 的一个真正独立的读者

修复,以及它原本就存在的地方

DER writer 一直都有排序版本。FPdfAsn1.pas 里的 TDerWriter 同时暴露 SetOf 和 SetOfSorted:前者把一个预先拼好的内容缓冲包进 SET tag,手里是什么顺序就保什么顺序;后者把成员一个个以 TDerByteArrays 收进来,先归并排序再包。FPdfMac.pas 里的 PDF MAC 代码早就用 SetOfSorted 构建它的认证属性集了,只有 CMS 签名器没有。把成员以数组形式一直保持到包裹调用,是修复的结构性一半——一旦属性被拼进单个缓冲,顺序信息就没了,下游再怎么小心也找不回来。SET OF 只给 SetOf(Content: TBytes) 的 writer API 就是在邀请这个 bug;接受数组的重载,才让正确的事变得可表达

PDFium Component 中 TDerWriter 一对 API 的示意图:SetOf 包一个预先拼好的 TBytes 缓冲,保留收到的成员顺序,产出的不是 DER;SetOfSorted 把成员保持为 TDerByteArrays 数组,按完整编码归并排序后包出合规的 SET OF
属性一旦拼进单个缓冲,顺序信息就没了,所以接受数组的 SetOfSorted 重载才是让编码点上能表达正确行为的东西
// 修复前:成员保持 AddAttr 追加它们的顺序
Result:= W.SetOf(ConcatMany(AttrList));

// 修复后:X.690 11.6 排序在编码点应用
Result:= W.SetOfSorted(AttrList);

SET OF 要排序,SEQUENCE OF 不能排

这是最常被搞混的区别,而且搞反它的危害和忽略它一样大。SEQUENCE OF 是有序集合:顺序是值的一部分,绝不能去排序。SET OF 作为值是无序的,所以 DER 强加一个 canonical 顺序来让编码唯一。给 SEQUENCE OF 排序会毁数据;让 SET OF 保持乱序产出的东西不是 DER

  • SET OF,必须排序:SignerInfo 里的 SignedAttributes 和 UnsignedAttributes、digestAlgorithms 集合、SignedData 里的 certificates 和 crls 集合、signerInfos 集合、每个 Attribute 内部的值集合,以及 X.501 Name 里每个 RelativeDistinguishedName 内的属性集合
  • SEQUENCE OF,绝不排序:构成 Name 的 RDN 列表、SigningCertificateV2 的 certs 字段、扩展列表,以及它们里面的 general-name 列表

RDN 是一个 SET OF,坐在上一层的 SEQUENCE OF 里,所以一个 distinguished name 在两层嵌套内同时包含这两条规则。这个布局也解释了为什么单成员集合那么宽容:只有一个元素的集合天然有序,这就是 BuildSignedAttrs 里用 W.SetOf 构建的属性值集合按原样写就是对的、无需改动的原因。bug 只存在于集合里真正有两个或更多成员的地方,而多值 RDN 少见得让大多数 name 编码器带着同样的错误混上几年

怎么防止它再回来?

对字节断言,别对结论断言。最便宜的持久检查是走一遍自己输出的自审:解析产出的 signedAttrs,用 writer 用的同一套无符号八位字节串比较,把每个成员和前一个比一遍,只要有一对乱序就让构建失败。它跑起来只要几微秒,不需要任何外部库,而且和验证往返不同,它测的正是规范真正声明的那个性质

// 走一遍 SET OF 的成员,确认 X.690 11.6 排序
// Members 是交给 SetOfSorted 的列表,或者重新解析
// 已产出 31 xx / A0 xx 块得到的结果
function SetOfIsSorted(const Members: TDerByteArrays): Boolean;
var
  I: Integer;
begin
  for I:= 1 to Length(Members)- 1 do
    if CompareDerBytes(Members[I- 1], Members[I])> 0 then
      Exit(False);
  Result:= True;
end;

// 签名测试里,别停在“我们的验证器说有效”上
// 钉住签名者哈希的那几个字节
Attrs:= BuildSignedAttrs(W, Digest, CertHash, DigestOid, SigOid, nil, Options);
Assert(Attrs[0]= $31, 'signedAttrs must carry the SET tag when hashed');
Assert(SetOfIsSorted(SplitSetOfMembers(Attrs)), 'signedAttrs not in DER order');

有两条边界值得直说。把 signedAttrs 排好序本身并不会让签名互操作——容器里其他每样东西也得站得住,而验证器拒绝一份听起来没问题的 PAdES 签名的理由,多到能单独开一篇讨论。另外,排序的 SET OF 是你写出来的性质,不是读进来的性质:PDFium Component 仍然得接受 signedAttrs 乱序到达的输入签名,因为现实里就有这种文件,而且 RFC 5652 5.4 条款叫验证器重新编码而不是拒绝。输出严格、输入宽容,照旧。PAdES B-B 走查里描述的 DER writer、CMS builder 和签名路径,都随 PDFium Delphi component 以可读的 Pascal 源码交付——签名库交付源码的意义正在于此:当欧洲某个验证器和你的文档意见不合时,你能直接读到自己代码吐出的确切字节、以及它们违反的确切条款,而不是去提个 ticket 然后等