技术文章

为什么验证器拒绝 PAdES 签名:Delphi 中的 PDFium 分析

在我们使用 Delphi 中的 PDFium 组件进行调试的几乎所有案例中,验证器拒绝 PAdES 签名的原因都不外乎以下三个之一: /ByteRange 数组仍持有其零占位符、 /M 签署时间不是格式良好的 PDF 日期字符串,或者增量更新从加密文档中丢弃了 /Encrypt 条目;这三者产生的文件都能正常打开、正常渲染,但在合规的验证器读取签名字典的瞬间就会失败

激发本文写作的场景令人痛苦地具体;您使用关于 PAdES B-B 签名的文章中的工作流签署了一份合同,您自有的检查代码报告签名存在且结构完好,每个本地测试都亮起绿灯 —— 接着对方将文件上传到他们的验证平台却得到了红叉;查看器中看不到任何有关失败的痕迹,因为这三个缺陷都不涉及页面内容;它们完全生活在签名字典和文件 Trailer 中,这正是验证器关注而查看器通常不看的地方

为什么我的 PDF 签名 ByteRange 无效?

ByteRange 被拒几乎总是意味着这四个字段从未被填入,而不是范围存在微妙的错误; /ByteRange [A B C D] 数组声明了两个跨度(从字节 AA+B 以及从字节 CC+D),且 EN 319 142-1 §6.3(要求 k)要求它们加起来必须覆盖除十六进制编码的 /Contents 字符串之外的整个文件;在数字上: A 为 0, C >= A+BC+D 等于文件长度,并且 BC 之间的空白恰好容纳 <...> 十六进制字符串 —— 即两个括号字节,加上每字节 CMS 数据两个十六进制字符;读取到 [0 0 0 0] 的验证器会断定该签名没有覆盖任何内容并予以拒绝,而不管 /Contents 内部的 CMS 结构是多么正确

发生这种情况的机制值得理解,因为每个签名栈中都存在相同的模式;签署者在文件布局完成前无法知道最终的偏移量,因此写入器会使用固定宽度的零占位符发出数组,并在布局后对其进行回填;在 2.14.1 版本之前的 PDFium 组件中,该回填从 /Contents 位置开始搜索占位符模式 —— 但 /ByteRange 位于字典中的 /Contents 之前,因此搜索一无所获,所有三个替换都静默失败;CMS 摘要是在正确的范围内计算的,因此密码学是完好的;但这些范围的声明保持为零,导致每个合规的验证器都拒绝了该文件;使用相同字典布局的 PAdES B-LTA 文档时间戳也以相同的方式失败 —— 如果您基于长期 B-LT 和 B-LTA 签名进行构建,这一点是相关的;v2.14.1 版本开始从签名对象的起点进行回填,并且该修复已由解析生成文件并断言如下算术的回归测试所覆盖:

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

二十行的普通 RTL 代码,没有库调用,并且它在生产端捕获了整个失效类;如果您的测试套件只保留本文中的一个断言,请保留这一个:解析您自有的已签名输出,并在文件离开您的进程之前检查这三个等式

为什么验证器会将 /M 签署时间标记为畸形?

严格的验证器会拒绝不完整 PDF 日期字符串的签署时间,最常遗失的两个部分是 D: 前缀以及 UTC 偏移量标记;ISO 32000-1 §7.9.4 将日期格式定义为 D:YYYYMMDDHHmmSS 紧跟偏移量 —— Z 表示 UTC,或带符号的 +HH'mm' 与其的关系;一个裸露的 20260709143000 在宽容的读取器看来解析为日期,但是字面应用该语法的验证器会在强制格式字段中看到畸形字符串并对签名做出标记;在 2.14.4 版本之前的 PDFium 组件中, TPdf.SignPadesSignPadesBytes/M 条目完全是以裸露形式写入的,自 2.14.4 起携带了 D: 前缀和 Z 标记,因此声明的签署时间读取为 D:20260709143000Z

