PDFium 组件通过其 SignPades 方法使用 PAdES B-B 数字签名签署 PDF:它加载文档、对签署的字节范围进行哈希处理、构建 CAdES CMS 结构,并追加签名作为增量更新。密码学后端仅限 Windows,因此在签名之前请使用 PadesCryptoAvailable 保护每次调用
这种情况很常见。一份合同 PDF 落在您的桌上,法务希望在它发出前进行数字签名,而您使用了本已用于渲染和检查文档的相同 PDFium 构建,却发现 PDFium 根本无法写入签名。其签名 API 严格来说是只读的。PDFium 组件通过在 Pascal 中拥有从哈希函数到字节级注入的整个签名管道来弥补这一差距,本文将端到端地介绍该管道
为什么 PDFium 无法写入数字签名?
PDFium 将签名暴露为只读对象,且不提供任何创建签名的功能。FPDFSignatureObj_* 系列允许您枚举现有签名、读取其 /Contents 并检查其 /ByteRange,但没有对应的构建签名字典、预留 /Contents 插槽或写入字节范围的功能;增量保存虽然存在(带有 FPDF_INCREMENTAL 的 FPDF_SaveAsCopy),但并不携带签名挂钩。因此,任何在 PDFium 之上签署 PDF 的组件都必须自行生成每个签名字节,这就是为什么 PDFium 组件通过三个纯 Pascal 单元构建了该机制。FPC 3.2.2 附带 md5 和 sha1 但完全没有 SHA-2,且 Delphi System.Hash SHA-256 API 与 FPC 不源兼容,因此 FPdfSha256 是一个自包含的 FIPS 180-4 implementation,它在单个 TSHA256Digest 类型上保留了每个 CMS 代码路径,没有编译器分支。FPdfAsn1 提供了 CMS 结构所需的 DER 编码器和读取器,而 FPdfCms 则在此两者的基础上组装了 CAdES SignedData
如何在 Delphi 中对 PDF 进行数字签名?
加载文档,然后使用证书指纹调用 SignPades。PDFium 组件在当前用户“MY”证书存储中解析该指纹,拉取匹配的证书及其私钥,并将签名的副本写入您指定的路径
uses
PDFium, FPdfCrypto;
procedure SignContract(const AThumbprint: string);
var
Pdf: TPdf;
begin
if not PadesCryptoAvailable then
raise Exception.Create('PAdES 签名需要 Windows CNG 后端');
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract.pdf'; // 要签署的文档
// 第二个参数:当前用户“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 组件对属性集进行一次编码,对 0x31 形式计算摘要,然后仅将前导标记字节重写为 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 字节(默认为 16384,明显大于典型的 CMS SignedData)的 /Contents 十六进制字符串,序列化增量更新以定位精确的插槽偏移量,然后计算出将插槽夹在中间的两个 /ByteRange 跨度(十六进制字符串左界前的一切和右界后的一切)。SHA-256 仅在这两个跨度上运行。完成的 CMS 被十六进制编码到预留插槽中并以零填充至固定宽度,接着追加交叉引用更新。因为宽度是预先固定的,填充插槽不会移动下游字节,这正是字节范围保持有效的全部原因;原始文档字节被逐字节保留,因此同一文件上的早期签名可原封不动地存续,这完全符合 ISO 32000-1 §12.8.1 增量签名的要求
Windows CNG 后端及其局限
PDFium 组件仅在 Windows 上签名,该边界是刻意设计的。FPdfCryptoWin 动态绑定 crypt32.dll 和 ncrypt.dll 动态,不增加编译时 DLL 依赖,签名链是标准 CNG:打开 MY 存储、通过哈希找到证书、通过 CryptAcquireCertificatePrivateKey 获取其私钥句柄,然后调用 NCryptSignHash。RSA 配合 PKCS#1 v1.5、RSA-PSS 以及 ECDSA 均受支持。ECDSA 需要一个其他算法不需要的修补,因为 NCryptSignHash 返回原始的 IEEE P1363 r-and-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 开箱即用的只读签名检查