在查看器为签名 PDF 显示绿色勾选之前,它会做三件机械工作:读取签名字典中的 /ByteRange 数组,精确哈希该数组描述的两个字节区间,然后验证存储在这两个区间之间的 /Contents 项中的 CMS 签名,该签名以十六进制保存。几乎每一次生产签名失败都能追溯到这三步之一安排错误:占位符太小装不下最终签名 blob,哈希计算覆盖了错误字节,或者签名后保存重写了范围已经覆盖的字节。密码学本身几乎不会失败。失败的是字节账目。
HotPDF 为 Delphi 和 C++Builder 应用提供三层签名支持:一键 PFX 签名、面向 HSM 和签名服务的外部签名器工作流,以及带文档时间戳的 PAdES 配置字段。本文按这个顺序介绍,因为每一层都是为处理前一层的某种失败模式而存在的。
ISO 32000-1 §12.8 中的 ByteRange 契约
PDF 签名必须位于它所签署的文件内部,这带来一个先有鸡还是先有蛋的问题:签名值不能覆盖自身。格式用一个洞解决这个问题。写入器预留固定大小、以零填充的 /Contents 项,/ByteRange 记录两个区间,即洞之前的所有内容和洞之后的所有内容。签名器对这两个区间哈希,再把得到的 CMS 结构以十六进制写入这个洞。工程师最容易踩中的后果是:预留大小在签名前就冻结了,所以最终签名,包括证书链在内,必须装进更早选择的洞里。大约 8 KB 可以容纳带短证书链的 detached CMS 签名。
HotPDF 直接暴露了这个差异。AddSignatureField 创建一个空的可见字段,供用户之后在查看器中签名;AddSignedSignatureField 创建字段并为程序化完成预留 /Contents 洞。选错方法是第一周常见错误:空字段不会给外部签名器任何可填充内容。
密钥在 PFX 文件中时的一次调用
当签名证书和私钥位于 PFX/PKCS#12 文件中时,整条管线会收敛成一个类函数:
if THotPDF.SignPDFWithPFX('invoice-unsigned.pdf', 'invoice-signed.pdf',
'company-cert.pfx', 'pfx-password') then
Writeln('Signed: invoice-signed.pdf')
else
raise Exception.Create('PFX signing failed');
这里最常见的支持问题与 PDF 无关,而是 PFX 容器本身。HotPDF 读取使用 PBES2 保护的 PFX 文件,也就是 PBKDF2 密钥派生加 AES-256-CBC。较旧 Windows 证书向导导出的容器,或 3.0 之前 OpenSSL 版本导出的容器,默认使用遗留 RC2/3DES 保护,无法解析。解决办法是用现代参数重新导出一次容器(当前 OpenSSL 默认如此),而不是修改代码。当证书“在别处都可用”但签名一开始就失败时,应先检查这一点。
外部签名:预留、哈希、插入
一键路径假设私钥是进程可读取的文件。生产签名密钥越来越不是这样,它们位于 HSM、USB token 或远程签名服务中,没有库能直接调用它们。针对这种拓扑,HotPDF 把工作流拆成字节级步骤:写出占位文档,计算哈希范围,把哈希输入交给持有密钥的一方,再把返回的 CMS 拼回文件。
var
Doc: THotPDF;
Fs: TFileStream;
PdfBytes, HashInput, SigHex: AnsiString;
R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
// 1. Write the document with a reserved /Contents hole
Doc := THotPDF.Create(nil);
try
Doc.FileName := 'placeholder.pdf';
Doc.BeginDoc;
Doc.CurrentPage.AddSignedSignatureField('Sig1',
Rect(50, 100, 350, 150), 8192, 'adbe.pkcs7.detached',
'Contract approval', 'Boston, MA', 'legal@example.com');
Doc.EndDoc;
finally
Doc.Free;
end;
// 2. Load the saved bytes; the returned offsets are 0-based
Fs := TFileStream.Create('placeholder.pdf', fmOpenRead);
try
SetLength(PdfBytes, Fs.Size);
Fs.ReadBuffer(PdfBytes[1], Fs.Size);
finally
Fs.Free;
end;
THotPDF.PreparePDFForSigning(PdfBytes, R1Start, R1Len, R2Start, R2Len,
CStart, CLen);
// 3. Hash both spans and sign externally (HSM, token, service)
HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
Copy(PdfBytes, R2Start + 1, R2Len);
SigHex := SignWithHsm(HashInput); // your integration: returns CMS as hex
// 4. Splice the signature into the reserved hole
THotPDF.InsertSignatureHex(PdfBytes, SigHex);
Fs := TFileStream.Create('signed.pdf', fmCreate);
try
Fs.WriteBuffer(PdfBytes[1], Length(PdfBytes));
finally
Fs.Free;
end;
end;
这个序列中有两个约束,漏掉时会导致间歇性生产失败。第一,PreparePDFForSigning 作用于已完整保存文件的字节,占位文档必须写完后,范围才有意义;如果对尚在写入的流计算范围,得到的偏移将不再匹配最终序列化。第二,8192 字节的预留必须容纳最终 CMS。嵌入中间证书的签名,或签名服务返回的带 signed attributes 的签名,都可能超过它,而 InsertSignatureHex 无法扩大这个洞。症状是某个证书成功、另一个证书失败;修复方式是用更大的预留重新生成占位文档,并以真实签名器产生的真实签名为依据确定大小。
PAdES 基线和文档时间戳
欧洲签名监管建立在 ETSI EN 319 142-1 之上,它定义了四个 PAdES 基线级别:B-B 是基本签名;B-T 增加可信时间戳以证明签名发生时间;B-LT 把验证材料,包括证书和吊销数据,嵌入文档;B-LTA 增加周期性文档时间戳,让证据在算法老化后仍可存活。HotPDF 会为这个生命周期创建文档侧结构:
// PAdES baseline signature field (ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
'Contract approval', 'Boston, MA', 'legal@example.com');
// Document timestamp: larger reservation for the TSA token and chain
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);
注意时间戳上的 16384 字节预留:时间戳机构的 token 带有自己的证书链,因此经常超过普通签名够用的 8 KB。文档时间戳也是 B-LTA 维护背后的机制,每隔几年用当前算法重新给已签名归档加时间戳,才能让 2026 年的签名在 2040 年仍可验证。
两个字段调用接收的原因、位置和联系字符串也需要策略说明:它们作为普通字典项存储,并渲染到可见外观中,但没有任何东西验证它们。它们为人类读者记录意图,例如“Contract approval”、城市、邮箱,审计人员也会读取它们,因此应从工作流数据中一致填充。只是永远不要把可见文本视为证据:密码学陈述完全存在于 CMS 及其证书链中,验证器会完全忽略外观。
为什么已签名文件只能增长
签名一旦存在,签名范围内的字节就永远冻结。ISO 32000-1 §7.5.6 的 incremental updates 是签名后修改文件的唯一合法方式:新增和修改对象会追加到原有字节之后,并带有回链的新交叉引用区段。签名对其所在 revision 仍然有效,查看器也会诚实报告状态:“已签署 revision 完整,文档之后被修改”。相反,完整重新序列化会重写已签名字节区间并直接破坏签名,即使没有任何可见元素发生变化。关于只追加保存的机制及何时压缩它们,详见对象流和 incremental updates 文章。
还有一个库层面的约束会影响规划:HotPDF 的 PDF/A 输出模式会拒绝签名字段,因此归档符合性和嵌入式签名必须拆分到不同交付物,而不是合并在一个文件中。签名也与保密性正交,签名证明来源和完整性,但不隐藏任何内容;那属于 AES-256 加密和权限策略的范围。
签名管线的验收测试应独立于生成文件的代码。在 Acrobat 的签名面板中打开输出并确认三种状态:签名有效,身份链到预期根证书,面板报告签名后没有变更。然后在副本里破坏签名范围内的一个字节,并确认同一个面板报告文档被篡改。一个从未被观察到验证失败的管线,实际上还没有验证过它的验证。
签名代码审查中常见的问题
/Contents 预留应该多大?
带短链的 detached CMS 通常 8192 字节;涉及时间戳或嵌入中间证书时使用 16384。应测量真实签名器产生的 CMS 并留出余量,预留以后无法增长。
一个文档能带两个签名吗?
可以。每个签名位于自己的 incremental revision 中,第二个签名的范围会覆盖第一个签名,这正是会签工作流的构建方式。
签名会保护文档内容不被读取吗?
不会。签名提供完整性和来源证据;任何人仍然可以读取文件。保密性需要单独配置加密。
这三层签名能力都随面向 Delphi 和 C++Builder 的 HotPDF Component 提供;产品页链接完整签名 API 参考。