技术文章

Delphi 里的 FDF Differences:追加已签名的表单修订版

HotPDF Component 从 Delphi 应用 FDF 的 /Differences 流,入口是 THPDFHeadlessDocument.SaveFDFDifferencesToFile:方法先检查已加载的 PDF 是否仍与基准修订版一致——基准用精确字节长度和 SHA-256 指认——然后把累积修订字节恰好追加一次,把结果过一遍加密权限和 DocMDP/FieldMDP 准入,最后原子发布。重复提交同一份文件是逐字节的无操作

这套机制服务的场景不体面但常见。你把合同或检验单以 PDF 发出去,对方在自己的阅读器里填好并签名,寄回来的不是 PDF 而是 FDF 文件。字段值搭 /Fields 的车,但被签的修订本身——CMS 签名真正覆盖的那些字节——搭 /Differences 的车。合并做错,你要么弄坏签名,要么更糟:接受了从未被任何签名覆盖的字段值

FDF 的 /Differences 流里到底装着什么?

FDF 的 /Differences 流装的是目标 PDF 的原始增量更新字节,不是 FDF 层面的历史。ISO 32000-1 §12.7.7.3 表 243 把 FDF 字典的 Differences 条目定义为底层 PDF 自打开以来的增量改动,形式与 §7.5.6 的 PDF 增量更新相同:新增和替换的对象、一个交叉引用节,以及 /Prev 指回基准文件内部的 trailer

FDF 容器本身恰恰相反。§12.7.7.1 禁止对 FDF 文件做增量更新,所以 FDF 只有单个 trailer、只有 generation 0 对象、对象号唯一。HotPDF Component 在它的 FDF 解析器里严格执行这一点,从不会为了让 /Differences 好做而放松。增量数据住在一个严格非增量的容器的单个流里,把这两层分开看是这活的一半

HotPDF 在 Delphi 里的 FDF /Differences 解剖图:严格非增量的 FDF 容器带单个 trailer 和 generation 0 对象,承载一条累积的 /Differences 流(原始增量更新字节),而 /Fields 和 /Annots 保持未签名、未合并
被签的修订搭 /Differences 流的车,不在 /Fields 里:容器本身禁止增量更新,只有 CMS 签名覆盖的字节负载可信

流的两个性质决定了下游的每一个设计决策:

  • 流是累积的:它装着目标 PDF 打开以来追加的每一个字节,所以同一会话的第二次提交会重复你已提交过的修订、并把新的那份接在后面
  • 同一份 FDF 外层的 /Fields 和 /Annots 条目不保证与 /Differences 一致,所以你轻松读到的字段值并不是签名覆盖的数据

第二点值得说得直白。如果你的导入代码从 /Fields 读 /V、又因为签名出现在同一个文件里就把结果当已签名,那你造出了一条伪造通道。HotPDF Component 刻意把两条路分开:FDF 注解导入器直接拒绝 /Differences,只有专门的 Differences 传输碰它

把追加绑到明确的基准修订版上

SaveFDFDifferencesToFile 拒绝去猜 FDF 属于哪个修订版。调用方传入 BaseBytes——表单打开自的那个完整 PDF 修订版的精确长度——和 BaseSHA256——它的 64 位十六进制 SHA-256。方法对已加载文档的那个前缀重新散列,不匹配就以 "The loaded PDF does not match the explicitly selected base" 拒绝,并检查前缀确实结束在一个真实的修订版端点、其 startxref 指向它实际的交叉引用数据

实用的规则是:发出表单时就记录这两个数,而不是等 FDF 回来。HPDFCrypt 里 SHA256Init / SHA256Update / SHA256Final 例程之上的一个小辅助函数就够用:

uses
  SysUtils, Classes, HPDFCrypt;

function PrefixSHA256Hex(const FileName: string; PrefixBytes: Int64): AnsiString;
var
  Stream: TFileStream;
  Ctx: TSHA256Context;
  Digest: TSHA256Digest;
  Buffer: array[0..65535] of Byte;
  Remaining: Int64;
  Count, I: Integer;
