技術記事

Delphiでの再現可能なPDF出力:バイト単位で同一の保存

同じ請求書コードが1秒違えて書いた2つのPDFは、めったに同じファイルになりません。バイナリ比較は、まずInfo辞書の内側で引っかかります。/CreationDateが1秒ずれているからです。続いてXMPパケット、そしてtrailerの/IDです。AES-256を有効にすると、今度はほぼすべてのストリームで比較が崩れます。ファイル暗号化鍵そのものが新たに生成されたからです。比較の前に日付をマスクしても、直るのは最初の2つの差だけで、他は直りません。ゴールデンファイルのテストが信頼できるのは、ライターが文書以外から持ち込む値がすべて、読み込まれた時点で固定されるときです

文書から来ない4つの値

HotPDF Delphi Componentは、他の規格準拠のPDFライターと同じく、ページコンテンツの外から4種類の値を埋めます。時計、文書識別子、暗号乱数源、そして辞書エントリのメモリ上の順序です。ISO 32000-1にはそれぞれに理由があります

  • 時計。Info辞書は/CreationDateと/ModDate(ISO 32000-1 §14.3.3、Table 317)を、タイムゾーン接尾辞付きのD:YYYYMMDDHHmmSS文字列として運び(§7.9.4)、XMPパケットは同じ時刻をxmp:CreateDateとxmp:ModifyDateとして繰り返します。これらはすべて、コンポーネントが構築時にNowから受け取る1つの作成タイムスタンプから描画されます
  • 識別子。trailerの/ID配列(§14.4)は、永続識別子と変更識別子のペアを保持します。デフォルトでは、前者はファイル名とミリ秒までの現在時刻のMD5で、後者はその値にシステムのティックカウントを合わせて再びハッシュしたものです
  • 乱数バイト。AES-256文書には、32バイトのファイル暗号化鍵、/U用の8バイト検証ソルトと8バイト鍵ソルト、/O用のもう1組、/Perms内の4バイトのフィラー、そして暗号化されるすべての文字列とストリームの前に置く16バイトの初期化ベクトルが必要です。ISO 32000-2 §7.6.4.4.7はソルトに乱数を求めます。RC4とAES-128はパスワードと第1の/ID要素から鍵を導出するので(ISO 32000-1 §7.6.3.3)、新しい識別子だけでファイル全体が再鍵化されます
  • 順序。PDFの辞書は順序を持たないマッピングです(§7.3.7)。メモリ上のリストをたどるライターは、コードが追加した順にキーを出力します。同じリソース辞書を違う順序で組み立てる2つのコード経路は、合法でありながらテキストとして異なるファイルを生みます
1つの文書に対する2回のHotPDF保存を違える4つのエントロピー源。Nowから取った作成タイムスタンプがD:の日時とXMPパケットを養い、trailerの/IDがファイル名、時計、ティックカウントをハッシュし、AESがシステムの乱数源から鍵素材を引き、辞書はメモリ上の挿入順で直列化されます
どの源も単体では正当で、ISO 32000-1もそれを求めています。それでも合わさると、ファイルは中身の関数ではなく、いつどこで書かれたかの関数になってしまいます

それ以外はすべて入力から決まります。コンテンツストリーム、圧縮画像データ、xrefのオフセットは、同じ描画呼び出しなら繰り返し同じになります。一見乱数に見える唯一のフィールドすら違います。埋め込みフォントサブセットの6文字の接頭辞(ISO 32000-1 §9.6.4。ABCDEF+ArialのABCDEF+の部分)は、サブセットが保持するグリフ集合から計算されるので、同じテキストは毎回同じ接頭辞を生みます。ノイズはメタデータとセキュリティ層にあり、だからこそ1つのプロパティで除去できるのです

ReproducibleOutputが固定するもの

BeginDocの前にReproducibleOutput := Trueを設定すると、4つの源がすべて固定値に置き換わります。置き換えは、そうでなければ時計や乱数生成器に手を伸ばすのと同じコード経路で行われます。後からクリーンアップのパスが走るわけではありません。プロパティの既定値はFalseで、ライブラリが勝手に有効にすることはありません

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'golden-invoice.pdf';
    Pdf.ReproducibleOutput := True;     // BeginDocの前に必ず設定
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'Invoice 2026-0042');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

