技術記事

HotPDFを使用してDelphiでPDFのデジタル署名を検証する

HotPDFは、v2.259.0で導入された3つのTHotPDFメソッド、すなわちGetLoadedSignatureInfoVerifyLoadedSignature、およびVerifyLoadedSignatureExを介して、ロードされたPDFドキュメントのデジタル署名を検証します。このコンポーネントは、元のファイルの/ByteRangeセグメントを再ハッシュし、CMSのmessageDigest属性をチェックし、埋め込まれた署名者証明書に対してRSA PKCS#1 v1.5検証を実行して、ドキュメントのバイトデータが改ざんされていない場合にsvValidを返します

状況自体はよくあるものですが、その重要性は極めて高いものです。取引先から署名済みの契約書が返送され、ワークフローでそれを保管する際、「これは私たちが送信したのとバイト単位で完全に同一のドキュメントか、また主張されている証明書によって正しく署名されているか」という最も重要な疑問が生します。これをコードで判定することが、署名プロセスにおける「検証側」の処理です。署名データを作成して埋め込む「署名側」の処理については、姉妹編の記事であるHotPDFによるPAdESデジタル署名の作成で詳しく説明しています。本記事ではその逆の方向、すなわちすでに署名された状態で届いたPDFに対して、Acrobatの緑色のチェックマークのスクリーンショットに頼るのではなく、プログラムで判定を下す方法について解説します

署名されたPDFはどのようにして改ざんされていないことを証明するのか?

PDFの署名は、抽象的な「ドキュメント」という概念ではなく、ファイル内の特定のバイト範囲を保護します。ISO 32000-1 §12.8はその仕組みを定義しています。署名フォームフィールドは辞書を保持し、その中の/ContentsエントリにはCMS SignedDataコンテナ(RFC 5652)が含まれ、/ByteRange配列は§12.8.1に従って署名がカバーする正確なファイル領域を指定します。この配列はオフセットと長さのペアのリストであり、実際には2つのセグメント、すなわち/Contentsの16進数文字列の前のすべての領域と、その後のすべての領域を示します。署名値自体が署名対象に自身を含めることはできないため、その「穴」を避けてファイルデータがハッシュ化されます

この設計はAPI全体の動作を決定づけています。すなわち、検証はディスク上に存在する状態と完全に同じ、元のシリアル化されたバイトデータに対してハッシュ値を計算しなければなりません。オブジェクトモデルに一度パースされたデータは、変更を加えていないドキュメントであっても再シリアル化の過程でバイトデータが変化するため、検証には使用できません。そのため、HotPDFはメモリ内のデータ表現ではなく、ドキュメントの読み込み元となったソースファイル、または呼び出し側から提供された生のバイトデータを含むTStreamに対して検証を実行します

検証実行前の署名メタデータの読み取り