begin
  Stream := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
  try
    if (PrefixBytes <= 0) or (PrefixBytes > Stream.Size) then
      raise EArgumentException.Create('Base length is outside the file');
    SHA256Init(Ctx);
    Remaining := PrefixBytes;
    while Remaining > 0 do
    begin
      Count := SizeOf(Buffer);
      if Remaining < Count then
        Count := Integer(Remaining);
      Stream.ReadBuffer(Buffer, Count);
      SHA256Update(Ctx, Buffer, Count);
      Dec(Remaining, Count);
    end;
    SHA256Final(Ctx, Digest);
  finally
    Stream.Free;
  end;
  Result := '';
  for I := 0 to High(Digest) do
    Result := Result + AnsiString(LowerCase(IntToHex(Digest[I], 2)));
end;

// 发送时:整个发出文件就是基准修订版
// BaseLength := 'out\contract-0042.pdf' 的字节数
// BaseHash   := PrefixSHA256Hex('out\contract-0042.pdf', BaseLength);

已加载的文档不必恰好是基准。它可以是基准加上你已从早先提交里落盘的修订,累积流正是因此才保持可控:BaseBytes 始终指原始前缀,其后的一切都当作先前的追加字节

HotPDF 在 Delphi 里的 FDF Differences 字节布局:已加载 PDF 拆成经 BaseBytes 长度与 BaseSHA256 验证的基准修订版、已提交的先前追加字节,以及一条累积 /Differences 负载——后者在追加新后缀之前必须逐字节重复前者
基准前缀由长度与 SHA-256 钉死,已提交字节充当只追加契约,只有尾随后缀是新的——重复提交于是落成逐字节的无操作

用 SaveFDFDifferencesToFile 应用 Differences

THPDFHeadlessDocument.SaveFDFDifferencesToFile 接收输出文件名、FDF 字节、基准长度与哈希、可选的嵌入 FDF 选择,以及一个 THPDFFDFPackageOptions 限额记录,返回 THPDFHeadlessFDFDifferencesResult。内部它把 FDF 打开成 THPDFFDFPackage 并自己调 DecodeDifferences,所以你传 FDF 文件,而不是预先解码的流。Selection 为空指顶层 FDF;非空则先走进嵌入的子 FDF

uses
  SysUtils, IOUtils, HPDFHeadlessDocument, HPDFHeadlessCMS, HPDFFDFPackage;

procedure CommitReturnedForm(const CurrentPdf, NextPdf, FdfFile: string;
  BaseLength: Int64; const BaseHash: AnsiString);
var
  Doc: THPDFHeadlessDocument;
  Policy: THPDFHeadlessCMSOptions;
  Selection: THPDFFDFChildSelections;
  Applied: THPDFHeadlessFDFDifferencesResult;
begin
  Doc := THPDFHeadlessDocument.Create;
  try
    Policy := THPDFHeadlessCMSOptions.Default;
    Policy.TrustedCAFile := 'trust\issuing-ca.pem';
    Doc.SetSignaturePolicyOptions(Policy);
    Doc.LoadFromFile(CurrentPdf);

    Selection := nil;  // 顶层 FDF,没有嵌入子级
    Applied := Doc.SaveFDFDifferencesToFile(NextPdf,
      TFile.ReadAllBytes(FdfFile), BaseLength, BaseHash,
      Selection, THPDFFDFPackageOptions.Default);

    Writeln(Format('base %d, prior %d, total %d, added %d, no-op %s',
      [Applied.BaseBytes, Applied.PriorAppendBytes, Applied.TotalAppendBytes,
       Applied.AddedBytes, BoolToStr(Applied.NoOp, True)]));
  finally
    Doc.Free;
  end;
end;

检查顺序比调用本身更值得在意。基准哈希通过之后,方法把已提交的追加字节——BaseBytes 到已加载文件末尾之间的一切——与解码负载的开头比较。比你已提交内容更短的提交以 "A cumulative submission cannot remove committed append bytes" 失败;与之冲突的以 "The cumulative Differences conflict with the committed revision" 失败。只有剩下的后缀是新的,也只有那个后缀进 AddedBytes

