技術記事

DelphiのPDF署名向けOpenSSL AIAとCRL取得

PDFium VCLはいまや、OpenSSLバックエンド上でPDF署名チェーンの補完と失効確認をネットワーク越しに行えます。OnlineRetrievalを有効にすると、ConfigureSslCmsVerifierが、AIAのcaIssuers URLから不足している中間証明書を、CRL配布ポイントからCRLを、検証呼び出しごとの固定の時間・リクエスト・バイト予算の中でダウンロードする検証器をインストールします。ダウンロードされた証明書はあくまでチェーン素材です。トラストは今も、システムストアと設定したアンカーからのみ来ます

この修正が塞ぐ隙間は、Linuxサーバーで現実世界のPDFを検証した最初の日に出てきます。署名者の多くはCMSに自分のリーフ証明書しか埋め込まず、OpenSSLはルートに届かず、TrustStatusはinvalidで戻り、チェーンが一度もトラスト可能にならないため失効確認は永遠に走りません。v3.121.0より前、PDFium VCLでOpenSSLを使ってPDF署名を検証するで述べたOpenSSLバックエンドは徹底してオフラインで、OnlineRetrievalは効き目がありませんでした。最初に言っておきたいことが1つあります。PDFiumエンジン自身はCMS検証をまったく行いません。だから以下の規則はすべて、コンポーネントのPAdESレイヤーとそのOpenSSLバインディングの中、つまり読める場所に住んでいます

OpenSSLバックエンドはどんな順で検証し、取得し、確認するのか

整合性が先、次にトラスト、最後に失効。ネットワークに触れるのは必要なステップの間だけです。VerifyCmsWithSslはチェーン評価を抑えた状態でCMS署名とsigned attributes(RFC 5652)を検査し、失敗したら即座に返ります。フェッチセッションが存在するよりも前なので、バイト列の壊れた文書は外向きのリクエストを一切起こしません。チェーンがその後失敗し、かつOnlineRetrievalが有効なときにだけ、AIAリンクをたどって再検証します。CRL配布ポイントを取得するのはチェーンがトラストされた後だけです。信頼できない経路にぶら下がるCRLは何も証明しないからです。3つの判定は最後まで独立しています。チェーンが不完全でも、署名が有効なら有効な署名として報告されます

PDFium ComponentのOpenSSLバックエンドにおけるVerifyCmsWithSslの順序の図。CMS署名検査はチェーン評価を抑えて走るので、壊れたバイトはネットワークに触れません。RetrieveIntermediatesはOnlineRetrieval有効下でチェーン失敗の後にだけAIA caIssuers URLをたどり、RetrieveCrlsはチェーンがトラストされた後に配布ポイントのCRLを独立ストアへ取得します
整合性、次にトラスト、最後に失効。ネットワークに触れるのは必要なステップの間だけです。信頼できない経路にぶら下がるCRLは何も証明しません
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER。唯一の追加トラスト
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // デフォルトはptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // 検証呼び出しごと

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

ダウンロードした証明書をトラストストアに入れない理由

URLの出所は検証対象の証明書であり、選んだのは署名者だからです。authorityInfoAccessのcaIssuersエントリ(RFC 5280 §4.2.2.1)は発行者がどこに住むかの手がかりであって、それ以上のものではありません。そのURLに応答する何者かがトラストアンカーストアに入る仕様だったら、誰でも自作鍵で署名し、AIAを自分のサーバーに向け、グリーンの判定を受け取れます。RetrieveIntermediatesはそのため、パースした各証明書をCMS_add1_certへ渡します。これは当該CMS構造体の非トラスト集合へ置くものであり、OpenSSLはそこから、設定したアンカーかシステムストアが既に持つアンカーへの経路をやはり組まなければなりません。もっと静かな理由もあります。CMS_verifyの証明書引数は、CMSに埋め込まれた証明書の差し替え品にはならないので、CMSそのものへ追加するのが確実な経路です

