技术文章

Delphi 中用 HotPDF 做 PDF 数字签名与 PAdES

PDF 签名多半是字节记账,而出错的地方正是字节记账。密码学部分跑在被审计了二十年的代码上,那一块几乎从不失手。在生产环境里失手的是些更朴素的东西:为真实签名预留得太小的占位符、在文件错误区段上取的哈希,或者签完之后一次悄悄重写了签名已冻结字节的「保存」。把字节布局做对,那个绿色对勾自己就会出现

HotPDF 为 Delphi 和 C++Builder 在三个层次上覆盖签名,而你在它们之间的选择只需回答一个问题:私钥住在哪里?磁盘上的一个 PFX 文件只需一次函数调用。锁在 HSM、USB 令牌里或远程签名服务背后的密钥,需要「预留—哈希—插入」这套序列,因为没有哪个库能伸进令牌里把密钥掏出来。而必须满足欧盟法规的签名,还要在此之上加上 PAdES 基线结构。下面各节就按这个递进来讲

决策示意图:在 HotPDF 的 PFX 一次调用签名、密钥位于 HSM 或远程服务时的预留—哈希—插入路径,以及面向受监管欧洲签名的 PAdES 基线结构之间做选择
用「私钥住在哪里」来挑签名层次;一个可读的 PFX 文件把签名坍缩成一次调用,而令牌持有的密钥迫使你绕道字节层,欧盟法规再叠上 PAdES 这一层

/ByteRange 是怎样钉住被签名字节的

签名必须住在它所签署的文件之内,而它没法签自己。PDF 靠留一个洞来绕开这个悖论。签名之前,写入方预留一个固定大小、填满零的 /Contents 条目,并记录一个 /ByteRange 数组,标出这个洞两侧的两个区段:洞之前的全部,和洞之后的全部。签名方对这两个区段取哈希,再把得到的 CMS 数据块以十六进制写进洞里。陷阱就在固定这个词上。你要在还不知道成品签名有多大之前就把洞的大小定死,所以这个预留必须是一个有把握的高估。八千字节足以从容容纳一个带短证书链的分离式 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 保护的容器,也就是在 AES-256-CBC 之上做 PBKDF2 密钥派生。由较老的 Windows 证书向导导出的、或者由 3.0 之前的 OpenSSL 导出的 PFX,通常包在遗留的 RC2 或 3DES 里,那就根本解析不了。修法是用现代保护方式把容器重新导出一次;今天的 OpenSSL 默认就这么做,这不是代码改动。所以当签名在一份「到哪儿都能用」的证书上瞬间失败时,先去看这个 PFX 是怎么生成的,再去怀疑你自己的代码

面向 HSM 与令牌的预留—哈希—插入路径

一次调用的路径假定你的进程能把密钥当文件读。而它越来越读不到了。密钥住在 HSM 里、USB 令牌上,或者某个签名服务的 API 背后,库没有办法直接够到它。HotPDF 的处理方式是把签名拆成字节级的几步:写出一份带占位符的文档,向库要出哈希区段,把哈希输入交给持有密钥的那一方,再把返回的 CMS 拼回洞里

HotPDF:在 placeholder.pdf 上的四步预留—哈希—插入流水线,展示夹在两个 ByteRange 区段之间的预留 /Contents 洞,以及用摘要向 HSM 换取 CMS 十六进制串
HotPDF 预留这个洞并报出两个 ByteRange 区段,你的密钥持有方在外部对它们签名,返回的 CMS 再逐字节拼回去,任何被冻结的字节都不受触碰
var
  Doc: THotPDF;
  Fs: TFileStream;
  PdfBytes, HashInput, SigHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
  // 1. 写出文档,其中带一个预留的 /Contents 洞
  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. 加载已保存的字节;返回的偏移量以 0 为起点
  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. 对两个区段取哈希并在外部签名(HSM、令牌、服务)
  HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
               Copy(PdfBytes, R2Start + 1, R2Len);
  SigHex := SignWithHsm(HashInput);  // 你自己的集成:以十六进制返回 CMS

  // 4. 把签名拼进预留的洞里
  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,而一个带中间证书的签名、或者被某个服务加上了签名属性的签名,都可能超出去。InsertSignatureHex 不会把洞撑大来腾地方。征兆是一条流水线用某张证书签得好好的、换下一张就失败;治法是用真实签名方产出的真实签名量出预留大小,再据此重新生成占位文档,而不是靠猜

