技术文章

Delphi 中的 PAdES 数字签名

校验一个 PAdES 签名意味着检查三件互相独立的事,而查看器里那个绿色对勾只告诉你第三件。第一,/ByteRange 数组必须覆盖正确的字节:它所指明的各个区段必须能重建出 CMS 摘要当初所取的那份输入,且不留任何已签名字节在区段之外。第二,CMS 内部的证书必须能链到你信任的某个根,并携带 PAdES 所要求的那条已签名的 signing-certificate 属性。第三,如果所声称的档次带时间戳,就必须有一个 RFC 3161 令牌把签名值绑定到证书过期之前的某个时间点上。Acrobat 把这三件事坍缩成一个图标;一致性检查器会把它们分开看待,而生成这些文件的代码也应当如此。losLab PDF Library(PDF Library for Delphi)为你提供其中的签名一侧、时间戳的重新嵌入,以及在你信任某个 ByteRange 之前用来检视它的审计调用

有一处区分几乎绊倒了每一次初次实现 PAdES 的尝试,因此值得在任何代码之前先说清楚。用 /SubFilter /adbe.pkcs7.detached 写出的签名是一个完全站得住脚的 ISO 32000-1 §12.8 签名,Acrobat 会报告它有效。它同时不是 PAdES 签名,因为 ETSI EN 319 142-1 在每个基线档次上都要求 ETSI.CAdES.detached。一个 eIDAS 一致性检查器会拒绝前者、接受后者,哪怕两者的密码学部分完全相同。档次是文档对自身作出的一项声明,而在 PDF Library for Delphi 里把这项声明写对只需一次调用

是什么让一个 PDF 签名成为 PAdES 签名

ETSI EN 319 142-1 在 CMS 格式之上定义了四个逐层堆叠的基线档次。PAdES-B-B 是入口:一个位于 PDF 签名字段中的 CAdES 签名,带 ETSI.CAdES.detached SubFilter 和一条已签名的 signing-certificate 属性。PAdES-B-T 在签名值之上加一个 RFC 3161 时间戳,证明该签名在某个无人能倒填的时间点之前就已存在。PAdES-B-LT 把校验所需的证书、CRL 和 OCSP 响应嵌入一个文档安全存储,使得文件在签发 CA 关停其基础设施之后仍可验证。PAdES-B-LTA 则用一个文档时间戳给这堆东西封顶,在算法逐渐变弱的过程中重新保护已积累的证据

PDF Library for Delphi 把这些概念映射到它的签名过程 API 上。档次标记是 SetSignProcessCustomSubFilter。如果你的策略需要承诺类型指示(来源证明、批准证明,或 ETSI 编号 1 到 6 的其他标识符之一),那就走 SetSignProcessCommitmentType。显式签名策略经 SetSignProcessSignaturePolicy 附加,它接受策略 OID 及其摘要。有一个默认值值得留意:把摘要算法留在自动挡时,库会为 ETSI 和 adbe.pkcs7.detached 签名选择 SHA-256,仅在遗留的 adbe.pkcs7.sha1 路径上退回 SHA-1。即便如此也请显式设定它。审计人员会问你用了哪种哈希,而代码里一个明写的值,比一个要翻手册才能解释的默认值好辩护得多

用 PDF Library for Delphi 构建的 PAdES 基线档次阶梯 B-B、B-T、B-LT 与 B-LTA,展示各档次如何在 ETSI.CAdES.detached 内核之上叠加时间戳、DSS 证据或可续期的文档时间戳
每个 ETSI 基线档次都在同一个 CAdES 内核上再叠一重保证,从已签名属性一路到可续期的文档时间戳

生成基线签名

扁平 API 把签名驱动成一台一次性的状态机:在源文件上开一个过程,配置它,收尾输出到目标文件,再读取结果码。下面这段序列产出一个采用 SHA-256 的 PAdES-B-B 签名。其中最要紧的那一行与签名本身毫无关系。它是那个刻意开大的 /Contents 预留空间,因为一旦日后要给这个签名加时间戳,这就是你唯一改不了的东西

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Sig1');
    Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 2);          // SHA-256
    Pdf.SetSignProcessReserveContentsBytes(SignId, 8192);  // 给日后的时间戳留出空间
    Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

