技術記事

PDF Library for Delphi: Delphi での PAdES signing and validation

1 つの PAdES 署名を検証するということは、3 つの独立した項目を確認するということであり、ビューアの緑色のチェックマークが教えてくれるのはそのうち 3 番目についてだけである。まず、/ByteRange 配列が正しいバイトをカバーしていなければならない。そこに記された各スパンは、CMS ダイジェストの計算対象となった入力を正確に再構成でき、署名済みのバイトがその外側に取り残されていてはならない。次に、CMS の内部にある証明書は信頼するルートへとチェーンし、PAdES が要求する署名済みの signing-certificate 属性を保持していなければならない。そして、プロファイルがタイムスタンプを主張している場合は、RFC 3161 トークンが証明書の失効前のある時点に署名値を結び付けていなければならない。Acrobat はこの 3 つを 1 つのアイコンにまとめてしまうが、適合性チェッカーはそれらを個別に扱う。これらのファイルを生成するコードもそうあるべきである。losLab PDF Library(PDF Library for Delphi)は、その署名側の処理、タイムスタンプの再埋め込み、そして信頼する前に ByteRange を検査するための監査用呼び出しを提供する

最初の PAdES 実装のほぼすべてがつまずく区別が 1 つあるので、コードの前に述べておく価値がある。/SubFilter /adbe.pkcs7.detached で書かれた署名は、Acrobat が有効だと報告する、まったく健全な ISO 32000-1 §12.8 準拠の署名である。しかしそれは PAdES 署名ではない。ETSI EN 319 142-1 は、どのベースラインレベルであっても ETSI.CAdES.detached を要求するからである。eIDAS の適合性チェッカーは、暗号技術としては同一であるにもかかわらず、前者を拒否し後者を受理する。プロファイルは文書が自らについて行う申告であり、その申告を正しくすることが PDF Library for Delphi での 1 回の呼び出しに集約されている

何が PDF 署名を PAdES 署名に変えるのか

ETSI EN 319 142-1 は、CMS フォーマットの上に積み重なる 4 つのベースラインレベルを定義している。PAdES-B-B は入り口であり、ETSI.CAdES.detached SubFilter と署名済みの signing-certificate 属性を持つ、PDF 署名フィールド内の CAdES 署名である。PAdES-B-T は、署名値に対する RFC 3161 タイムスタンプを追加し、誰にも遡って日付を偽ることのできないある時点より前にその署名が存在していたことを証明する。PAdES-B-LT は、検証に必要な証明書、CRL、OCSP レスポンスを Document Security Store に埋め込み、発行元の CA がインフラを廃止した後でもファイルが検証可能な状態を保てるようにする。PAdES-B-LTA は、アルゴリズムが弱体化していく中でも積み上がった証拠を再保護するドキュメントタイムスタンプでこの積み重ねの最上段を締めくくる

PDF Library for Delphi はこれらの概念を SignProcess API 上にマッピングしている。プロファイルの目印になるのは SetSignProcessCustomSubFilter である。ポリシー上コミットメントタイプの表示(発信元証明、承認証明、あるいは 1 から 6 まで番号付けされたほかの ETSI 識別子のいずれか)が必要な場合は、SetSignProcessCommitmentType を通す。明示的な署名ポリシーは SetSignProcessSignaturePolicy で付加し、これはポリシー OID とそのダイジェストを受け取る。1 つだけ注意すべき既定値がある。ダイジェストアルゴリズムを自動のままにしておくと、ライブラリは ETSI と adbe.pkcs7.detached の署名には SHA-256 を選び、レガシーな adbe.pkcs7.sha1 経路のときだけ SHA-1 にフォールバックする。それでも明示的に設定しておくこと。監査人はどのハッシュを使ったかを尋ねてくるものであり、コード中の明示的な値のほうが、マニュアルを読み直さないと説明できない既定値よりも弁明しやすい

PDF Library for Delphi で構築する PAdES ベースラインレベル B-B、B-T、B-LT、B-LTA の階段図。ETSI.CAdES.detached コアの上に各レベルがタイムスタンプ、DSS 証拠、更新可能なドキュメントタイムスタンプを追加する様子
ETSI の各ベースラインレベルは、同じ CAdES コアの上に保証を 1 つずつ積み重ねます。署名属性から、更新可能なドキュメントタイムスタンプまでです

