PDFium VCLは、CertGetCertificateChainの失効パスにCERT_CHAIN_REVOCATION_CHECK_CACHE_ONLYを加えることで、Windows上のPDF署名失効確認をオフラインで行います。チェーン構築に使うキャッシュ限定フラグは、CRLやOCSPの取得をまったくカバーしないためです。v3.119.1以降、オフラインの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のときはいつでも、このフラグを失効パスへORします。チェーンフラグ、CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT、CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUTの上に載せます。これはレイテンシ以上に重要です。OCSPリクエストはレスポンダにあなたがどの証明書を見ているかを教えます。エアギャップ検証器が避けるべきはまさにこれです
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline、15000ミリ秒
Options.CheckRevocation := True; // デフォルトはFalse
Options.CheckTimeStamps := True;
// オフラインは今や失効についてもオフラインを意味する:キャッシュ済みのCRLと
// OCSP応答のみで、pcvstOnlineRetrievalチェックポイントは上がらない
Report := Pdf.ValidatePadesTrust(Options);
end;
2つのチェーン構築、2つのエラーフィールド
Windowsバックエンドはチェーンを2回構築し、それぞれの構築が今や自分のエラースロットを持ちます。1回目のCertGetCertificateChain呼び出しは失効フラグなしで走り、ベースポリシーでCertVerifyCertificateChainPolicyに渡されます。ここからTrustStatusとTrustErrorが生まれます。2回目の呼び出しが失効フラグを加えます。v3.119.1より前は、この2回目の呼び出しが失敗するとGetLastErrorがTrustErrorへ書き込まれていました。失効プロバイダーの不調のせいで、つい先ほどtrustedと検証されたチェーンが、untrustedに見えて返ってくることがあり得たのです。修正はGetLastErrorを即座に読み、TPdfCmsVerifyResult.RevocationErrorへ保存します。1回目の判定には触れません。そして2回目の呼び出しのTrueも成功扱いにはしません。Windowsが検査に値するチェーンコンテキストを返してきたという意味でしかないからです
ゼロのtrustエラーマスクが実際に証明するもの
それ単体では、何も証明しません。失効パスの後の集約TrustStatus.dwErrorStatusがゼロというのは、エラービットが1つも立っていないと言っているだけです。そしてどの要素も失効情報をまったく運ばないチェーンなら、まさにその結果が生まれます。旧コードは「失効ビットなし、未知ビットなし、オフラインビットなし」をそのままvalidへ写像していました。検証器が未確認の証明書を綺麗なものとして報告する、古典的なやり方です。新しいReadWinRevocationEvidenceルーチンは、すべてのsimple chainとすべての要素を歩き、cbSizeが安全に読めるサイズに満たない構造は拒否し、少なくとも1つの非ルート要素が存在し、そのような要素すべてがdwRevocationResultがゼロのCERT_REVOCATION_INFOを運ぶときにだけ成功を報告します
// 証拠ウォークから簡略化:要素が数えられるのは、失効プロバイダーが
// 実際にその要素について答えたときだけ
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つの独立した呼び出しで検証します。そしてv3.119.0がTPadesSignatureValidation上にそれぞれ専用の診断を与えました。署名者にはRevocationReasonとNativeRevocationError、TSAにはTimeStampRevocationReasonとNativeTimeStampRevocationErrorです。したがって失効したTSA証明書が失効した署名者のふりをすることはできず、タイムスタンプの失敗が既に確立された完全性の結果を消すこともありません。CheckRevocationがFalseのときや検証がその段階へ届かなかったときは、フィールドはpcrrNoneと0のままです。必ずRevocationStatusとTimeStampRevocationStatusの隣で読んでください
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エクスポートは古いフィールドの意味を変えずに対応するフィールドを足します。オフライン検証がindeterminateを返し続けるなら、恒久の修正は上流にあります。検証素材は署名時に集めてください。RFC 3161タイムスタンプとDSSによる長期PDF署名で述べた通りです。検証するマシンのキャッシュが温かいことを期待するのではなく
テストマトリクスが証明することと、しないこと
Windows検証マトリクスは、DelphiとFPCのWin32・Win64ターゲットそれぞれで、管理されたチェーンAPIシナリオ30件と、実物のオフラインCMSスモーク1件を通しました。実物のスモークが検証するのは、信頼できないプライベートCAの下での有効な署名です。綺麗な結果と明示的な失効の結果は、インストールされたトラストアンカーや実取得ではなく、スタブ化したCertGetCertificateChain応答から来ます。これは正直に言っておくべき境界です。フラグ処理、エラー分離、証拠ウォークは釘付けされました。しかし、特定のマシンの失効キャッシュがその日に何を含んでいるかは依然としてWindowsの領域であり、空のキャッシュは今やネットワークリクエストでも偽の「valid」でもなく、正しく「unknown」を生み出します
オフライン失効処理、フィールドごとの診断、証拠エクスポートは、DelphiとC++Builder向けPDFium VCLのPDF署名検証APIの一部であり、クロスプラットフォーム展開向けのOpenSSLとmacOSのバックエンドと並んでいます