HotPDF 在 Delphi 里 SaveFDFDifferencesToFile 的检查管线:基准长度与 SHA-256、累积前缀比较、临时候选验证、/ID 与签名同一性、权限位加 DocMDP 与 FieldMDP 准入,然后原子发布追加的修订版
六道失败即关闭的闸门按固定顺序运行,之后追加修订版的任何一个字节才会发布,所以畸形的 FDF 或不被允许的改动都让已加载文档与既有输出原封不动

拼装好的候选写入目标目录下一个独占的临时文件,flush,作为独立的修订版来源重新打开并验证,然后才替换输出。解析、取消、策略、预算或发布失败都让已加载文档与既有输出文件原封不动。两个前置条件让人早点踩坑:输出不得与已加载源是同一个文件;文档不得有未保存的编辑,因为一次不透明追加会无声丢掉它们("Publish pending document edits before applying Differences")

为什么重复应用是逐字节的无操作?

同一份累积 FDF 的重复应用是无操作,因为它负载的每个字节都已在文件里。负载与先前追加字节完全相等时,SaveFDFDifferencesToFile 报 NoOp = True 和 AddedBytes = 0,发布的输出与当前 PDF 相同。这比"只追加"更强的保证,也是网络超时或 worker 崩溃之后重试安全的原因

// Rev 1 已提交进 contract-0042-r1.pdf;同一份 FDF 到了两次
// (Doc、Again、Fdf、Selection、BaseLength 与 BaseHash 的声明同上)
Doc := THPDFHeadlessDocument.Create;
try
  Doc.LoadFromFile('store\contract-0042-r1.pdf');
  Again := Doc.SaveFDFDifferencesToFile('store\contract-0042-r1-retry.pdf',
    Fdf, BaseLength, BaseHash, Selection, THPDFFDFPackageOptions.Default);
  // Again.NoOp = True, Again.AddedBytes = 0,
  // 且重试文件与 contract-0042-r1.pdf 字节相同
finally
  Doc.Free;
end;

把验收测试写成匹配这个保证的样子。断言重试输出与当前修订版全字节相等,而不仅是文件没有变小。只检查追加行为的测试会欣然放过一个把同一修订版追加第二遍的实现——那产出一个看起来有效、却带着重复更新节的 PDF,留给之后的 DocMDP 审计去解释

修订版发布之前跑哪些准入检查?

发布之前,候选 PDF 必须通过同一性、签名和权限检查,且没有一个信任外层 FDF 字段。trailer /ID 的第一个元素必须原样存活;已加载文档里已有的每个签名字段必须仍以相同的签名字典和相同的签名值同一性存在。丢弃、替换或改写既有签名的 Differences 负载以 hdSignatureModificationUnsupported 失败

接下来是授权层。加密文档上的非空追加,被改对象按角色分类(catalog 与页面、注解、AcroForm 字段、外观流)并对照已认证的用户权限位检查,所以改注解的修订需要注解权限,依此类推。然后候选走 HotPDF Component 在别处也在用的同一套认证与字段锁执行:DocMDP 认证签名(ISO 32000-1 §12.8.2.2)限制后续修订版可做哪类改动,FieldMDP 锁(§12.8.2.4)保护具名字段。HotPDF 的 DocMDP 与 FieldMDP 修订分析讲了这些权限如何按修订版分类

错误处理应当把"这份 FDF 畸形"和"这份修订版不被允许"分开,因为后一种通常需要人介入:

try
  Applied := Doc.SaveFDFDifferencesToFile(NextPdf, Fdf, BaseLength, BaseHash,
    Selection, THPDFFDFPackageOptions.Default);
except
  on E: EHPDFFDFPackageError do
    QuarantineFdf(FdfFile, 'No usable Differences stream: ' + E.Message);
  on E: EHPDFHeadlessDocumentError do
    case E.Code of
      hdPermissionDenied, hdSignatureModificationUnsupported:
        EscalateForReview(FdfFile, E.Message);
      hdLimitExceeded:
        QuarantineFdf(FdfFile, 'Over budget: ' + E.Message);
    else
      QuarantineFdf(FdfFile, E.Message);  // 基准不匹配、冲突、坏端点
    end;