ベースライン署名を生成する

フラット API は署名処理を単発のステートマシンとして駆動する。ソースファイルに対してプロセスを開き、設定し、出力ファイルへ完了させ、結果コードを読み取る。以下の一連の流れは、SHA-256 を使った PAdES-B-B 署名を生成する。最も重要な行は署名そのものとは無関係であり、意図的に大きく確保された /Contents の予約である。なぜなら、後からこの署名にタイムスタンプを追加する必要が生じたとき、それだけは後から変更できないからである

var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('invoice.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open source PDF');
    Pdf.SetSignProcessField(SignId, 'Sig1');
    Pdf.SetSignProcessPFXFromFile(SignId, 'company.pfx', PfxPassword);
    Pdf.SetSignProcessInfo(SignId, 'Approved', 'Vienna', 'billing@example.com');
    Pdf.SetSignProcessCustomSubFilter(SignId, 'ETSI.CAdES.detached');
    Pdf.SetSignProcessDigestAlgorithm(SignId, 2);          // SHA-256
    Pdf.SetSignProcessReserveContentsBytes(SignId, 8192);  // room for a timestamp later
    Pdf.EndSignProcessToFile(SignId, 'invoice-signed.pdf');
    if Pdf.GetSignProcessResult(SignId) <> 1 then
      raise Exception.CreateFmt('signing failed, code %d',
        [Pdf.GetSignProcessResult(SignId)]);
    Pdf.ReleaseSignProcess(SignId);
  finally
    Pdf.Free;
  end;
end;

NewSignProcessFromFile は、ソースをそもそも開けなかった場合に 0 を返す。その後は GetSignProcessResult が、実運用で実際に起こる失敗のパターンを切り分けてくれる。4 は誤った PDF パスワード、7 は誤った PFX パスワード、9 は秘密鍵を含まない証明書ファイル、10 は書き込み不能な出力パス、11 は署名バイトの適用中の失敗である。この数値コードを入力ファイル名の隣にログとして残しておけば、あいまいなサポートチケットが 1 分で診断できるものに変わる

ライブラリが取得してくれない RFC 3161 タイムスタンプを追加する

PDF Library for Delphi は TSA クライアントを一切同梱していないが、これは欠落ではなく意図的な境界である。ライブラリはタイムスタンプ局が対署名すべきハッシュを計算し、その後で拡張済みの CMS を再埋め込みする。その間の HTTP 通信と CMS への手術は呼び出し側の仕事になる。この分離には確かな技術的理由がある。未署名属性を追加するとされている Windows CryptoAPI の制御機能 CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR は、PAdES が使う分離型の SignedData レイアウトに対しては CRYPT_E_INVALID_INDEX で失敗する。したがって、拡張された CMS は自分自身で制御する CMS エンコーダーから作らなければならない。1 回のシステム呼び出しでトークンを静かに組み込めるライブラリなど存在せず、そう謳っているものがあれば、見えないどこかでこの手術を行っているだけである

Delphi で PAdES 署名へ RFC 3161 タイムスタンプを追加するパイプライン。PDF Library for Delphi のハッシュ化と埋め込みを、予約済み /Contents 空間内での呼び出し側の TSA 要求と CMS 再エンコードから分離
ライブラリーがハッシュ化と再埋め込みを担い、トークンの取得と CMS の組み込みはコード側が行います。結果は 8192 バイトの /Contents 予約領域内に収まる必要があります
var
  Pdf: TPDFlib;
  StsId: Integer;
  HashHex, TstDer, TsAttr, AugmentedCms: AnsiString;