源文件根本打不开时,NewSignProcessFromFile 返回 0。在那之后,GetSignProcessResult 把生产环境里真正会出现的几种失败模式区分开来:4 表示 PDF 口令不对,7 表示 PFX 口令不对,9 表示证书文件里没有私钥,10 表示输出路径不可写,11 表示写入签名字节时失败。把这个数字码连同输入文件名一起记进日志,能把一张含混的支持工单变成一分钟就能下的诊断

加上库不会替你去取的那个 RFC 3161 时间戳

PDF Library for Delphi 不附带 TSA 客户端,这是一条刻意划下的边界,而不是一处缺失。库负责算出时间戳机构需要联署的那个哈希,并在事后把增强过的 CMS 重新嵌回去;中间的 HTTP 交互和 CMS 手术属于调用方。这种分工有一个硬性的技术理由。名义上用来添加未签名属性的那个 Windows CryptoAPI 控制码 CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR,在 PAdES 所用的分离式 SignedData 布局上会以 CRYPT_E_INVALID_INDEX 失败。因此增强后的 CMS 必须出自一个由你自己掌控的 CMS 编码器。没有哪个库能靠一次系统调用悄悄把令牌折进去,凡是声称能做到的,都是在你看不见的地方动了刀

在 Delphi 中为 PAdES 签名添加 RFC 3161 时间戳的流水线,把 PDF Library for Delphi 的哈希与嵌入同调用方的 TSA 请求和 CMS 重编码分开,全部落在预留的 /Contents 空间内
库负责哈希与重新嵌入,你的代码负责取回令牌并完成 CMS 手术,而结果必须落在那 8192 字节的 /Contents 预留空间之内
var
  Pdf: TPDFlib;
  StsId: Integer;
  HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
    Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
    Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
    HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
    // 下面两个调用都属于应用代码:一次发往你的 TSA 的 HTTP POST,
    // 以及一次把令牌作为未签名属性挂上去的 CMS 重编码
    TstDer := RequestTimeStampToken(HashHex);
    TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
    AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
    Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
    Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
    if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
      raise Exception.Create('timestamp embedding failed');
    Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
  finally
    Pdf.Free;
  end;
end;

这里要盯住结果码:12 表示指定的签名字段不存在,11 表示既有 CMS 无法解析,13 表示增强后的 CMS 已经塞不进预留的 /Contents 占位符。真正让人肉痛的是 13,因为唯一的修法是重新签名:一个典型的时间戳令牌连同它的证书链要占 4 到 6 KB,而在 B-B 那一步预留的 8192 字节,存在的意义正是让这一步有地方落脚

校验从 ByteRange 开始,而不是从证书链开始

查看器里的绿色对勾,是针对那台机器的证书存储所作的一次信任判定,而不是关于文件结构的裁决。程序化校验应当从更低的位置起步,从那个被增量更新弄得微妙起来的问题开始:每个签名到底覆盖了哪些字节?本文讨论的每一项增强,无论是第二个签名、一个 DSS 字典还是一个文档时间戳,都是经增量更新到来的,而每一次更新都会在先前签名的 /ByteRange 之外追加字节。这些追加的字节是正当的。校验器仍然要拿文档的修改策略去给它们定性,而承载该策略的按字段 DocMDP 档次可以用 GetSignatureDocMDPLevelByName 读出

在 Delphi 中对已签名 PDF 做字节布局审计:展示 ByteRange 覆盖的区段、被排除在外的 /Contents 字节、落在区间之外的增量更新,以及对照文件大小得出的覆盖结论
两个覆盖区段加上被排除的签名自身字节,才讲出了覆盖范围的真实故事,而追加的更新应当按 DocMDP 策略定性,而不是一味害怕
var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  I: Integer;
  B0, B1, B2, B3, FileSize: Int64;
