技术文章

在 Delphi 中用 PDFium 做 PAdES B-B 签名

PDFium Component 通过它的 SignPades 方法给 PDF 加上 PAdES B-B 数字签名:它加载文档、对被签名的字节范围做哈希、构建一个 CAdES CMS 结构,并把签名作为一次增量更新追加进去。加密后端只支持 Windows,所以在签名之前,要用 PadesCryptoAvailable 守住每一次调用

这个场景很熟悉。一份合同 PDF 落到你桌上,法务要求在发出去之前给它做数字签名,于是你伸手去拿那个你早就用来渲染和检视文档的 PDFium 构建,却发现 PDFium 根本写不了签名。它的签名 API 是严格只读的。PDFium Component 用 Pascal 把整条签名流水线接管下来,从哈希函数一直到字节级注入,填上了这道缺口,而本文会把这条流水线从头走到尾

PDFium 为什么写不了数字签名?

PDFium 把签名暴露为只读对象,也不提供任何创建签名的东西。FPDFSignatureObj_* 这一族让你枚举一个已有的签名、读它的 /Contents、检视它的 /ByteRange,但没有任何对应物去构建签名字典、预留 /Contents 槽位或写入字节范围;增量保存是有的(带 FPDF_INCREMENTAL 的 FPDF_SaveAsCopy),却不带任何签名钩子。因此,任何在 PDFium 之上给 PDF 签名的组件都必须自己生成签名的每一个字节,这就是为什么 PDFium Component 用三个纯 Pascal 单元把这套机制搭了起来。FPC 3.2.2 提供 md5 和 sha1,却完全没有 SHA-2,而 Delphi 的 System.Hash SHA-256 API 与 FPC 并不源码兼容,所以 FPdfSha256 是一份自足的 FIPS 180-4 实现,让每一条 CMS 代码路径都统一在一个 TSHA256Digest 类型上,无需编译器分支。FPdfAsn1 提供 CMS 结构所需的 DER 编码器和读取器,而 FPdfCms 在两者之上组装出 CAdES SignedData

在 Delphi 中怎么给 PDF 做数字签名?

加载文档,然后带上一个证书指纹调用 SignPades。PDFium Component 会拿那个指纹到当前用户的 "MY" 证书存储区里去解析,取出匹配的证书及其私钥,并把签好名的副本写到你指定的路径

示意图:Delphi 中的 PAdES B-B 签名流水线——PDFium Component 探测 Windows CNG 后端、对被签名的字节范围做哈希、构建 CAdES CMS,并追加一次增量更新
PDFium Component 接管整条 PAdES B-B 流水线,从平台探测经 SHA-256 哈希和 CMS 构建,直到增量追加
uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // 要签名的文档
    Pdf.Active := True;
    // 第二个参数:当前用户 "MY" 存储区中某张证书的 SHA-1 指纹。
    // 第一个参数:签好名的副本要写到哪里
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailable 是你每一次都要先查的那个探测。在 Windows 上它返回 True,crypt32/ncrypt 后端是活的;在任何其他平台上它返回 False,而一次签名调用会抛出 EPadesCrypto。把这道守卫当作强制的,能让 Linux 或 macOS 构建不至于在一条那里根本走不通的路径上于运行期失败。指纹本身是证书的 SHA-1 哈希,和 Windows 证书管理器"详细信息"页上显示的是同一个值,它指名一个具体的签名者,同时完全不用把密钥材料放进你的源码

CMS 里装了什么:签名属性与 RFC 5652

一个 PAdES 基线签名并不是对文件做的一次裸 RSA 签名;它是一个 CAdES CMS SignedData 结构,携带着一组被强制要求的签名属性,而 FPdfCms.BuildSignedData 发出的正是这一组:content-type、message-digest,以及 signing-certificate-v2,也就是那个用哈希把签名与签名者证书绑定起来的 ESS 属性。这里有一个细节能击垮几乎每一份手写的 CMS 实现。RFC 5652 §5.4 要求签名属性的摘要要在 DER SET OF 编码上计算,标签为 0x31,而同样这些属性在 SignerInfo 内部却是在 IMPLICIT [0] 标签 0xA0 之下传输的。PDFium Component 把属性集合编码一次,对 0x31 形式做摘要,然后只把开头那个标签字节改写成 0xA0 用于输出,于是同一个缓冲区兼任两个角色,无需在树上再走第二遍