两个实际注意点适用于此字段;首先,写入 UTC 并如此声明:没有偏移量标记的时间戳会迫使验证器猜测时区关系,并且 §7.9.4 将偏移量视为格式的一部分,而不是可选的细节;其次,记住 /M 是什么 —— 签署者声明的时间,不过是一个声称而不是证明;验证器在此检查的是其格式,而不是其真实性,可证明的时间在 PAdES B-T 及以上级别中源自 RFC 3161 时间戳;正确格式化字段和信任它是不同的决定,而验证器只强制执行前者

为什么 SignPades 会在加密的 PDF 上引发 EPadesCrypto?

PDFium 组件拒绝为加密的文档签名,因为另外的选择是生成一个合规读取器在打开时会破坏的文件;PAdES 签名作为增量更新附加,并且 ISO 32000-1 §7.5.6 要求更新部分的全新 Trailer 携带前一个 Trailer 的每个条目(除了 /Prev) —— 在加密的文档中这包括 /Encrypt;丢弃它,最新的 Trailer 就会声明该文件未加密,导致合规的读取器将加密的主体解析为纯文本并得到垃圾;更糟糕的是,签名者附加的流和字符串本身必须使用文档密钥进行加密才能合法,而纯文本签名者无法做到这一点;由于无法向加密文件附加有效的纯文本签名,因此自 2.14.2 版本起, TPdf.SignPadesSignPadesBytesInjectPadesDssMarkers 会引发 EPadesCrypto,而不是发出损坏或不可验证的结果:

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

异常是正确的结果,因此请围绕它设计工作流,而不是捕获并重试;首先以所有者权限解密,对纯文本副本进行签名,如果分发渠道需要加密,请接受“先加密后签名”和“先签名后加密”会产生具有不同验证故事的不同工件;在纯文本字节上计算的签名无法在这些字节重新加密后存活,因此诚实的选择是已签名的纯文本文件或在代码旁记录政策决定

两个 bug 如何互相印证

ByteRange 缺陷之所以隐藏了这么久,是因为我们自有的验证器在互补的方面出错了,这也是本文中最具借鉴意义的教训; ValidatePadesCompliance 中的覆盖率检查要求第二个跨度精确地从 A+B 开始 —— 零间隙 —— 这错误分类了间隙中容纳有 /Contents 十六进制字符串的标准布局;因此,内部验证器拒绝了合规的布局,而内部生成器从未产生过这种布局,导致端到端的“签名后验证”流水线恰好保持绿色;每个 bug 都剥夺了测试套件本可以揭露另一个 bug 的反例;v2.14.1 版本在同一版本中修复了这两侧:生成器回填了所有四个字段,并且验证器接受 C >= A+BC+D 等于文件长度

方法上的修复是针对非您编写的实现进行交叉验证;共享代码库、作者甚至仅仅是对格式的心智模型的生成器和验证器,会无限期地达成共享的误解,独立的验证器则打破了这种对称;在发布前将您的签名输出运行通过至少一个外部合规性检查器,并在构建中保留结构化的自检作为快速的第一防线 —— 在签名检查文章中描述的 TPdf.ValidatePades 会将覆盖率失败报告为命名的问题:

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

已签署输出的被拒核对清单

当平台拒绝您的 PAdES 签名时,在怀疑证书或信任链之前,请先检查廉价的结构性原因;读取 /ByteRange 数组并验证三个等式:首字段为零、第二个跨度从第一个跨度结束处或之后开始、第二个跨度精确地在文件结束处结束;读取 /M 条目并验证它是否为带有 D: 前缀和偏移量标记的完整 §7.9.4 日期字符串;确认在追加签名时源文档并未加密 —— 如果您的栈仍在没有任何怨言的情况下对其进行了签名,请将该沉默视为栈中的 bug;所有这三个检查都在毫秒内对原始字节运行,在我们的经验中,它们比任何密码学原因都能更频繁地解释拒绝原因

这里使用的签名、检查和验证调用 —— SignPadesValidatePades 以及带有校正后 ByteRange 算术、日期格式化和加密门控的 PAdES 合规性检查 —— 随适用于 Delphi、C++Builder 和 Lazarus 的 PDFium Component 一起提供