begin
  FileSize := TFile.GetSize('invoice-bt.pdf');  // 要在 Open 之前:SignDoc 会持有共享锁
  Doc := TPDFlibSignDoc.Create;
  try
    if not Doc.Open('invoice-bt.pdf', '', False) then
      raise Exception.Create('cannot open for audit');
    Names := TStringList.Create;
    try
      Doc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
        if Doc.GetSignatureValueObjNum(Names[I]) > 0 then   // >0 表示确实已签名
        begin
          B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
          B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
          B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
          B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
          if (B0 = 0) and (B2 + B3 = FileSize) then
            Writeln(Names[I], ': covers the file to EOF')
          else
            Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
        end;
    finally
      Names.Free;
    end;
    Doc.Close;
  finally
    Doc.Free;
  end;
end;

这条审计路径上住着两个陷阱。TPDFlibSignDoc.Open 会以独占共享锁持有文件,因此一个还想读取原始文件字节来做 CMS 验证的校验器,必须在为审计打开它之前先把文件读进内存。把顺序反过来,读取就会栽在你自己设下的锁上。第二个陷阱是无声的而非喧哗的:扁平 API 里的对应函数 GetSignProcessByteRange 返回 Integer,而底层偏移量是 Int64,于是超过 2 GB 之后这个扁平调用会一声不吭地截断,这也正是本例改用审计类来取偏移量的原因。还有一处缺席也值得点名。扁平层完全没有 VerifySignature 这样的封装。密码学层面的裁决来自类层的 TPDFlibSignatureVerifier,它返回 vsValidvsInvalidvsUnknown,也可以来自你的合规策略本就信任的某个外部校验器

长期校验:DSS、VRI 与文档时间戳

PAdES-B-LT 之所以存在,是因为吊销基础设施也有寿命。ETSI EN 319 142-1 §5.4.2.2 规定了文档安全存储:一个文档级字典,承载证书、CRL 和 OCSP 响应,还可以通过以各签名 /Contents 哈希为键的 VRI 条目按签名建立索引。PDF Library for Delphi 的流程与时间戳那套设计如出一辙。NewPAdESDSSProcessFromFile 开启过程;AddPAdESDSSCertificateAddPAdESDSSCRLAddPAdESDSSOCSP 接受 DER 二进制块;AddPAdESDSSVRI 把选定的材料绑定到某一个签名上;EndPAdESDSSProcessToFile 把所有内容作为增量更新写出。难的部分仍在你这一侧。取回吊销材料,并判断它是否新鲜到值得嵌入,是调用方的活儿。库保证这些字典在结构上是合规的;它保证不了你的 OCSP 响应方说的是实话

归档终点 B-LTA 添加的是一个文档时间戳:一个类型为 DocTimeStamp 而非 Sig 的独立签名字段,经 SetSignProcessDocTimeStamp 并配一个预留签名长度产出。它并不取代 B-T 那一步得到的签名时间戳。签名时间戳证明的是某一个特定签名何时存在;文档时间戳保护的是整份文件,连同 DSS 证据在内,也正是长期归档在算法变弱时每隔几年就要续期的那个元素。一套成熟的归档档次两者都带。对于早于这些结构的阅读器,TPDFlibSignDoc.EnsurePAdESExtensions 会在文档目录中登记 ESIC 开发者扩展,声明该文件使用了 ETSI 定义的特性

有一种反应值得提前挡掉,因为它看起来像 bug,其实不是。对一份 PAdES 结构完全正确的文件,查看器常常报告「有效性未知」。信任与结构是两条互相独立的轴。查看器只是无法在那台机器上把签名者链到某个受信任的根,这在使用私有 CA 和测试证书时属于家常便饭,而与此同时 ByteRange 审计和 CMS 验证两者都能通过。修法是把根证书正确分发出去,或者在真正目标是取得 eIDAS 合格状态时改为对照欧盟信任清单来评估,而不是去动签名代码

关于审计一侧的视角,也就是跨语料枚举签名字段、导出 ByteRange 布局并批量读取 DocMDP 档次,参见配套文章合规与签名工作台。既要签名又要满足归档策略的文档,属于 Delphi 中的 PDF/A 与 PDF/UA 预检所描述的那套工作流。完整的 API 文档和评估版下载见 losLab PDF Library for Delphi 产品页