示意图:Delphi 中 PDFium Component 构建的 PAdES CMS 里签名属性的标签处理——对 0x31 的 SET OF 形式做摘要,随后只把开头一个字节变成 SignerInfo 内部的 0xA0
RFC 5652 §5.4 对标签为 0x31 的 SET OF 编码做摘要,而同样这些属性字节在 SignerInfo 内部是在 IMPLICIT [0] 标签 0xA0 之下传输的
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // MY 存储区里的签名者
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // /Contents 的十六进制宽度

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

带选项的重载补上了 ISO 32000-1 §12.8.1 定义的签名字典元数据:Reason、Location、ContactInfo 和 Name,全都是可选的,也全都会写进签名值字典。有一条约束很容易绊倒人。如果你设置了 CommitmentTypeOid 来添加一个 CAdES commitment-type-indication 签名属性,就别再设 Reason;ETSI EN 319 142-1 §6.3 禁止两者同时携带,因为这两样是用不同手段表达同一个意图

ByteRange 与 /Contents 槽位是怎么配合的?

一个签名必须覆盖整个文件,除了装着签名本身的那些字节,而 PAdES 用一个定宽占位符解开这个循环,SignPadesBytes 精确地管理着它。它预留一段 ContentsSize 字节的 /Contents 十六进制字符串(默认 16384,从容地大于一个典型的 CMS SignedData),把增量更新序列化出来以定位那个槽位的确切偏移,然后把 /ByteRange 算成夹住这个槽位的两段:十六进制字符串起始定界符之前的一切,以及它结束定界符之后的一切。SHA-256 只在这两段上跑。做好的 CMS 被十六进制编码填进预留的槽位、按定宽补零,随后追加交叉引用更新。因为宽度事先就固定了,填充槽位不会挪动后面任何一个字节,而这正是字节范围保持有效的全部原因;原始文档字节被逐字保留,所以同一文件上更早的签名完好无损地活了下来,恰如 ISO 32000-1 §12.8.1 的增量签名所要求的那样

示意图:Delphi 中用 PDFium Component 签名的 PDF 的 ByteRange 布局——两段参与哈希的区间夹着预留的十六进制 Contents 槽位和追加的交叉引用更新
两段 ByteRange 区间夹住那个定宽的十六进制槽位,所以填入签名绝不会挪动摘要已经算过的任何一个字节

Windows CNG 后端及其边界

PDFium Component 只在 Windows 上签名,而这条边界是有意划的。FPdfCryptoWin 动态绑定 crypt32.dll 和 ncrypt.dll,不引入任何编译期 DLL 依赖,而签名链条是标准的 CNG:打开 MY 存储区、按哈希找到证书、通过 CryptAcquireCertificatePrivateKey 取得它的私钥句柄,再调用 NCryptSignHash。带 PKCS#1 v1.5 的 RSA、RSA-PSS 和 ECDSA 都受支持。ECDSA 需要一处别的算法不需要的修补,因为 NCryptSignHash 返回的是原始的 IEEE P1363 r 与 s 值对,而 CMS 期待的是一个 DER 的 ECDSA-Sig-Value SEQUENCE,所以后端按 RFC 5480 把它重新编码

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // 这个平台上没有签名后端

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

实际后果是私钥必须住在 Windows 证书存储区里。一张放在 PFX 文件里的证书,只有在你把它导入当前用户存储区之后才能用,而到那时它的指纹就是你传给 SignPades 的那个值。本版本没有 PKCS#11 或 HSM 路径,也没有软件密钥文件后端,所以当 PadesCryptoAvailable 返回 False 时,那台机器上就根本没法签名

PAdES B-B 到哪里为止

PAdES B-B 是基线,是四个 PAdES 级别的地板:它证明谁签的,以及此后字节没有变过,仅此而已。一个 B-B 签名不携带可信时间戳,所以它证明不了签名发生在何时,它也不嵌入吊销数据,所以多年以后的验证方必须自己去取证书链和它的状态。这些缺口正是更高级别所要填上的。当你需要一个审计员会接受的签名时间时,加上 RFC 3161 时间戳与用于长期验证的 DSS会把签名推到 B-T 及以上;当你想把一个做好的签名读回来、确认它达到了哪个级别时,检视 PDF 数字签名及其 PAdES 级别是配套的工具;而在签任何东西之前,审计一份 PDF 的安全风险会告诉你自己正要在什么上面署名

这里展示的 SignPades 方法随面向 Delphi 与 C++Builder 的 PDFium Component 一同发布,与 PDFium 开箱提供的只读签名检视并行