技術記事

DelphiでWindowsのPDF署名失効確認をオフラインで行う

PDFium VCL は Windows での PDF 署名の失効確認を、CertGetCertificateChainの失効パスにCERT_CHAIN_REVOCATION_CHECK_CACHE_ONLYを加えることでオフラインで行います。チェーン構築に使う cache-only フラグだけでは、CRL や OCSP の取得にはまったく届かないからです。したがってオフラインのValidatePadesTrust呼び出しはネットワークに触れず、クリーンな結果には証明書ごとの実際の失効エビデンスが要求されます。この記事の残りは、この文の各要素がなぜ手間のかかる作業なのかを説明するものです

この罠はごく普通の構成で顔を出します。検証サービスがロックダウンされた Windows ホストで動いており、TPadesTrustValidationOptions.NetworkPolicyはptnpOffline(これがデフォルトでもあります)、オペレーターはすべての答えがローカルの証明書キャッシュから出てくると期待しています。失効パスをチェーン構築用フラグだけで組み立てると、誰かがファイアウォールログで CA 配布点への外向きリクエストに気づくか、各署名でUrlRetrievalTimeoutMsの15000ミリ秒をまるごと使い切って固まるバッチジョブに気づくことになります。コードのどこにもネットワークを求める部分はありません。それでも Windows はそこへ行きます

オフラインのチェーン構築がWindowsで依然CRLを取得する理由

理由は、CERT_CHAIN_CACHE_ONLY_URL_RETRIEVALが制限するのはチェーン構築が行う URL 取得、つまり AIA の発行者取得やルート・CTL の更新だけだからです。Microsoft のCertGetCertificateChainドキュメントは、このフラグは失効確認には適用されないと明言しています。失効には専用のスイッチCERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY($80000000)が用意されていて、これがないと、周囲の呼び出しはオフラインに見えていても、失効プロバイダーは CRL のダウンロードや OCSP リクエストの送信を自由に行えます。PDFium VCL はOnlineRetrievalが False のときは常に、このフラグをチェーン系フラグ、CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT、CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUTに加えて失効パスへ OR で込みます。これはレイテンシ以上に重要です。OCSP リクエストはレスポンダーに「今どの証明書を見ているか」を教えます。エアギャップの検証器が避けるべきは、まさにこれです

WindowsでオフラインのPDF署名検証を守る2つの独立したスイッチ:CERT_CHAIN_CACHE_ONLY_URL_RETRIEVALはAIA発行者やルートの取得といったチェーン構築だけを制限し、失効にはCERT_CHAIN_REVOCATION_CHECK_CACHE_ONLYが要ります。これがなければプロバイダーはCRLをダウンロードし、どの証明書が検証されているかを明かすOCSPリクエストを送り続けます
PDFium VCLは、OnlineRetrievalがFalseのときはいつでも、失効パスへ失効のキャッシュ限定フラグをORします。ptnpOfflineの信頼検証が両パスでネットワークに触れないのはそのためです
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline、15000 ms
  Options.CheckRevocation := True;                 // デフォルトは False
  Options.CheckTimeStamps := True;
  // オフラインは失効確認にも当てはまります:キャッシュ済みの CRL と
  // OCSP レスポンスのみ。pcvstOnlineRetrieval チェックポイントは立たない
  Report := Pdf.ValidatePadesTrust(Options);
end;

2つのチェーン構築、2つのエラーフィールド

Windows バックエンドはチェーンを2回構築し、構築ごとに固有のエラースロットを持ちます。最初のCertGetCertificateChain呼び出しは失効フラグなしで実行され、基本ポリシーとともにCertVerifyCertificateChainPolicyへ渡され、TrustStatusとTrustErrorを生み出します。2回目の呼び出しが失効フラグを加えます。この2回目の呼び出しが失敗したときGetLastErrorをTrustErrorに書き込んでしまうと、失効プロバイダーの一時的なつまずきだけで、直前に信頼済みと検証されたチェーンが信頼不能に見えて返ってきかねません。PDFium VCL はGetLastErrorを即座に読んでTPdfCmsVerifyResult.RevocationErrorに格納し、1回目の判定には触れません。2回目の呼び出しが True を返しても、それは成功扱いではありません。単に、検査する価値のあるチェーンコンテキストが Windows から返ってきたというだけです

ゼロのtrustエラーマスクが実際に証明するもの

それ自体では、何の保証にもなりません。失効パスの後の集約TrustStatus.dwErrorStatusがゼロということは、エラービットが1つも立っていないだけで、どの要素も失効情報をまったく運んでいないチェーンは、まさにその状態を返せます。「失効ビットなし、不明ビットなし、オフラインビットなし」をそのまま「有効」に写像するのは、検証器が未確認の証明書をクリーンとして報告する古典的なやり方です。PDFium VCL のReadWinRevocationEvidenceルーチンはすべての simple chain のすべての要素を歩き、安全に読めないほどcbSizeが小さい構造は拒否し、少なくとも1つの非ルート要素が存在し、そのすべての要素がdwRevocationResultがゼロのCERT_REVOCATION_INFOを運んでいるときにだけ成功を報告します

