技術文章

Delphi 的 FDF Differences:附加已簽署的表單修訂版

HotPDF Component 透過 THPDFHeadlessDocument.SaveFDFDifferencesToFile 從 Delphi 套用 FDF 的 /Differences 流:這個方法先檢查已載入的 PDF 是否仍與您以精確位元組長度和 SHA-256 指定的基準修訂版相符,把累積的修訂位元組恰好附加一次,再讓結果通過加密權限與 DocMDP/FieldMDP 准入檢查,最後原子式發布。重複提交同一份 FDF 是逐位元組的 no-op

這個功能服務的流程平凡而常見。您把合約或檢驗表單以 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 好做而放寬。增量資料住在一個嚴格非增量的容器裡的一條流中,把這兩層分清楚就是一半的工作

Delphi 的 HotPDF FDF /Differences 解剖圖:帶單一 trailer 與 generation 0 物件的嚴格非增量 FDF 容器,承載一條由原始增量更新位元組組成的累積 /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 始終指向原始前綴,其後的一切都被視為先前的附加位元組

Delphi 的 HotPDF FDF Differences 位元組版面:已載入 PDF 切成由 BaseBytes 長度與 BaseSHA256 驗證的基準修訂版,加上已提交的先前附加位元組,以及一條必須先重複那些位元組、再新增新後綴的累積 /Differences 承載
基準前綴由長度與 SHA-256 釘死,已提交位元組是一份 append-only 契約,只有尾部後綴是新的——重複提交於是落成逐位元組的 no-op

用 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

Delphi 中 HotPDF SaveFDFDifferencesToFile 的檢查管線:基準長度與 SHA-256、累積前綴比對、暫存候選驗證、/ID 與簽章身分、權限位元加 DocMDP 與 FieldMDP 准入,最後原子式發布附加修訂版
六道 fail-closed 關卡按固定順序執行,任何一位元組的附加修訂版都不會在通過前發布,畸形 FDF 或不被允許的變更因此不會動到已載入文件與既有輸出

組裝好的候選寫進目標目錄裡一個獨佔的暫存檔,沖刷、以獨立修訂來源的身分重新打開,驗證通過才替換輸出。解析、取消、原則、預算或發布的失敗,都不會動到已載入文件與任何既有輸出檔。兩個前置條件讓人早點踩線就停:輸出不得是已載入來源的別名,文件不得有未存檔的編輯,因為不透明的附加會靜默丟掉它們(「Publish pending document edits before applying Differences」)

為什麼重複套用是逐位元組的 no-op?

重複套用同一份累積 FDF 是 no-op,因為它的承載的每個位元組都已在檔案裡。當承載與先前附加位元組完全相等,SaveFDFDifferencesToFile 回報 NoOp = True 與 AddedBytes = 0,發布的輸出與當前 PDF 完全相同。這比「append-only」更強,也正因如此,網路逾時或 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;

驗收測試也照這個標準寫。斷言重試輸出與當前修訂版完全逐位元組相等,而不是只斷言檔案沒有縮小。只檢查 append-only 行為的測試,會很樂意放行一個把同一修訂版再附加一次的實作——那會產出一個看似有效的 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 檔案規格,所以 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 已載入文件權限讀取器對 /Lock 是內嵌字典的簽章欄位,以「Signature field /Lock must be an indirect reference」拒收,即使某些簽章路徑容忍內嵌形式,所以請把鎖字典寫成自己的物件
  • 用裸的 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,但套用呼叫請交給它 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 表單資料交易都隨 HotPDF Component 提供,支援 Delphi、C++Builder 與 Free Pascal