フラグの下では、BeginDocが作成タイムスタンプを2026年1月1日0時(ローカル時刻)に設定します。Infoの日付もXMPの日付もこの1つの値から描画されるので、1つの代入ですべてをカバーします。同じ分岐が、内部の文書識別子をファイル名と時計の代わりに定数文字列から種付けします。ファイルを書き出すとき、trailerの/IDは、すべてのD:日付文字列の数字を先にゼロにした、正準化したオブジェクトグラフ全体に対するMD5として計算されます。タイムスタンプがハッシュ経由で忍び戻ることはありません。/IDの両要素がこの値を受け取ります。一度もBeginDocを呼ばずに暗号化する読み込み済み文書(LoadFromFileの後にActivateProtectionを設定)も、同じ内容由来の識別子を得ます

乱数バイトは定数に置き換わります。AES-256のファイル鍵、4つのソルトすべて、/Permsのフィラーは$5Aで満たされ、すべてのCBC初期化ベクトルは、AES-128の文字列やストリームもAES-256も含めて、バイトIが14 * (1 + I)というパターンになります。このパターンが意図的に非ゼロなのは当然です。全ゼロのIVはCBCの教科書的な弱い選択であり、リグレッションスイートがそれを標準化する筋合いはありません。鍵とソルトとベクトルが固定されれば、/U、/UE、/O、/OE、/Perms、そしてすべての暗号化ストリームは、2回目の実行でも同一に出ます

最後に、SaveToStreamは再現可能フラグが立っていればDeterministicDictionaryOrderを有効にします。しかも、リニアライズド、オブジェクトストリーム、クラシックの各レイアウトを選ぶ前にそうするので、LinearizeOutputやUseObjectStreamsを使う出力もソートされます。キーは生バイト順に並び、他のキーの接頭辞であるキーが先に来て、同順は元の位置で決まります。これは診断ライターが使う順序で、PDFを手で編集し後から修復する方法の記事で述べたものです。再現可能フラグはその順序を借りるだけで、あのライターの平文レイアウトは借りません。DeterministicDictionaryOrderだけを有効にするのは半分の対策です。キーは揃いますが、Infoの日付とtrailerの/IDの両文字列は依然として時計を運ぶので、2つの保存は同じ4か所で違い続けます

HotPDFのReproducibleOutputが固定するもの。作成日時は2026年1月1日になり、trailerの識別子はD:の数字をゼロにした正準的なオブジェクトグラフのMD5になり、AESの鍵とソルトは$5Aバイトで満たされ、各IVバイトは14 × (1 + i)に従い、DeterministicDictionaryOrderがすべての辞書をソートします
置き換えは、そうでなければ時計や乱数生成器に手を伸ばすのと同じコード経路で走るので、別途の後処理パスは不要であり、/IDの両方の要素が同じ内容由来の値を受け取ります

運だけでは合格しないテスト

日付がBeginDocで固定されるのは、コンストラクタは呼び出し側がプロパティを設定する機会を得る前に走るからです。通常の順序はCreate、次にReproducibleOutput := True、そしてBeginDocであり、最後のものだけが最終値のフラグを見ます。この順序はテスト設計に直接の帰結を持ちます。同じ壁時計の秒の中に収まった2つの保存は、偶然にも同じD:文字列を書きます。つまり日付を固定していなくても速いテストは合格し、数か月後に遅いビルドエージェントで失敗します

対策は安上がりです。2つの保存の間に1100ミリ秒待って、タイムスタンプが確実に秒の境界をまたぐようにする。平文、AES-128、AES-256の出力でケースを回し、暗号化の変種には実際のパスワードを渡す。そしてtrueかfalseの代わりに、最初に違いのあるバイトオフセットを報告する。オフセットは1つのオブジェクトを指します。ブーリアンはファイル全体を指すにすぎません

uses
  System.SysUtils, System.IOUtils, HPDFDoc;