取得ループは意図的に狭く作ってあります。RetrieveIntermediatesは最大4ラウンドで、各ラウンドはいまCMSにいる全証明書からcaIssuers URLを集め、1ラウンドで何も増えなかったか時間予算を使い切った時点で止まります。レスポンスはd2i_X509で、本文全体を消費する単一のDER証明書としてデコードできなければなりません。後続バイトは拒否し、.p7c URLから配られたPKCS#7の証明書のみバンドルは、展開せずスキップします。同じAIA拡張内のOCSPアクセスメソッドは無視します。このバックエンドはOCSPを話さないからです。失効側では、RetrieveCrlsはCMS証明書と設定済みアンカーから各DistributionPoint(RFC 5280 §4.2.1.13)のfullName URIだけを読み、ダウンロードしたCRLはフルチェーンCRL検査付きの、2つ目の独立したX509_STOREへ入ります。欠落や古いCRLはTrustStatusに一切触れずにRevocationStatusを変えるのです

// VerifyCmsWithSsl(FPdfCryptoSsl.pas)から要約。BIOのセットアップは省略。
// _CMS_verifyの各呼びに新鮮なcontent BIOを渡す
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // 壊れた署名:ネットワークには一切触れない
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert、非トラスト専用
  // 同じアンカーストアに対してチェーンを再検証する
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// 2つ目の独立ストア:設定済みCRL+取得CRL
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

検証呼び出し1回の最大コスト

固定の天井です。1回の検証呼び出しのAIAステップとCRLステップが共有する、1つのTPdfCryptoFetchSessionが強制します。上限はFPdfCryptoHttpの定数であって、提案ではありません

  • 時間:UrlRetrievalTimeoutMs。TPdfCmsVerifyOptions.DefaultとTPadesTrustValidationOptions.Defaultのどちらでもデフォルトは15000です。0で作ったセッションは30000へフォールバックし、時計は署名の合格後から始まり、それ以降の全リクエストをカバーします
  • リクエスト:セッションあたり最大8。トランスポートを試みる前に数えるので、死んだホストでもスロットを1つ消費します
  • バイト:レスポンスあたり1 MiB、合計4 MiB。2048文字超のURLは接続の前に拒否します
PDFium Componentの検証呼び出しでAIAとCRLのステップが共有する1つのTPdfCryptoFetchSessionの硬い天井の図。UrlRetrievalTimeoutMsはデフォルト15000 ms、ゼロのフォールバックは30000、セッションあたり最大8リクエスト、レスポンスあたり1 MiB、合計4 MiBで失敗レスポンスも数え、2048文字超のURLは拒否します
上限は定数であって提案ではありません。失敗レスポンスのバイトも予算を消化します。しかも各署名と各タイムスタンプは個別に検証されるので、最悪ケースは署名数とともに膨らみます

会計は一見より厳密です。失敗レスポンスから受け取ったバイトも合計に算入されるので、大きなページ付きの404を返すサーバーは予算をただで吸い取れません。レスポンスあたり上限を跨ぐ読み取りは、切り詰められた本文をASN.1パーサーへ渡す代わりにダウンロードを打ち切ります。空本文のHTTP 200は一蹴して拒否します。そうしないとAIA経路が空配列のData[0]を参照しかねないからです。通すのは素のhttp://とhttps://のURLだけで、リダイレクト、Cookie、資格情報、自動プロキシ発見はありません。一方HTTPSは通常の証明書とホスト名検査を保ちます。URLの重複排除は意図的に1呼びに限定します。次の検証が、新しく公開されたCRLを見えなければならないからです。予算も呼びごとであって文書ごとではなく、ValidatePadesTrustは各署名と各タイムスタンプトークンを個別に検証するので、最悪ケースは署名数とともに膨らみます

タイムアウトしたWinHTTPリクエストがメモリに書き続けられる理由

