技術記事

DelphiでPDFiumを使いPAdES B-B署名を付与する

PDFium ComponentはSignPadesメソッドを通してPDFにPAdES B-Bの電子署名を付けます。文書を読み込み、署名対象のバイト範囲をハッシュし、CAdESのCMS構造を組み立て、署名を増分更新として追記します。暗号のバックエンドはWindows専用なので、署名する前にはどの呼び出しもPadesCryptoAvailableで守ってください

状況には見覚えがあるはずです。契約書のPDFが机に届き、法務は送付前に電子署名を求め、あなたは描画や点検にすでに使っているのと同じPDFiumビルドへ手を伸ばし、そしてPDFiumがそもそも署名を書けないことに気づきます。その署名APIは厳密に読み取り専用です。PDFium Componentは、ハッシュ関数からバイト単位の注入まで署名の処理系まるごとをPascalで所有することでその隙間を埋めます。本稿はその処理系を端から端までたどります

PDFiumが電子署名を書けないのはなぜですか?

PDFiumは署名を読み取り専用のオブジェクトとして見せ、署名を作るものは何も提供しません。FPDFSignatureObj_*の一族は既存の署名を列挙し、その/Contentsを読み、/ByteRangeを調べさせてくれますが、署名辞書を組み立てたり、/Contentsの枠を確保したり、バイト範囲を書いたりする対応物はありません。増分保存は存在しますが(FPDF_INCREMENTAL付きのFPDF_SaveAsCopy)、署名のためのフックは持ちません。したがってPDFiumの上でPDFに署名するコンポーネントは、署名のバイトをすべて自分で生成しなければならず、だからこそPDFium Componentは純Pascalの3つのユニットからその仕掛けを組み立てています。FPC 3.2.2はmd5とsha1を同梱しますがSHA-2はまったく持たず、DelphiのSystem.HashのSHA-256 APIはFPCとソース互換ではないので、FPdfSha256はFIPS 180-4に沿った自己完結の実装であり、CMSのすべての経路をコンパイラ分岐なしに単一のTSHA256Digest型の上で保ちます。FPdfAsn1はCMS構造が必要とするDERの符号化器と読み取り器を供給し、FPdfCmsはその両方の上にCAdESのSignedDataを組み上げます

DelphiでPDFに電子署名するにはどうしますか?

文書を読み込み、証明書のサムプリントを添えてSignPadesを呼びます。PDFium Componentはそのサムプリントを現在のユーザーの「MY」証明書ストアと突き合わせ、一致する証明書とその秘密鍵を取り出し、指定したパスへ署名済みの控えを書き出します

DelphiにおけるPAdES B-B署名の処理系の図。PDFium ComponentがWindows CNGバックエンドを調べ、署名対象のバイト範囲をハッシュし、CAdESのCMSを組み立て、増分更新を追記する
PDFium Componentは、プラットフォームの確認からSHA-256のハッシュとCMSの組み立てを経て増分の追記に至るまで、PAdES B-Bの処理系をまるごと所有します
uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES signing requires the Windows CNG backend');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // 署名する対象の文書
    Pdf.Active := True;
    // 第2引数: 現在のユーザーの「MY」ストアにある証明書のSHA-1
    // サムプリント。第1引数: 署名済みの控えの書き出し先。
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('Signing failed');
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailableは毎回まず確かめる調べ役です。Windowsではこれが真を返し、crypt32 / ncryptのバックエンドが生きています。それ以外のプラットフォームでは偽を返し、署名を呼べば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は、署名属性のダイジェストをタグ0x31のDER SET OF符号化に対して計算することを求める一方、同じ属性はSignerInfoの内側ではIMPLICIT [0]タグの0xA0の下を運ばれます。PDFium Componentは属性の集合を一度だけ符号化し、0x31の形をダイジェストしてから、出力用に先頭のタグバイトだけを0xA0へ書き換えます。こうして1つのバッファが両方の役を果たし、木構造を二度なぞる必要がありません