GetLoadedSignatureInfoは、ドキュメントのバイトデータに触れることなく、署名辞書とCMSコンテナを解析します。これは、署名者と署名日時のみを表示したい場合に最適です。署名フィールドはフォームフィールドの順序で0からインデックス付けされ、GetLoadedSignatureFieldCountで総数を取得できます。返されるTHPDFSignatureInfoレコードは、フィールド名、/SubFilter、署名者証明書の共通名(CN)、主体者(Subject)および発行者(Issuer)の識別名(DN)、シリアル番号、有効期限、署名時刻(署名属性がある場合はそこから取得し、なければ辞書の/Mエントリから取得)、ダイジェストアルゴリズム名、および/Reason/Location/ContactInfo文字列を保持します。そのStatusメンバーは「解析済み、未検証」を示すsvNotVerifiedになります

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('signed-contract.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Info := Pdf.GetLoadedSignatureInfo(I);
      Writeln('Field:     ', Info.FieldName);
      Writeln('Signer:    ', Info.SignerName);
      Writeln('Issuer:    ', Info.IssuerDN);
      Writeln('Algorithm: ', Info.HashAlgorithm);
      Writeln('SubFilter: ', Info.SubFilter);
    end;
  finally
    Pdf.Free;
  end;
end;

暗号検証の実行

VerifyLoadedSignatureExは、ファイルからロードされたドキュメントの完全な検証を実行し、必要な情報を格納したレコードを1回の呼び出しで返します。ソースファイルを再度開き、SignerInfoのダイジェストアルゴリズムで/ByteRangeセグメントをハッシュ化し、その結果をmessageDigest署名属性(RFC 5652 §5.4)と比較し、署名属性をDER SET OF形式に再エンコードしたデータに対してRSA検証を実行します。署名に署名属性が含まれていない場合は、代わりにドキュメントのハッシュ値に対して直接RSAチェックを実行します。サポートされている署名は、SHA-1、SHA-256、SHA-384、またはSHA-512ダイジェストを使用するRSA PKCS#1 v1.5であり、これは主要な署名ツールで生成されるadbe.pkcs7.detachedおよびETSI.CAdES.detachedのサブフィルタをカバーします

var
  Status: THPDFSignatureVerifyStatus;
  Info: THPDFSignatureInfo;
begin
  Status := Pdf.VerifyLoadedSignatureEx(0, Info);
  case Status of
    svValid:
      if Info.CoversWholeDocument then
        Writeln('Valid; signature covers the whole file')
      else
        Writeln('Valid; file was extended after signing');
    svDigestMismatch:
      Writeln('Document bytes changed after signing');
    svSignatureInvalid:
      Writeln('RSA check failed over signed attributes');
    svUnsupportedAlgorithm:
      Writeln('Non-RSA key or unknown digest algorithm');
    svMalformed:
      Writeln('CMS container could not be parsed');
    svSourceUnavailable:
      Writeln('No source bytes; use the TStream overload');
  end;
end;

外部から原因不明に見える失敗を理解する上で、2つの実装詳細を知っておく価値があります。第1に、署名属性のチェックはエンコーディングに対して極めて厳密である点です。ファイル内部では属性に[0] IMPLICITタグが付与されていますが、署名はそれらのDER SET OF形式に対して計算されているため、検証ツールはRFC 5652 §5.4の要求通り、ハッシュ計算の前にタグを付け替えます。ファイル内のバイトデータをそのままハッシュ化する簡易的な検証処理では、正しく署名されたドキュメントであってもすべてエラーと判定してしまいます。第2に、/Contentsは慣例として予約された領域サイズに合わせてゼロパディングされているため、検証ツールは解析前にDERブロブをその外側のSEQUENCEの実際の長さに切り詰めます。末尾にゴミのように見えるゼロが並んでいるのは正常であり、破損ではありません。証明書のインポート時におけるこれと同様のASN.1解析の危険性については、HotPDFにおけるPKCS#12およびASN.1のセキュリティ強化で取り上げています

有効な署名は実際に何を保証するのか?

svValidが意味するのは、「/ByteRangeで指定されたバイトデータのハッシュ値が署名者の署名した値と一致し、CMSコンテナに埋め込まれた証明書の公開鍵によって署名が検証された」という事実のみです。これはバイトデータの整合性と鍵の紐付けのみを保証し、それ以上のことは保証しません。証明書チェーンや信頼性の検証は、HotPDFの検証機能の対象外です。ルート証明書までのチェーンの走査、失効チェック、トラストストアの参照などは行いません。改ざんされたドキュメントに対して攻撃者が自己署名証明書で再署名した場合であっても、数学的な整合性は保たれるためsvValidと判定されます。署名者が本当に本人であるか、またその署名者を信頼すべきかどうかは、組織の証明書ホワイトリスト、Windowsの証明書ストア、または認証局などの上位レイヤーで判断する必要があります

CoversWholeDocumentフラグは、より細かな隙間を警戒するためのものです。署名は常にその/ByteRangeのみを保護し、PDFのインクリメンタルアップデートの仕組みにより、署名の後にコンテンツを追加しても署名は無効化されません。これは設計通りの動作であり、複数署名ワークフローが機能する原理です。このフラグは検証中に計算され、2つのセグメントと/Contentsの隙間を合わせた範囲がファイル全体に及んでいる場合にのみTrueになります。svValidでありながらCoversWholeDocumentがFalseである場合、署名された時点のリビジョンは改ざんされていませんが、ファイルにはその後にデータが追加されています。その追加データが何を変更したかを受け入れるかどうかは、個別のワークフローで判断する必要があります

ストリームからロードされたドキュメントや暗号化されたドキュメントのソースバイトの提供

引数を持たないVerifyLoadedSignatureおよびVerifyLoadedSignatureExは、コンポーネントがドキュメントの読み込み元ファイルを記憶していることに依存しています。ストリームからドキュメントを読み込んだ場合、再オープンするためのファイル名は存在しません。これは、暗号化されたドキュメントで使用されるパスワード再ロード処理(HotPDFによるAES-256 PDF暗号化で説明しているワークフロー)を実行した後でも同様です。どちらの場合も、ファイルベースのオーバーロード関数は処理を推測することなくsvSourceUnavailableを返します。解決策はTStreamを受け取るオーバーロード関数を使用することであり、これにより、保存されている元の生のバイトデータ(保持しているファイル、メモリバッファ、データベースのバイナリデータなど)を直接渡すことができます

var
  Src: TFileStream;
  Status: THPDFSignatureVerifyStatus;
  Info: THPDFSignatureInfo;
begin
  // Stream-loaded document: the component holds no source
  // file name, so supply the original bytes yourself.
  Src := TFileStream.Create('signed-contract.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Status := Pdf.VerifyLoadedSignature(0, Src, Info);
    if Status <> svValid then
      Writeln('Verification failed: ', Ord(Status));
  finally
    Src.Free;
  end;
end;

検証できない場合のステータス報告

「有効」と「無効」しか判別できない検証処理は、単に解析できない形式のドキュメントを誤って「無効」と報告してしまいます。そのため、ステータスの列挙型はUIが区別すべきケースを細分化しています。svDigestMismatchは署名後にドキュメントのバイトデータが変更されたことを意味し、改ざんの明確な兆候です。svSignatureInvalidはバイトデータのハッシュ値は一致しているもののRSAチェックに失敗したことを示し、署名値の破損や偽造を意味します。svUnsupportedAlgorithmはECDSA鍵や認識できないダイジェストに対する適切な判定であり、署名自体は完全に有効であってもHotPDFが検証できないことを示します。これを「無効」と報告すると、問題のないドキュメントを誤って排除してしまいます。svMalformedは、CMSコンテナをパースできなかったことを示します。一括チェック用として、VerifyAllLoadedSignaturesは、少なくとも1つの署名フィールドが存在し、かつそれらすべてがsvValidと検証された場合にのみTrueを返します。これは、問題のあるドキュメントを確実に排除したいアーカイブ取り込みパイプラインなどで便利に使用できる単一のブーリアン値です

署名の検証、PAdES署名の付与、AES-256暗号化、およびロード済みドキュメントの編集APIは、すべて外部DLLに依存しないDelphiおよびC++Builder向けの同一のネイティブVCLライブラリに含まれています。機能の詳細一覧とサポートされているIDEのバージョンは、HotPDF Componentの製品ページに掲載されています