begin
  Pdf := TPDFlib.Create;
  try
    StsId := Pdf.NewPAdESSignatureTimeStampProcessFromFile('invoice-signed.pdf', '');
    Pdf.SetPAdESSignatureTimeStampField(StsId, 'Sig1');
    Pdf.SetPAdESSignatureTimeStampDigestAlgorithm(StsId, 2);
    HashHex := Pdf.GetPAdESSignatureValueHashHex(StsId);
    // 下の2つのcallはapplication code:TSAへのHTTP POST、
    // およびtokenをunsigned attributeとして付けるCMS re-encode
    TstDer := RequestTimeStampToken(HashHex);
    TsAttr := Pdf.BuildPAdESSignatureTimeStampAttribute(TstDer);
    AugmentedCms := AttachUnsignedAttribute(Pdf.GetPAdESSignatureCMSBytes(StsId), TsAttr);
    Pdf.SetPAdESSignatureCMSBytes(StsId, AugmentedCms);
    Pdf.EndPAdESSignatureTimeStampProcessToFile(StsId, 'invoice-bt.pdf');
    if Pdf.GetPAdESSignatureTimeStampProcessResult(StsId) <> 1 then
      raise Exception.Create('timestamp embedding failed');
    Pdf.ReleasePAdESSignatureTimeStampProcess(StsId);
  finally
    Pdf.Free;
  end;
end;

ここでは結果コードに注意すること。12 は指定した署名フィールドが存在しないこと、11 は既存の CMS を解析できなかったこと、13 は拡張済みの CMS が予約済みの /Contents プレースホルダーにもう収まらないことを意味する。コード 13 が特に痛いのは、唯一の修正方法が再署名だからである。証明書チェーンを含む典型的なタイムスタンプトークンは 4 から 6 キロバイトに達し、B-B のステップで確保しておいた 8192 バイトの予約は、まさにこのステップのための余地としてそこにある

検証は証明書チェーンではなく ByteRange から始まる

ビューアの緑色のチェックマークは、そのマシンの証明書ストアに対する信頼の判断であって、ファイルに関する構造的な判定ではない。プログラムによる検証はもっと低い層、増分更新によって見えにくくなる問いから始めるべきである。各署名は実際にはどのバイトをカバーしているのか。2 つ目の署名であれ、DSS 辞書であれ、ドキュメントタイムスタンプであれ、ここで扱うすべての拡張は増分更新を通じて到着し、それぞれの更新は以前の署名の /ByteRange の外側にバイトを追記する。それらの追記されたバイト自体は正当なものである。バリデータはそれでも、文書の変更ポリシーに照らしてそれらを分類しなければならない。そのポリシーが宿るフィールドごとの DocMDP レベルは、GetSignatureDocMDPLevelByName で読み取れる

Delphi における署名済み PDF のバイトレイアウト監査。ByteRange がカバーする範囲、除外される /Contents バイト、範囲外に追記されたインクリメンタル更新、ファイルサイズに対するカバレッジ判定
署名自身のバイトを除いた 2 つのカバー範囲が本当のカバー状況を語り、追加された更新は恐れるのではなく DocMDP ポリシーに照らして分類されます
var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  I: Integer;
  B0, B1, B2, B3, FileSize: Int64;
begin
  FileSize := TFile.GetSize('invoice-bt.pdf');  // before Open: SignDoc holds a share lock
  Doc := TPDFlibSignDoc.Create;
  try
    if not Doc.Open('invoice-bt.pdf', '', False) then
      raise Exception.Create('cannot open for audit');
    Names := TStringList.Create;
    try
      Doc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
        if Doc.GetSignatureValueObjNum(Names[I]) > 0 then   // >0なら実際に署名済み
        begin
          B0 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
          B1 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
          B2 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
          B3 := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
          if (B0 = 0) and (B2 + B3 = FileSize) then
            Writeln(Names[I], ': covers the file to EOF')
          else
            Writeln(Names[I], ': earlier revision, or unexpected ByteRange layout');
        end;
    finally
      Names.Free;
    end;
    Doc.Close;
  finally
    Doc.Free;
  end;
end;