DelphiのPDFium Componentが組み立てるPAdESのCMSにおける署名属性のタグ付けの図。0x31のSET OF形式をダイジェストし、SignerInfoの内側では先頭のバイトだけが0xA0になる
RFC 5652 §5.4はタグ0x31のSET OF符号化をダイジェストしますが、同じ属性のバイト列はSignerInfoの内側ではIMPLICIT [0]タグの0xA0の下を運ばれます
var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // MYストアの署名者
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // /Contentsの16進の幅

    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が定める署名辞書のメタデータを加えます。Reason、Location、ContactInfo、Nameで、いずれも任意であり、いずれも署名値の辞書へ書き込まれます。踏みやすい制約が1つあります。CAdESのcommitment-type-indication署名属性を加えるためにCommitmentTypeOidを設定するなら、Reasonも併せて設定してはいけません。ETSI EN 319 142-1 §6.3は両方を運ぶことを禁じています。2つは同じ意図を別の手段で表しているからです

ByteRangeと/Contentsの枠はどう噛み合いますか?

署名は、署名そのものを収めるバイトを除いたファイル全体を覆わなければならず、PAdESはこの堂々巡りを、SignPadesBytesが精密に管理する固定幅の場所取りで解きます。まずContentsSizeバイトの/Contents16進文字列を確保し(既定は16384で、標準的なCMS SignedDataより十分に大きい)、増分更新を直列化して枠の正確な位置を突き止め、それから枠を挟む2つの区間として/ByteRangeを計算します。16進文字列の開き区切りより前のすべてと、閉じ区切りより後のすべてです。SHA-256はその2つの区間だけを走ります。仕上がったCMSは16進に符号化されて確保済みの枠に収まり、固定幅までゼロで埋められ、相互参照の更新が追記されます。幅が最初から固定なので枠を埋めても後続のバイトは一切ずれません。これこそバイト範囲が有効なままでいられる理由のすべてです。元の文書のバイトはそのまま保たれるので、同じファイルに先に付いていた署名も、ISO 32000-1 §12.8.1の増分署名が求めるとおり無傷で生き残ります

DelphiのPDFium Componentで署名したPDFのByteRangeの配置の図。ハッシュされる2つの区間が確保済みの16進Contents枠と追記された相互参照の更新を挟む
2つのByteRangeの区間が固定幅の16進の枠を挟むので、署名を書き込んでもダイジェストが既に数えたバイトはずれません

Windows CNGバックエンドとその限界

PDFium Componentが署名するのはWindowsの上だけで、その線引きは意図的なものです。FPdfCryptoWinはcrypt32.dllとncrypt.dllを動的に結び付けるのでコンパイル時のDLL依存は増えず、署名の連鎖は標準的なCNGです。MYストアを開き、ハッシュで証明書を探し、CryptAcquireCertificatePrivateKeyで秘密鍵のハンドルを取得し、NCryptSignHashを呼びます。PKCS#1 v1.5のRSA、RSA-PSS、ECDSAのいずれにも対応します。ECDSAだけは他にはない後始末が要ります。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;   // このプラットフォームには署名のバックエンドがない

  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が偽を返すなら、そのマシンで署名する手立ては単に存在しません

PAdES B-Bが止まる場所

PAdES B-Bはベースライン、すなわちPAdESの4つの水準の床です。誰が署名したか、そしてそれ以降バイトが変わっていないことを証明しますが、それ以上のことはしません。B-Bの署名は信頼できるタイムスタンプを運ばないので、署名がいつ行われたかは証明できず、失効情報も埋め込まないので、何年も後の検証者は証明書の連鎖とその状態を自分で取りに行かなければなりません。この隙間こそ、上位の水準が塞ぐものです。監査人が受け入れる署名時刻が必要なら、RFC 3161のタイムスタンプと長期検証のためのDSSを加えることで署名はB-T以上へ進みます。仕上がった署名を読み返してどの水準に達したかを確かめたいなら、PDF署名とそのPAdES水準を点検するが対になる道具です。そして何かに署名する前には、PDFのセキュリティ上のリスクを監査することが、これから自分の名前を載せようとしている相手を教えてくれます

ここで示したSignPadesの各メソッドは、Delphi / C++Builder向けのPDFium Componentに同梱されており、PDFiumが最初から備える読み取り専用の署名点検と並んで使えます