技術記事

PDF署名ラッピング:ByteRangeギャップと2つ目の署名

Delphi PDFコンポーネントのHotPDFは、署名ラッピングを拒否するようになりました。v2.759.0から、VerifyLoadedSignatureExとバッチバリデーターの両方が、2つの/ByteRangeセグメントの間のギャップが、デリミッター込みで正確に/Contentsの16進文字列であることを要求します。そしてv2.761.0はAddLoadedSignedSignatureFieldを追加しました。署名済みPDFへ、きれいなインクリメンタルリビジョンとして2つ目の署名を追加できます。この2つの変更は一緒に語られるべきものです。正しい2つ目の署名こそ、より厳格になった検証器が期待するレイアウトだからです

問題を露呈させた状況は、ごく普通です。契約書がベンダーに署名され、その後、最初の署名を乱さずに会署しなければならない承認者へ回されます。2つ目のリビジョンは最初のものの後に追加され、その独自の/ByteRangeは成長したファイル全体を跨ぎ、両方の署名が検証を通るはずです。手でそこへ辿り着くには、インクリメンタルセクションを自分で書く必要があり、まさにそれをやっていたテストフィクスチャは、旧検証器が喜んで受け入れる教科書的な署名ラッピング構造だったのです。検証APIをまだ見たことがなければ、HotPDFでPDFデジタル署名を検証するガイドが、この記事が土台とする基本を扱います

ByteRangeギャップに正確に何が属するのか

ギャップは完全な/Contentsの値だけを含まなければなりません。ISO 32000-1 §12.8.3.3は、16進文字列がその<と>のデリミッター込みで、2つのバイトレンジの間の空間にきっちり収まると述べ、ISO 32000-2 §12.8.1は同じルールを引き継ぎます。Table 252とPAdESの文書が言うのは、ダイジェストがContentsの値を除外する、ということです。「16進の桁だけを除外する」と読めてしまいます。以前のHotPDFリリースはそう読みました。PreparePDFForSigningとストリーミングCMS準備は、山括弧もハッシュしていました。ソースコメントは「括弧は覆われなければならない」と主張していました。ギャップを署名値と比較するバリデーターは、そのレイアウトを無効なバイトレンジとしてフラグを立てます。だからv2.759.0は、両方のデリミッターを署名レンジの外へ動かしました。署名済みの任意のファイルへの手軽な独立検査は、2バイトを見ることです。オフセットByteRange[1]のバイトは<でなければならず、オフセットByteRange[2] - 1のバイトは>でなければなりません

HotPDFで正しく埋められたPDF署名ByteRangeの解剖。最初のレンジはバイト0からファイルを覆い、ギャップは小なりと大切りのデリミッター込みで完全な/Contentsの16進文字列を保持し、2つ目のレンジはトレーラーから末尾までを覆います。ByteRange[1]とByteRange[2] - 1での2つの1バイト検査が、任意の署名済みファイルでレイアウトを確認します
v2.759.0から、デリミッターは署名レンジの外に座ります。だからダイジェストは桁だけを覆い、ギャップはバイト単位で検証できます

空でないギャップ検査はなぜ署名ラッピングを見逃すのか

空でないギャップ検査が証明するのは、何かがダイジェストから外されたことだけで、何が外されたかではありません。それが攻撃面のすべてです。/Contentsのプレースホルダーは数千桁のゼロで予約されますが、実際のCMSコンテナがそれを埋めることはめったにありません。攻撃者はそのゼロパディングの内側で>によって16進文字列を早く閉じ、予約空間の残りへ新しいオブジェクトや偽造リビジョンを書き込み、バイトレンジに触れずに済みます。CMS署名は依然として検証を通ります。署名されたバイトが1つも変わっておらず、レンジは依然として0から始まりファイルサイズで終わるからです。旧HotPDF検証器は、CoversWholeDocumentをTrueに設定したsvValidを報告していました。その間、PDFリーダーは、その未署名の穴の中にあるものを何でもパースします

HotPDFは現在、ギャップをバイト単位で検証すべきデータとして扱います。検証器はギャップを読み、デリミッターを剥ぎ、16進の桁とPDFの空白(タブ、LF、FF、CR、スペース)だけを受け入れ、桁をデコードし、結果が署名辞書の/Contentsと正確に等しいことを要求します。それ以外のものは、結果をsvInvalidByteRangeへ格下げします。検査は単一署名の経路とValidateLoadedSignatureBatchの両方で走ります。後者は独自のカバレッジロジックを持っており、同じ修正が必要でした。v2.759.0より前のHotPDFが作ったファイル、つまり括弧がレンジのすぐ内側に座りギャップが桁だけを保持していたファイルは、今も検証を通ります。アーカイブされた文書が突然赤くなることはありません

署名ラッピングがDelphiで緩く検査されたPDF ByteRangeを突く方法。攻撃者は数千の予約ゼロ桁の内側で16進文字列を早く閉じ、覆われたバイトに触れず未署名ギャップへ偽造リビジョンを書き込みます。旧HotPDF検査はv2.759.0がギャップのバイト単位検証を始めるまで、CoversWholeDocument trueのsvValidを報告していました
空でないギャップが証明するのは、何かがダイジェストから外されたことで、何かではありません。パディングされた穴こそが、攻撃面のすべてです
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

