技術記事

Delphi の FDF Differences: 署名済みフォームリビジョンの追記

HotPDF Component は、Delphi から THPDFHeadlessDocument.SaveFDFDifferencesToFile を通じて FDF の /Differences ストリームを適用します。このメソッドは、正確なバイト長と SHA-256 で指定したベースリビジョンにロード済み PDF が一致することを確認し、累積リビジョンのバイトをちょうど 1 回追記し、結果を暗号化権限と DocMDP/FieldMDP の受け入れ検査に通し、原子的に公開します。同じ提出を繰り返してもバイト単位の no-op です

この機能が存在するワークフローは地味でよくあります。契約書や検査票を PDF で送ると、誰かが自分のビューアで記入して署名し、返ってくるのは PDF ではなく FDF ファイルです。フィールド値は /Fields に乗りますが、署名済みリビジョンそのもの、CMS 署名が実際にカバーするバイトは、/Differences に乗ります。マージを間違えれば、署名を壊すか、さらに悪いことに、どの署名もカバーしていないフィールド値を受け入れることになります

FDF の /Differences ストリームは実際に何を運ぶのか

FDF の /Differences ストリームは、ターゲット PDF への生のインクリメンタル更新バイトを運ぶのであって、FDF レベルの歴史ではありません。ISO 32000-1 §12.7.7.3、Table 243 は FDF 辞書の Differences エントリを、基礎 PDF が開かれてから加えられた増分変更と定義し、その形は §7.5.6 の PDF インクリメンタル更新と同じです。新規と置換のオブジェクト、クロスリファレンスセクション、/Prev がベースファイルを指すトレーラーです

FDF コンテナ自体は正反対です。§12.7.7.1 は FDF ファイルへのインクリメンタル更新を禁じており、FDF は単一のトレーラー、generation 0 のオブジェクトだけ、一意のオブジェクト番号を持ちます。HotPDF Component は FDF パーサでまさにそれを強制し、/Differences を楽にするために緩めたりしません。増分データは厳密に非増分のコンテナの 1 つのストリームの中に住んでおり、この 2 つの層を混ぜないことが仕事の半分です

Delphi における HotPDF FDF /Differences の構造図。単一トレーラーと generation 0 オブジェクトを持つ厳密に非増分の FDF コンテナが、生のインクリメンタル更新バイトの 1 本の累積 /Differences ストリームを運び、/Fields と /Annots は未署名かつ未マージのまま残る
署名済みリビジョンが乗るのは /Differences ストリームであって /Fields ではありません。コンテナ自体がインクリメンタル更新を禁じており、信頼できるのは CMS 署名がカバーするバイトペイロードだけです

ストリームの 2 つの性質が、下流のすべての設計判断を形作ります

  • ストリームは累積です。ターゲット PDF が開かれてから追記されたすべてのバイトを持つため、同じセッションからの 2 回目の提出は、すでにコミット済みのリビジョンを繰り返し、その後ろに新しいものを足します
  • 同じ FDF の外側の /Fields と /Annots エントリが /Differences と一致する保証はありません。簡単に読めるフィールド値は、署名がカバーするデータではないのです

2 つ目の点は率直に言う価値があります。インポートコードが /Fields から /V を読み、署名が同じファイルに届いたからといってその結果を署名済みとして扱うなら、偽造経路を作ったことになります。HotPDF Component は意図的に 2 つの経路を分けています。FDF 注釈インポーターは /Differences を丸ごと拒否し、これに触れるのは専用の Differences 転送だけです

追記を明示的なベースリビジョンに束ねる

