技術記事

DelphiでのPDFiumを使用したPAdES B-BによるPDF署名

PDFiumコンポーネントは、SignPadesメソッドを介してPDFにPAdES B-Bデジタル署名を行います。ドキュメントをロードし、署名されたバイト範囲をハッシュ化し、CAdES CMS構造を構築し、増分更新として署名を追加します。暗号化バックエンドはWindows専用であるため、署名する前にすべての呼び出しをPadesCryptoAvailableで保護してください

よくある状況です。契約書のPDFが届き、法務部門から送信前にデジタル署名を行うよう求められます。ドキュメントのレンダリングや検査にすでに使用しているのと同じPDFiumビルドを使用しようとしますが、PDFiumは署名を一切書き込めないことに気づきます。PDFiumの署名APIは厳密に読み取り専用です。PDFiumコンポーネントは、ハッシュ関数からバイトレベルの注入に至るまで、Pascalで署名パイプライン全体を実装することでこのギャップを埋めており、この記事ではそのパイプライン全体を解説します

なぜPDFiumはデジタル署名を書き込めないのか

PDFiumは署名を読み取り専用オブジェクトとして公開し、署名を作成する機能は提供していません。FPDFSignatureObj_*ファミリーを使用すると、既存の署名を列挙し、その/Contentsを読み取り、/ByteRangeを検査できますが、署名辞書を構築し、/Contentsスロットを予約し、バイト範囲を書き込むような機能はありません。増分保存(FPDF_INCREMENTALを指定したFPDF_SaveAsCopy)は存在しますが、署名用のフックは備わっていません。したがって、PDFiumの上でPDFに署名するコンポーネントは、署名バイト自体をすべて生成する必要があります。これが、PDFiumコンポーネントが3つの純粋なPascalユニットからその仕組みを構築している理由です。FPC 3.2.2にはmd5とsha1が含まれていますが、SHA-2は一切含まれていません。また、DelphiのSystem.Hash SHA-256 APIはFPC与ソース互換性がないため、FPdfSha256はコンパイラの分岐なしにすべてのCMSコードパスを単一のTSHA256Digest型に維持する、自己完結型のFIPS 180-4実装です。FPdfAsn1はCMS構造が必要とする DERエンコーダとリーダーを提供し、FPdfCmsはその両方の上にCAdES SignedDataを構築します

DelphiでPDFにデジタル署名する方法

ドキュメントをロードし、証明書のサムプリントを指定してSignPadesを呼び出します。PDFiumコンポーネントは、そのサムプリントを「現在のユーザー」の「個人(MY)」証明書ストアと照合して解決し、一致する証明書とその秘密鍵を取得して、指定されたパスに署名コピーを書き込みます

uses
  SysUtils, PDFium;

procedure OpenDocument(const AFileName: string);
var
  Pdf: TPdf;
begin
  // Deploy pdfium.dll next to the executable, matched to the build target:
  //   <AppDir>\DLLs\Win32\pdfium.dll   for a 32-bit host process
  //   <AppDir>\DLLs\Win64\pdfium.dll   for a 64-bit host process
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := AFileName;
    try
      Pdf.Active := True;   // first use lazily binds pdfium.dll
    except
      on E: EPdfError do
        // The message already names the real cause: a 32/64-bit mismatch
        // (ERROR_BAD_EXE_FORMAT) or a pdfium.dll older than this binding
        raise Exception.CreateFmt('PDF engine did not start: %s', [E.Message]);
    end;
    if Pdf.Active then
      RenderFirstPage(Pdf);
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailableは、常に最初に確認する検出機能です。Windows上ではTrueを返し、crypt32/ncryptバックエンドが有効になります。他のプラットフォームではFalseを返し、署名呼び出しはEPadesCryptoを発生させます。このガード処理を必須とすることで、LinuxまたはmacOSビルドが機能しないパスで実行時にクラッシュするのを防ぎます。サムプリント自体は証明書のSHA-1ハッシュであり、Windowsの証明書マネージャーの詳細タブに表示される値と同じです。ソースコード内に鍵資料を配置することなく、特定の署名者を指定できます

CMSに含まれるもの:署名付き属性与RFC 5652

PAdES基準署名は、ファイルに対する生のRSA署名ではなく、必須の署名付き属性セットを保持するCAdES CMS SignedData構造です。FPdfCms.BuildSignedDataは、まさにそのセット(content-type、message-digest、およびハッシュによって署名を署名者証明書に関連付けるESS属性であるsigning-certificate-v2)を出力します。ここの1つの詳細が、手作りのCMS実装のほとんどを失敗させます。RFC 5652 §5.4は、署名付き属性のダイジェストをDER SET OFエンコード(タグ0x31)に対して計算することを要求していますが、同じ属性はSignerInfo内ではIMPLICIT [0]タグ(0xA0)の下で渡されます。PDFiumコンポーネントは、属性セットを一度エンコードし、0x31フォームをダイジェスト化した後、出力用に先頭のタグバイトのみを0xA0に書き換えます。これにより、ツリーを二度走査することなく、1つのバッファで両方の役割を果たします

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // signer in the MY store
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // hex width of /Contents

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