署名済みPDFへ2つ目の署名を追加するには

BeginIncrementalUpdateで署名済みファイルを開き、AddLoadedSignedSignatureFieldを呼び、SaveIncrementalUpdateで保存し、それからクラス関数のTHotPDF.SignPDFWithPFXで準備済みファイルへ署名します。v2.761.0より前、BeginIncrementalUpdateの後にTHPDFPage.AddSignedSignatureFieldを呼ぶというドキュメントのレシピは動けませんでした。インクリメンタルモードではCurrentPageがnilで、ロード済み文書のフィールドへ/Vプレースホルダーを付けられるものがなかったからです。新しいメソッドは、ロード済みページへウィジェットを作り、新規ドキュメント経路が使うのと同じプレースホルダー辞書を/Vの下に吊るします。だから2つの署名経路が1つの直列化を共有します。最初の署名そのものについては、DelphiでPAdESデジタル署名を作成する記事がPFXパイプラインを歩きます

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // ページ0、ポイント単位のウィジェット矩形、CMS用に8192バイト予約
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureFieldは、意図的に兄弟より静かです。他のAddLoaded*フィールド生成器は、AcroFormに/NeedAppearances trueを設定します。ビューアーにフィールドアピアランスの再生成を促すものです。署名済み文書では、その再生成が署名されたコンテンツを書き直しかねないので、新しいメソッドは、ソースがすでにフラグを運んでいない限り、フラグを再び取り除きます。/SigFlagsは元の値 OR 3(SignaturesExistプラスAppendOnly、ISO 32000-1 Table 219)を保ちます。MarkDirtyをページに呼ぶ必要もありません。/Annotsと/Fieldsへの追加はダーティフラグを所有する間接オブジェクトへ伝播し、明示的なページマークは、変わっていないページ辞書を新しいリビジョンへ引きずり込むだけです。リビジョン解析はそれをページ変更として報告します。最後に、プレースホルダーは/Contentsの前に/ByteRangeを書きます。パッチャーはセンチネルの/ByteRangeを先に特定し、前方へ向かって対応する16進文字列を探すからです

署名済みPDFへ会署するHotPDF Delphiのワークフロー。BeginIncrementalUpdateがファイルを開き、AddLoadedSignedSignatureFieldがウィジェットを作り/Contentsプレースホルダーを予約し、SaveIncrementalUpdateが2つ目のリビジョンを追加し、SignPDFWithPFXがそれを埋めます。最初の署名はUnsignedTrailingBytes付きで有効なまま、新しいByteRangeは成長したファイル全体を跨ぎます
リビジョンごとに1つのプレースホルダー、両方の署名経路で同じ直列化が準備とパッチを担います。より厳格な検証器が期待するきれいなレイアウトです

外部署名者やHSMがCMSを作るとき何が変わるか

ワークフローは変わりませんが、オフセットが仕様の言う意味を持つようになりました。PreparePDFForSigningは2つの0ベースレンジを返し、そのギャップは/Contents文字列全体です。そしてContentsHexStartは、AnsiStringの中の最初の16進桁の1ベースインデックスです。短いCMSは、閉じの>の前に、末尾へ0でパディングされます。PreparePDFForSigningは見つけた最初の未パッチのセンチネルをパッチするので、リビジョンごとにプレースホルダーを正確に1つ用意し、ファイルに先行する署名がすでに存在するときは、検索ベースのInsertSignatureHexより、返されたオフセットでのInsertSignatureHexAtを選んでください

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // 自前のヘルパー
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // ギャップは16進文字列全体:'<'がレンジ1を終え、'>'がレンジ2に先立つ
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // 自前のCMS署名器、hex DER
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // 自前のヘルパー
end;

新しい検査の限界はどこか

ギャップ検査は1つの特定の穴を塞ぐもので、過大宣伝すべきではありません。svValidは今も、バイトの完全性と、埋め込み証明書に一致する鍵を意味します。その証明書への信頼は別の判断です。ギャップは、検証器がソースバイトを持つときだけ検証されます。VerifyLoadedSignatureExはロード済みファイルから読み、TStreamオーバーロードはあなたから受け取ります。会署されたファイルの最初の署名では、CoversWholeDocumentは正しくFalseです。追加されたリビジョンが署名だけを足したのかページも変えたのかは、HotPDFのDocMDP、FieldMDP、リビジョン解析への質問です。付属のPDF MAC検査がオフセットを<と>の位置と比べることにも注意してください。新旧どちらのレイアウトも受け入れます。v2.759.0以前のオフセットをハードコードしたあなた自身のツールは、新しく署名されたファイルに出会うと、まず失敗します

DelphiやC++BuilderのアプリケーションがPDFへ署名し、会署し、監査するなら、一番安全な道は、1つのライブラリに同じレイアウトの生成と検証を任せることです。ネイティブDelphi PDFコンポーネントのHotPDFは、より厳格なギャップ検証、インクリメンタルな2つ目の署名、そして上で示した外部署名者フックを、1つのコンポーネントへ同梱します