SaveFDFDifferencesToFile は、FDF がどのリビジョンに属するかを推測することを拒みます。呼び出し側は BaseBytes(フォームが開かれた完全な PDF リビジョンの正確な長さ)と BaseSHA256(64 桁の 16 進 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 で固定され、コミット済みバイトは追記専用の契約として働き、新しくないのは末尾の接尾辞だけです。同じ提出の再送はバイト単位の 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 の受け入れ検査、そして追記リビジョンの原子的な公開
追記リビジョンのどのバイトも公開される前に、6 つの fail-closed ゲートが固定の順序で走ります。不正な FDF や許されない変更は、ロード済み文書も既存の出力も触れません

組み立てられた候補はターゲットディレクトリの排他一時ファイルに書かれ、フラッシュされ、独立したリビジョンソースとして開き直され、出力を置き換える前に検証されます。パース、キャンセル、ポリシー、予算、公開の失敗は、ロード済み文書と既存の出力ファイルをどちらもそのまま残します。2 つの前提条件が早い段階で人を捕まえます。出力はロード済みソースの別名であってはならず、文書に未保存の編集があってはなりません。不透明な追記はそれを無言で捨ててしまうからです("Publish pending document edits before applying Differences")

同じものの再適用がバイト単位の no-op になる理由

同じ累積 FDF の再適用が no-op になるのは、ペイロードのすべてのバイトがすでにファイルの中にいるからです。ペイロードが追記済みバイトと正確に一致する場合、SaveFDFDifferencesToFile は NoOp = True と AddedBytes = 0 を報告し、公開される出力は現在の PDF と同一です。これは「追記専用」より強い保証で、ネットワークタイムアウトやクラッシュしたワーカー後のリトライを安全にするものです

// Rev 1 は contract-0042-r1.pdf にコミット済み。同じ FDF が 2 回届く
// (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;

受け入れテストもそれに合わせて書いてください。リトライ出力と現在のリビジョンの完全なバイト一致を assert するのであって、ファイルが縮まなかったことだけではありません。追記専用の振る舞いだけを見るテストは、同じリビジョンを 2 回追記する実装でも喜んで通ります。それは、後の DocMDP 監査が説明を強いられる、更新セクションが重複したもっともらしい PDF を生みます

リビジョンが公開される前にどんな受け入れ検査が走るのか

公開の前に、候補 PDF は同一性、署名、権限の検査を通らなければならず、どれも外側の FDF フィールドを信頼しません。トレーラー /ID の最初の要素は無傷で生き延びなければならず、ロード済み文書にすでに存在するすべての署名フィールドは、同一の署名辞書と同じ署名値の同一性で存在し続けなければなりません。既存の署名を落としたり、置き換えたり、書き換えたりする Differences ペイロードは hdSignatureModificationUnsupported で失敗します

次に来るのは承認の層です。暗号化文書への空でない追記では、変更されたオブジェクトが役割ごと(カタログとページ、注釈、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 やアクションも実行しません。すべての入力は明示的に届きます。ロード済みベース、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 ビットで走ります。各セキュリティターゲットは 1,244 のネイティブチェックと 382 のコールバックコマンドを伴う 21 ケースをカバーします

知っておく価値のある限界とフィクスチャの罠

Differences 転送は意図的に狭く設計されており、いくつかの縁は実在のテストスイートを噛んでいます

  • ベースは完全な PDF リビジョンでなければなりません。ファイル途中に着地する長さや、startxref が実際のクロスリファレンスデータを指さないものは、バイトが 1 つもコピーされる前に拒否されます
  • 署名フィクスチャには間接の /Lock が要ります。Windows のロード済み文書権限リーダーは、/Lock がインライン辞書の署名フィールドを "Signature field /Lock must be an indirect reference" で拒否します。いくつかの署名経路はインライン形を許容するのですが、ロック辞書は独自のオブジェクトとして書いてください
  • 素の openssl req -x509 で作ったテスト CA には CA:TRUE と keyCertSign がないため、チェーン検証はライブラリのバグに見えてそうではない certificate verify エラーで失敗します
  • 外側の /Fields 値はこの呼び出しではマージされません。文書にも入れたいなら、それが参考情報なのかを明示的に決め、署名済みとして提示してはいけません
  • FDF 自体は非増分のままです。複数トレーラー、ゼロでない generation、重複オブジェクト番号を持つ FDF ファイルは、より豊かな FDF ではなく不正な FDF です

署名済みリビジョンが絡まない、FDF と XFDF の素の値の側面には、HotPDF のマルチセレクト FDF と XFDF の往復やDelphi での XFDF 注釈インポートとエクスポートの方がよい出発点です

FAQ

FDF ファイルの代わりにデコード済み Differences ストリームを渡せるか

いいえ。SaveFDFDifferencesToFile は渡された FDF を THPDFFDFPackage としてパースし、任意の埋め込み選択を適用し、内部で DecodeDifferences を呼びます。生のリビジョンバイトを検査やアーカイブに使いたいときは THPDFFDFPackage.DecodeDifferences を単独で使ってください。ただし適用呼び出しには FDF ファイルを渡します

クライアントが 2 つ目のより大きい累積 FDF を送ってきたらどうなるか

最初にコミットしたリビジョンをすでに含む PDF をロードし、同じ BaseBytes と BaseSHA256 を保ちます。メソッドは新しいペイロードがコミット済みバイトと正確に一致する先頭から始まることを検証し、新しい接尾辞だけを追記して AddedBytes に報告し、結果に対して完全な権限と DocMDP/FieldMDP の受け入れ検査を走らせます

適用の成功は /Fields のフィールド値が署名済みという意味か

いいえ。ISO 32000-1 は 1 つの FDF 内の /Fields、/Annots、/Differences が一致することを要求せず、HotPDF Component も照合しません。署名されているのは、追記されたリビジョン内の CMS 署名がカバーするものです。署名済みの値はマージ後の PDF から読むのであって、FDF ラッパーからではありません

FDF Differences 転送、headless 署名ポリシーエンジン、Windows フォームデータトランザクションはすべて、Delphi、C++Builder、Free Pascal 向けの HotPDF Component に同梱されています