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" 证书存储区里去解析,取出匹配的证书及其私钥,并把签好名的副本写到你指定的路径
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 用于输出,于是同一个缓冲区兼任两个角色,无需在树上再走第二遍
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 的增量签名所要求的那样
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 开箱提供的只读签名检视并行