DelphiでReadWinRevocationEvidenceが各Windowsチェーン要素に対して行う証拠ウォーク:pRevocationInfoが存在し、cbSizeが読める十分な大きさで、dwRevocationResultがゼロでなければならず、末端要素は自己署名かCA信頼の印があるときだけ除外されます。ゼロのtrustエラーマスクが未確認の証明書を隠すことはもうできません
綺麗な判定には少なくとも1つの非ルート要素と、要求された全要素からの回答が必要です。RevocationErrorはCRYPT_E_REVOKEDのような生のプロバイダーDWORDをそのまま保持します
// 証拠ウォークから簡略化:要素が数えられるのは、失効プロバイダーが
// 実際にその要素について答えたときだけ
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // ルートだけのチェーンは何も証明しない

プロバイダーの結果は生のまま保持されます。RevocationErrorはdwRevocationResultのDWORDを、プロバイダーが返したとおりに保持します。失効した要素があればそのエラーを優先し(CRYPT_E_REVOKEDは$80092010)、trustビットマスクをネイティブのエラーコードに仕立て直すことは決してありません。TPdfCmsRevocationReasonへの写像は意図的に大まかです。明示的な失効にはpcrrCertificateRevokedとpcvsInvalid、失効と無関係な理由でチェーンが失敗した場合はpcrrChainUntrusted、それ以外はすべてpcrrUnknown。WindowsはCRLではなくOCSPを試していたかもしれないため、オフラインや未知の結果はpcrrCrlExpiredへ翻訳されません。OpenSSL CMS検証バックエンドはそうしたCRL固有の区別を付けられます。評価するのはこちらから渡したCRLだけだからです。一方macOS SecTrustバックエンドはフィールドをpcrrNoneとゼロのままにします。これは「詳細な診断なし」を意味するのであって「合格」ではありません

ルート除外が止まる場所

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOTがアンカーをスキップするのは正当です。自分自身に対してルートを失効させるCRLを公開する者はいないからです。罠は、どの要素がルートかの判定にあります。PDFium VCLはsimple chainの最後の要素を、そのdwInfoStatusが自己署名($00000008)か明示的なCA信頼($00004000)の印を付けているときにだけ除外します。オフラインホストは欠けた発行者を取得できないことが多く、チェーンは中間CAで終わります。その部分的なチェーンの最終要素をルート扱いすれば、失効状態がキャッシュに最も無いはずのその1枚の証明書を、静かに落とすことになります。その要素は要求セットに留まり、プロバイダーの回答を持たず、結果はpcvsIndeterminateのままです

署名とタイムスタンプの失効結果をどう分けておくか

互いに上書きしない独立したフィールドとしてです。PAdES 検証器は文書署名の detached CMS と、RFC 3161 タイムスタンプトークンの attached CMS を2回の独立した呼び出しで検証し、それぞれがTPadesSignatureValidation上に固有の診断を持ちます。署名者にはRevocationReasonとNativeRevocationError、TSA にはTimeStampRevocationReasonとNativeTimeStampRevocationErrorです。したがって失効した TSA 証明書が失効した署名者に成りすますことはできず、タイムスタンプの失敗が、すでに確立された整合性の結果を消し去ることもありません。CheckRevocationが False のときや、検証がその段階に達しなかったときは、これらのフィールドはpcrrNoneと0のままです。必ずRevocationStatusやTimeStampRevocationStatusと並べて読んでください

PDFium VCLで署名とタイムスタンプの失効は分かれたままです。文書署名のdetached CMSがRevocationReasonとNativeRevocationErrorを満たし、RFC 3161トークンのattached CMSがTimeStampRevocationReasonとNativeTimeStampRevocationErrorを満たし、未確認の段階はステータスフィールドの隣にpcrrNoneとゼロを残します
したがって失効したTSA証明書が失効した署名者のふりをすることはなく、タイムスタンプの失敗が署名検証が既に確立した完全性の結果を消すこともありません
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

エビデンスレポートも同じ規則に従います。CSV エクスポートは既存の列順の末尾にrevocationReason、nativeRevocationError、そしてnativeTimeStampRevocationErrorまでのタイムスタンプ列を追加するので、古いパーサーも動き続けられ、JSON エクスポートは旧フィールドの意味を変えずに対応するフィールドを加えます。ある日の特定マシンの失効キャッシュの中身は Windows の管轄であり、空のキャッシュが返すのは「不明」であって、ネットワークリクエストでも偽の「有効」でもありません。オフライン検証が「不定」を返し続けるなら、恒久の修正は上流にあります。署名時に検証材料を集めておくことです。詳しくはRFC 3161 タイムスタンプと DSS による長期 PDF 署名で説明しています。検証するマシンのキャッシュが温かいことを願うのはやめましょう

オフライン失効処理、フィールドごとの診断、証拠エクスポートは、DelphiとC++Builder向けPDFium VCLのPDF署名検証APIの一部であり、クロスプラットフォーム展開向けのOpenSSLとmacOSのバックエンドと並んでいます