PAdES 基线,以及让签名保持存活的时间戳

如果你是在欧盟规则下签名,登场的标准是 ETSI EN 319 142-1,它叠了四个 PAdES 基线档次。B-B 是纯签名。B-T 加上一个可信时间戳,证明它是何时做出的。B-LT 把校验材料,也就是证书和吊销数据,嵌进文档内部,好让它在多年之后仍可校验。B-LTA 再在其上叠加周期性的文档时间戳,让证据比它所依赖的算法活得更久。HotPDF 为每个档次都产出文档侧的结构:

HotPDF:从 B-B 经 B-T 与 B-LT 到 B-LTA 的 PAdES 基线档次堆叠,附一条续期时间线,展示周期性文档时间戳如何让签名在几十年后仍可校验
每一档都在上一档之上叠加新的保护;B-LTA 会不断重新施加文档时间戳,让证据比它最初所依赖的算法活得更久
// PAdES 基线签名字段(ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
  'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
  'Contract approval', 'Boston, MA', 'legal@example.com');

// 文档时间戳:为 TSA 令牌及其证书链预留更大空间
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);

时间戳上那 16384 字节的预留是刻意的。时间戳机构返回的令牌会拖着自己的证书链一起过来,所以它通常需要比纯签名心满意足的 8 KB 更多的空间。这些文档时间戳也正是 B-LTA 背后的机制:每隔几年用当时仍然当代的算法给归档签名重新盖一次时间戳,正是让你在 2026 年签下的文档到 2040 年仍可校验的原因

关于两个字段调用都接受的原因、地点和联系方式字符串,得说一句:它们只是便利性元数据,仅此而已。HotPDF 把它们存成普通的字典条目,并画进可见的签名外观里,但没有任何校验器会拿它们去对照什么。请按你的工作流数据一致地填好它们,因为审计人员确实会读,然后千万别把它们误当成证据。真正的密码学主张完全住在 CMS 及其证书链里,而校验方会彻底无视那些可见文字

签名之后,文件只能增长

签名一旦存在,它区段之内的字节就被冻结了。此后修改文件的唯一正当方式,是 ISO 32000-1 §7.5.6 的增量更新,它把新增和变更的对象追加在原有字节之后,并把一段新的交叉引用节链回去。这么做,签名对它那一版仍然有效,而查看器报告的是诚实的状态:已签名的那一版完好无损,文档随后被扩展过。反过来把整个文件重新序列化,你就重写了被签名的区段,即便看得见的东西一点没变,签名也被毁了。同一套版本机制也正是一份文档承载多个签名的方式:每个新签名落在它自己的增量更新里,而它的区段覆盖它之前的一切,包括先前的那些签名。这套只追加的机制,以及何时可以安全地把它们压实,见对象流与增量更新一文

设计时有两条边界值得记在心里。HotPDF 的 PDF/A 输出模式干脆拒绝签名字段,因此归档合规与内嵌签名必须作为两份文件分别交付。另外,签名对保密只字未提:它证明的是谁产出了一份文档、以及它此后未被改动,但任何人仍然读得了它。遮蔽内容是另一件活儿,由 AES-256 加密与权限策略负责

无论你搭出什么,都请用写出这份文件的代码之外的东西去测它。在 Acrobat 的签名面板里打开输出,确认三件事:签名有效、身份链到你所预期的那个根、面板报告签名之后无更改。然后在一份随手可弃的副本里翻转被签名区段内的某一个字节,确认面板现在把该文档判为已被改动。一条你从未亲眼看过它拒收被篡改文件的签名流水线,是它的校验尚未真正被测过的流水线

三个签名层次都随面向 Delphi 与 C++Builder 的 HotPDF Delphi Component 一同发布;产品页上有完整签名 API 参考的链接