オプションのオーバーロードは、署名辞書メタデータ(ISO 32000-1 §12.8.1が定義するReasonLocationContactInfo、およびName。すべてオプションで、署名値辞書に書き込まれます)を追加します。1つの制限事項として、注意が必要な点があります。CAdESのcommitment-type-indication署名付き属性を追加するためにCommitmentTypeOidを設定する場合は、Reasonも設定しないでください。ETSI EN 319 142-1 §6.3は、異なる手段で同じ意図を表現することになるため、両方を保持することを禁止しています

ByteRangeと/Contentsスロットはどのように連動するのか

署名は、署名自体を保持するバイトを除くファイル全体をカバーする必要があり、PAdESはこの循環参照を、SignPadesBytesが精密に管理する固定幅のプレースホルダーで解決します。ContentsSizeバイト(デフォルトは16384で、一般的なCMS SignedDataよりも十分に大きい)の/Contents 16進文字列を予約し、増分更新をシリアル化して正確なスロットオフセットを特定し、スロットを挟む2つのスパン(16進文字列の開始デリミタより前のすべてと、終了デリミタより後のすべて)として/ByteRangeを計算します。SHA-256はこれら2つのスパンのみに対して実行されます。完成したCMSは予約されたスロットに16進エンコードされ、固定幅にゼロパッドされ、相互参照の更新が追加されます。幅があらかじめ固定されているため、スロットを埋めても下流のバイトがずれることはなく、これがバイト範囲が有効に保たれる理由そのものです。元のドキュメントのバイトデータはそのまま保存されるため、ISO 32000-1 §12.8.1の増分署名が要求する通り、同じファイルに対する以前の署名も無傷で残ります

Windows CNGバックエンドとその制限事項

PDFiumコンポーネントはWindows上でのみ署名を行いますが、この制限は意図的なものです。FPdfCryptoWinはcrypt32.dllとncrypt.dllを動的にバインドし、コンパイル時のDLL依存関係を追加しません。署名チェーンは標準的なCNGです。個人(MY)ストアを開き、ハッシュによって証明書を見つけ、CryptAcquireCertificatePrivateKeyを介してその秘密鍵ハンドルを取得し、NCryptSignHashを呼び出します。PKCS#1 v1.5を使用したRSA、RSA-PSS、およびECDSAがすべてサポートされています。ECDSAは他の形式にはない1つの修正が必要です。NCryptSignHashは生のIEEE P1363 r/sペアを返しますが、CMSはDERのECDSA-Sig-Value SEQUENCEを期待するため、バックエンドはRFC 5480に従ってそれを再エンコードします

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // no signing backend on this platform

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

実用上の結果として、秘密鍵はWindowsの証明书ストアに存在する必要があります。PFXファイルに保持されている証明書は、現在のユーザーのストアにインポートした後にのみ機能し、その時点でそのサムプリントがSignPadesに渡す値になります。このリリースにはPKCS#11やHSMのパス、およびソフトウェアキーファイルバックエンドはないため、PadesCryptoAvailableがFalseを返す場合、そのマシン上では単に署名を行うことはできません

PAdES B-Bの限界

PAdES B-Bは、4つのPAdESレベルの最低ラインである基準レベルです。誰が署名したかと、それ以降バイトが変更されていないことのみを証明し、それ以上のことは証明しません。B-B署名には信頼されたタイムスタンプが含まれていないため、署名がいつ行われたかを証明することはできません。また、失効データも埋め込まれていないため、何年も後の検証者は証明書チェーンとそのステータスを自身で取得する必要があります。これらのギャップこそが、上位レベルが解消するものです。監査人が受け入れる署名時刻が必要な場合は、長期検証用のRFC 3161タイムスタンプとDSSの追加によって署名をB-T以上に移行します。完成した署名を読み戻してどのレベルに達したかを確認したい場合は、PDF署名与PAdESレベルの検査が対になるツールです。そして、署名を行う前に、PDFセキュリティリスクの監査を行うことで、署名しようとしているドキュメントの内容について把握できます

ここで紹介したSignPadesメソッドは、PDFiumが標準で提供する読み取り専用の署名検査とともに、DelphiおよびC++Builder用のPDFiumコンポーネントに同梱されています