タイムアウトで返っても、飛行中のコールバックはキャンセルされないからです。WindowsトランスポートはWinHTTPを非同期で駆動し、セッションの残り時間だけイベントで待ちます。その待ちが諦めた後でも、リクエストは読み取りを完了して後から通知できます。非同期読み取りの先をスタックバッファに向けていれば、その遅れた完了は、その頃には無関係な関数のものになっているフレームへ書き込みます。修正は所有権であって、タイミングではありません。イベントと16 KBの読み取りバッファは、参照を2つ持つヒープレコードに住みます。1つは呼び出し元が持ち、もう1つは最後のHANDLE_CLOSINGコールバックだけが解放します。どちらが最後に終わっても、メモリを解放するのはその側です

タイムアウトしたWinHTTPリクエストがメモリに書き続けられる理由の図。タイムアウトで返ってもコールバックは飛行中のままなので、PDFium Componentは非同期読み取りの先を、ヒープ確保したTHttpStateレコードへ向けます。16 KBバッファと2つの参照(1つは呼び出し元、もう1つは最後のHANDLE_CLOSINGコールバックが解放)は、最後の側が終わって初めて解放されます
遅れた完了は、待ちが諦めた後に読み取りを終え得ます。参照を2つ持つヒープ所有権なら、その書き込みはまだ生きているメモリに着地します
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // 呼び出し元+最後のHANDLE_CLOSINGコールバック
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // 非同期読み取りの着地点。スタックには決して置かない
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// ステータスコールバック内:HANDLE_CLOSINGはWinHTTPがリクエストに
// 送る最後の通知なので、2つ目の参照を落とす
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

FPC Unixでlibcurlに求める条件

非同期リゾルバとスレッドセーフなビルドです。どちらかがなければオンライン取得はオフのままです。FPC Unixではトランスポートはlibcurlを通ります。非Windowsターゲット向けlibcurlタイムスタンプバックエンドの背後にいるのと同じ依存です。バインディングは、機能マスクにCURL_VERSION_ASYNCHDNSかCURL_VERSION_THREADSAFEのどちらでも欠くライブラリを拒否します。理由はこうです。他人のプロセス内で動くライブラリが設定せねばならないCURLOPT_NOSIGNALと、同期リゾルバの組み合わせは、DNSルックアップがタイムアウトを悠々と生き延びることを許します。2つ目の罠はシャットダウンです。curl_global_cleanupは非同期DNSスレッドを待たないので、libcurlを初期化した後は、バックグラウンドスレッドをアンロード済みコードへ走らせる代わりに、モジュールはプロセス終了までマップされたままになります。どちらかの要件が満たされないとき、SslCapabilities.OnlineRetrievalはFalseとなり、SslVerifyOptionsDiagnosticsは、ネットワークに問い合わせたふりをする代わりにpsvdOnlineRetrievalIgnoredを報告します

結果が保証することとしないこと

このバックエンドの有効なRevocationStatusが意味するのは、チェーン全体をカバーする現行のCRLが見つかり、設定またはダウンロードされ、どれも内の証明書を挙げていなかったこと、それだけです。OCSPはありません。失効情報をOCSPだけで公開するCAは結果が未サポートのままになり、ネットワーク障害は何も公開しないCAとまったく同じに見えます。もう1つ、psvdNoCrlsConfiguredは設定したCRLについてだけを述べるので、オンライン取得ありではヒントであって失敗の予報ではありません。監査証跡をネットワークなしで再現可能にしなければならないときは、NetworkPolicyをデフォルトのptnpOfflineに置いてください。フェッチセッションは作られず、バックエンドは接続を一切開きません。WindowsでのオフラインPDF署名失効確認で述べたCryptoAPI側のオフライン契約と一致します

取得コード、予算、トランスポートバインディングは、PDFium Delphiコンポーネントとともにソースで届きます。信用できない文書を扱うサーバーでptnpOnlineを有効にする前に、検証がどのURLに接触し得て、どれだけダウンロードし得るかを正確に確認できます