この監査経路には 2 つの落とし穴がある。TPDFlibSignDoc.Open はファイルを排他的な共有ロックのもとに置くため、CMS 検証のために生のファイルバイトもハッシュしたいバリデータは、監査用に開く前にファイルをメモリへ読み込んでおかなければならない。この順序を逆にすると、自分自身が設定したロックのせいで読み取りが失敗する。2 つ目の落とし穴は、はっきりとは表に出ない。フラット API 側の対応物である GetSignProcessByteRangeInteger を返すが、内部のオフセットは Int64 であるため、2 GB を超えるとフラット呼び出しは何の警告もなく値を切り捨ててしまう。この例が監査用クラスを通じてオフセットを取得しているのはそのためである。もう 1 つ、存在しないものについても述べておく価値がある。フラット層には VerifySignature のラッパーが一切存在しない。暗号学的な判定は、vsValidvsInvalidvsUnknown のいずれかを返すクラス層の TPDFlibSignatureVerifier から得るか、コンプライアンスポリシーがすでに信頼している外部バリデータから得ることになる

長期検証: DSS、VRI、そしてドキュメントタイムスタンプ

PAdES-B-LT が存在するのは、失効情報を扱うインフラにも寿命があるからである。ETSI EN 319 142-1 §5.4.2.2 は Document Security Store を規定している。証明書、CRL、OCSP レスポンスを保持する文書レベルの辞書であり、任意で、各署名の /Contents のハッシュをキーとする VRI エントリを通じて署名ごとに索引付けできる。PDF Library for Delphi のフローはタイムスタンプの設計をなぞっている。NewPAdESDSSProcessFromFile でプロセスを開き、AddPAdESDSSCertificateAddPAdESDSSCRLAddPAdESDSSOCSP が DER 形式のデータを受け取り、AddPAdESDSSVRI が選択した素材を 1 つの署名に結び付け、EndPAdESDSSProcessToFile がすべてを増分更新として書き出す。難しい部分は依然として自分の側に残る。失効情報を取得し、それを埋め込むだけの新しさがあるかどうかを判断するのは呼び出し側の仕事である。ライブラリが保証するのは辞書が構造的に適合していることだけであり、自分の OCSP レスポンダが真実を語っているかどうかまでは保証できない

アーカイブの終着点である B-LTA は、ドキュメントタイムスタンプを追加する。これは、Sig ではなく DocTimeStamp というタイプを持つ独立した署名フィールドであり、予約された署名長を伴う SetSignProcessDocTimeStamp を通じて生成する。これは B-T ステップの署名タイムスタンプを置き換えるものではない。署名タイムスタンプは、ある特定の署名がいつ存在していたかを証明するものであり、ドキュメントタイムスタンプは DSS の証拠も含めたファイル全体を保護するもので、アルゴリズムが弱体化していく中で長期アーカイブが数年ごとに更新していく要素である。成熟したアーカイブ用プロファイルは両方を備えている。これらの構造が登場する前に作られたリーダー向けには、TPDFlibSignDoc.EnsurePAdESExtensions が文書カタログに ESIC 開発者拡張を記録し、そのファイルが ETSI 定義の機能を使っていることを告知する

ここまでの内容に対して 1 つの反応をあらかじめ潰しておく価値がある。バグのように見えるが、実はそうではないからである。PAdES の構造としては完全に正しいファイルに対して、ビューアが「有効性不明」と報告することはよくある。信頼と構造は独立した軸である。ByteRange の監査と CMS の検証がどちらも通っていても、ビューアはそのマシン上で署名者を信頼するルートへ単純にチェーンできないだけであり、これはプライベート CA やテスト用証明書ではごくありふれたことである。この場合の対処法は、署名側のコードに手を加えることではなく、ルート証明書を適切に配布すること、あるいは適格な eIDAS ステータスが本来の目標であるなら EU の信頼リストに照らして評価することである

監査側の視点、つまり多数のファイル群にわたって署名フィールドを列挙し、ByteRange のレイアウトをダンプし、DocMDP レベルを一括で読み取るといった作業については、コンプライアンスと署名のワークベンチに関する関連記事を参照してほしい。アーカイブポリシーも同時に満たさなければならない署名済み文書は、Delphi における PDF/A と PDF/UA プリフライトで説明しているワークフローに沿うことになる。完全な API ドキュメントと評価版のダウンロードはlosLab PDF Library for Delphi製品ページで公開している