function SaveOnce(const Target: string; Encrypt: Boolean;
  KeyType: THPDFKeyType): TBytes;
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := Target;
    Pdf.ReproducibleOutput := True;
    Pdf.VerifySavedOutput := True;   // EndDocの直後に厳格に再読み込み
    if Encrypt then
    begin
      Pdf.OwnerPassword := 'owner';
      Pdf.UserPassword := 'user';
      Pdf.CryptKeyLength := KeyType; // aes128 または aes256
      Pdf.ActivateProtection := True;
    end;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(40, 40, 0, 'reproducible save');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
  Result := TFile.ReadAllBytes(Target);
end;

// 自作ヘルパー。等しければ -1、そうでなければ最初に違うオフセット
function FirstDifference(const A, B: TBytes): Integer;
var
  I, N: Integer;
begin
  N := Length(A);
  if Length(B) < N then
    N := Length(B);
  for I := 0 to N - 1 do
    if A[I] <> B[I] then
      Exit(I);
  if Length(A) <> Length(B) then
    Result := N
  else
    Result := -1;
end;

procedure CheckReproducible(Encrypt: Boolean; KeyType: THPDFKeyType);
var
  A, B: TBytes;
  Offset: Integer;
begin
  A := SaveOnce('run-a.pdf', Encrypt, KeyType);
  Sleep(1100);                     // 別のタイムスタンプ秒を強制
  B := SaveOnce('run-b.pdf', Encrypt, KeyType);
  Offset := FirstDifference(A, B);
  if Offset >= 0 then
    raise Exception.CreateFmt('Saves differ at byte offset %d', [Offset]);
end;

CheckReproducible(False, aes128)、CheckReproducible(True, aes128)、CheckReproducible(True, aes256)を呼びます。平文の実行ではキー種は無視されます。VerifySavedOutputはこのテストで仕事をします。これを有効にすると、EndDocは書いたばかりのファイルを、xref復元を無効にした新しいTHotPDFインスタンスで再読み込みします。文書が保護されていればユーザーパスワードを使い、読み込みに失敗すれば例外を上げます。出力を安定にすると同時に読めなくする変更は、緑のdiffをそのまま通過します。再読み込みがそれを止めるのです

再現可能な暗号化PDFはそれでも安全なのか

いいえ。ReproducibleOutputの下で暗号化された文書に本当の保護はなく、テストディレクトリの外に出るものにこのフラグを立てるべきではありません。AES-256のファイル鍵は32バイトの$5Aです。パスワードは依然として/UEと/OEのラッパーへの門ですが、包まれているのは公開された定数なので、それを知る者はパスワードなしですべてのコンテンツストリームを復号できます。ソルトの固定は、ISO 32000-2 §7.6.4.4.7が「同じパスワードが2つのファイルで同じ/U文字列を生む」ことを避けるために頼っている、ファイルごとの一意性も奪います。AES-256の設定に関する記事は、乱数源が健全なときに暗号化プロパティが何を届けるかを述べています。再現可能フラグの下ではそのどれも当てはまりません。このプロパティはテスト専用の設定値の裏に隠し、製品コード経路が届く設定にしないでください

AES-GCMは別扱いです。CryptKeyLength := aesgcm(PDF 2.0のみ)では、コンポーネントは8バイトのノンス接頭辞を、ReproducibleOutputが何と言おうとシステム乱数源から引き、それから暗号化する文字列やストリームごとに数え上げます。それが正しい振る舞いです。GCMは同じ鍵でノンスが重複すると壊滅します。2つの暗号文は平文のXORを漏らし、攻撃者は認証タグを偽造できるまで回復できます。固定鍵と固定ノンスは、スイートが生むすべてのファイルでその重複を確実にします。それゆえ、暗号化されたゴールデンファイルにはCBCモードで動くaes128かaes256を使います

識別子はより柔らかいトレードオフです。ISO 32000-1 §14.4は、2つ目の/ID要素が変更のたびに変わることを意図しており、再現可能な保存は両スロットに同じ値を書きます。その値はオブジェクトグラフをハッシュするので、違う内容は依然として違う識別子を得ます。/IDをキャッシュのキーにするリーダーや、注釈ストア、フォームデータのサイドカーは、同じ内容の2つの再現可能な保存を依然として1つのファイルとして扱います。テストスイートではそれこそが望むところです