end;

这个操作不做什么,和它检查什么同样重要。SaveFDFDifferencesToFile 从不取 FDF 的 /F file specification,所以 FDF 里点名的 PDF 无法把合并重定向到磁盘或网络上的别的文件;它也从不执行 JavaScript 或 action。每个输入都显式到来:已加载的基准、FDF 字节、你配置的信任材料

Windows 事务与 C ABI 的 fdf.differences.apply

同一套传输不必直接写 headless 代码也能用。THPDFWindowsFormDataTransaction.ApplyFDFDifferences 接收 FDF 字节、基准长度、基准 SHA-256、选择、包选项和密码,委托给 SaveFDFDifferencesToFile,并加上事务的字节记账、回滚、取消与同句柄重试行为。对非 Delphi 调用方,C ABI 暴露 JSON 操作 fdf.differences.apply,base 携带长度与哈希:

{ "schemaVersion": 1, "type": "fdf.differences.apply", "format": "fdf",
  "base": { "bytes": 673, "sha256": "..." },
  "signaturePolicy": { "trustedCAPEM": "..." } }

JSON 应答报告 baseBytes、priorAppendBytes、totalAppendBytes、addedBytes 和 noop,与 THPDFHeadlessFDFDifferencesResult 相同的字段。这条传输发布的验收闸门跑在 Linux FPC 3.3.1、Windows Delphi 32 位与 64 位以及 Windows FPC 32 位与 64 位上;每个安全目标覆盖 21 个用例、1,244 条原生检查和 382 条回调命令

值得知道的限制与测试夹具陷阱

Differences 传输刻意做得窄,它有几条边真实咬过测试套件:

  • 基准必须是完整的 PDF 修订版。落在文件中段的长度、或 startxref 没指向其实际交叉引用数据的,在任何字节被复制之前就被拒绝
  • 签名夹具需要间接的 /Lock。Windows 已加载文档权限读取器以 "Signature field /Lock must be an indirect reference" 拒绝 /Lock 为内联字典的签名字段——尽管一些签名路径容忍内联形式——所以把锁字典写成独立对象
  • 用裸 openssl req -x509 做的测试 CA 缺 CA:TRUE 和 keyCertSign,于是链验证以一个看起来像库 bug、其实不是的证书验证错误失败
  • 这个调用不合并外层 /Fields 的值。文档里也想要它们时,明确决定它们是否仅供参考,并且永远不要把它们当已签名呈现
  • FDF 本身保持非增量。带多个 trailer、非零 generation 或重复对象号的 FDF 文件是畸形 FDF,不是更丰富的 FDF

只看 FDF 与 XFDF 的普通值侧、没有签名修订在场时,HotPDF 的多选 FDF 与 XFDF 往返和 Delphi 里的 XFDF 注解导入导出是更好的起点

FAQ

能传解码后的 Differences 流代替 FDF 文件吗?

不能。SaveFDFDifferencesToFile 把你传入的 FDF 解析为 THPDFFDFPackage,应用可选的嵌入选择并在内部调 DecodeDifferences。想检查或归档原始修订字节时可以单独用 THPDFFDFPackage.DecodeDifferences,但 apply 调用要交给它 FDF 文件

客户端发来第二份、更大的累积 FDF 会怎样?

加载已包含第一个已提交修订版的 PDF,并保持相同的 BaseBytes 和 BaseSHA256。方法验证新负载恰好以已提交的字节开头,只追加新后缀并把它记进 AddedBytes,然后对结果跑完整的权限与 DocMDP/FieldMDP 准入

应用成功等于 /Fields 里的字段值被签名了吗?

不等于。ISO 32000-1 不要求同一份 FDF 里的 /Fields、/Annots 和 /Differences 相互一致,HotPDF Component 也不去调和它们。被签名的是追加修订版里的 CMS 签名所覆盖的内容,所以已签名的值要从合并后的 PDF 读,不要从 FDF 外壳读

FDF Differences 传输、headless 签名策略引擎和 Windows 表单数据事务都随面向 Delphi、C++Builder 和 Free Pascal 的 HotPDF Component 发布