ライターの外に留まる入力

ReproducibleOutputがカバーするのは、ライターが自分で持ち込むエントロピーです。それ以外の入力が、ビルドマシンや、定義上新しい時刻を記録する操作から来ます

HotPDFの再現可能な保存の限界。PDFの日付文字列は依然としてローカルのUTCオフセットを運ぶので、ゴールデンファイルはタイムゾーンをまたぐと違うものになり、SaveIncrementalUpdateは差分が新しい変更である以上、新しい変更識別子を組み立て、パススルーの近道は無効化され、読み込んだファイルは常に全体が書き直されます
再現性は1台のマシン上の実行間、またはタイムゾーンを共有するマシン間で保たれます。再現可能な保存の差分相手は、前回の再現可能な保存であり、元の入力であってはなりません
  • タイムゾーン接尾辞。すべてのPDF日付文字列はローカルのUTCオフセットで終わるので、同じ固定日付があるエージェントではD:20260101000000+08'00'と読め、別のエージェントではD:20260101000000-05'00'と読めます。ゴールデンファイルは1台のマシン上の実行間、またはタイムゾーンを共有するマシン間で保たれます。ファイルがオフィス間を旅するなら、エージェントのタイムゾーンを固定してください
  • インクリメンタル更新。SaveIncrementalUpdateは元の永続識別子を保持し、変更識別子を、前回の識別子、ターゲットパス、ティックカウント、現在時刻から新たに組み立てます。追記セクションは定義上、新しい変更です。比較するのは完全な書き直し同士であって、追記差分ではありません
  • パススルーの近道。SaveLoadedDocumentは通常、未変更で暗号化されていない元ファイルを、直列化し直すのではなくバイト単位でコピーします。再現可能フラグはこの近道を無効にし、順序と識別子のルールを適用するために完全な書き直しを強制します。つまり読み込み済みファイルの再現可能な保存は既定より遅く、入力のコピーには決してなりません。差分を取る相手は前回の再現可能な保存であり、元ファイルではありません
  • 自分で足すタイムスタンプ。AddDocumentAttachmentは、埋め込みファイルの/Paramsの/CreationDateと/ModDate(ISO 32000-1 §7.11.4)を呼び出し時点で刻印し、署名は署名時刻を/Mに記録します。これらの日付は呼び出し側が要求した出来事を記述するので、フラグは手を付けません。添付と署名をバイト単位のケースから外すか、比較でそれらの文字列をマスクしてください
  • インストール済みフォント。TrueTypeサブセットは、テストを実行するマシンにインストールされたフォントファイルから切り出されます。Arialのリリースが違う2つのエージェントは、同じテキストに対して違うグリフプログラムを埋め込み、サブセット接頭辞もそれとともに変わることがあります。フォントファイルはビルドイメージに同梱してください

読み込み済み文書も同じ規則に従います。プロパティを読み込みの前に設定し、編集し、SaveLoadedDocumentで保存します。出力は正規化された書き直しで、次回実行のリファレンスとしてコミットできます

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.ReproducibleOutput := True;   // 読み込みの前に設定
    if Pdf.LoadFromFile('incoming.pdf') <= 0 then
      raise Exception.Create('incoming.pdf did not load');
    // ...テスト対象の編集...
    Pdf.SaveLoadedDocument('normalized.pdf');  // 全体書き直し、キーはソート済み
  finally
    Pdf.Free;
  end;
end;

バイト単位に同一な保存は、ちらつくコンフォーマンスチェックも追い詰めます。ライターが安定した後で、ある実行では合格し次の実行では失敗するチェックは、テストが組み立てるオブジェクトグラフに反応しています。たとえば1つの直接辞書を2つの親に渡すようなケースです。バリデータの前にテスト入力を調べてください。catalogから下へたどる構造diffは、2つの実行の間でどのオブジェクトが動いたかを見せます

上で使ったReproducibleOutput、DeterministicDictionaryOrder、VerifySavedOutput、暗号化の各プロパティは、HotPDF Delphi Componentの一部で、DelphiとC++Builderの両方に同梱されます