PDFium VCLはCMS検証を、IPdfCmsVerifierインターフェースの背後に置かれた差し替え可能なバックエンドとして扱います。PAdES検証器はWindowsではCryptoAPIで、macOSではキーチェーンで、そしてOpenSSLのある場所ならどこでもConfigureSslCmsVerifier経由で動きます。インターフェースは小さくできています。しかしその下にあるOpenSSLの挙動のうち3つは、素朴に実装すると自信満々の間違った答えを生みます
DelphiアプリケーションがWindowsを離れた瞬間、動機は明白になります。署名検証は、プラットフォームの暗号スタックが実装の詳細ですまない数少ない領域です。どの証明書を信頼するか、どんなアルゴリズムが存在するか、失効とは何を意味するかを決めるのは、プラットフォームの側です。1つをハードコードすれば移植できず、雑に抽象化すれば、プラットフォームごとに形の違う答えが返り、呼び出し側は比較できなくなります
抽象化が実際に運ぶべきもの
検証の形が2つ、独立した判定が3つです。PDF署名はデタッチ形式であり、署名対象のコンテンツは/Contentsの穴を挟む2つのバイト範囲です。そのためVerifyDetachedは1つのバッファではなく2つのセグメントを取ります。タイムスタンプトークンはアタッチ形式で自分自身のコンテンツを運ぶため、VerifyAttachedはDERだけを受け取ります
結果が3つのステータスに分かれるのは、3つの異なる問いに答え、しかも食い違い得るからです。SignatureStatusは、そのバイト列が署名者証明書の鍵で署名されたものかを語ります。TrustStatusは、その証明書が信頼する何かにチェーンするかを語ります。RevocationStatusは、証明書が該当時点でまだ有効だったかを語ります。聞いたことのない証明書による数学的に完璧な署名を持つドキュメントは、有効かつ信頼されず、かつ未知です。これを単一のブーリアンに潰すのが、検証器がユーザーに嘘をつくことになる経路です
uses
FPdfCrypto, FPdfCryptoSsl;
var
Options: TPdfCmsVerifyOptions;
begin
if not SslAvailable then
raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER、空でもよい
ConfigureSslCrls(LoadFreshCrls); // DER、空でもよい
ConfigureSslCmsVerifier; // バックエンドをインストールする
Writeln('backend : ', PadesCmsVerificationBackendName);
Writeln('library : ', SslLibraryPath, ' ', SslLibraryVersion);
Writeln('ABI : ', SslAbiLayout); // ulong=<n> long=<n>
Options := TPdfCmsVerifyOptions.Default;
Options.CheckRevocation := True;
Options.CollectChainCertificates := True;
end;
SslAbiLayoutは好奇心の産物に見えて、実はそうではありません。すべてのOpenSSLエラーコードとすべてのストアフラグは、Cのunsigned longとして境界を越えてきます。Windowsでは4バイト、LinuxとmacOSでは8バイトです。これを固定の32ビット型として宣言すると、Windowsでは動き、LP64では値の半分を黙って読みます。仮定した幅を、テストでアサートできる文字列として報告すれば、プラットフォームABIドリフトのクラス全体が1行のチェックになります。PKCS#11バインディングでCK_ULONGの同じ問題を経験した人なら即座に見覚えがあるはずです。その話はPKCS#11の構造体パッキングとCK_ULONG幅にあります
2回目の検証パスが空のコンテンツを見る理由
CMS_verifyはデタッチされたコンテンツのBIOをファイル終端まで読み、読まれたBIOは勝手に巻き戻されないからです。検証を2パスで行うのは合理的な設計です。まずチェーン評価を抑止して暗号署名だけを検証し、次に完全な評価を行う。そして両パスが1つのBIOを共有すると、並みならず紛らわしい形で失敗します
2パス目が受け取るコンテンツはゼロバイトです。デタッチモードではこれはエラーになりません。空のコンテンツバッファは正当な入力だからです。ダイジェストは単に一致せず、失敗はコンテンツの失敗としてではなくチェーン構築の失敗として表面化します。実際の問題はストリーム位置なのに、証明書とトラストストアの調べ物に出かけさせられるわけです。メモリBIOはBIO_new_mem_bufでパスごとに作り直してください。確保1回のコストで、この可能性ごと消し去れます
no-verifyフラグが抑止するもの、しないもの
CMS_NO_SIGNER_CERT_VERIFYが抑止するのはチェーン評価であって、署名者証明書の検索ではありません。内部的には、OpenSSLはフラグを参照する前に署名者証明書を解決して添付済みです。したがってこのフラグを立てた1パス目の後には、署名者は既に利用可能で、そのアルゴリズム識別子はすぐに読めます。署名者証明書を得るためだけに2回目の完全検証を走らせる必要はありません。フラグの名前がそう思い込ませたがるにもかかわらず、です
これには所有権のルールが1つ付いて回ります。署名者参照はCMS構造の所有物であり、独立して解放してはなりません。構造体が生きている間だけ有効で、これを解放すると、破損の症状がまったく別の場所、たいていは無関係なオブジェクトのクリーンアップ中に現れます
CRLチェックを有効にすると全署名が拒否される理由
OpenSSLはCRLを、ストアが既に保持しているものに対してのみチェックし、自分では何もフェッチしないからです。CRL配布ポイントを辿らず、OCSPも話しません。CRLを1つも含まないストアにX509_V_FLAG_CRL_CHECKを設定すると、すべてのチェーンが、証明書CRLを取得できないという理由で失敗します。結果は、失効チェックが動作して問題を発見したように見えます。実際は、失効チェックがまったく動いていないのです
そのためバックエンドは、ConfigureSslCrlsが実際に少なくとも1つのCRLを供給したときにだけフラグを立てます。1つもなければ、RevocationStatusはpcvsUnsupportedで戻ります。問いに答えられなかったという正直な表明です。同じ理由で、OnlineRetrievalはこのバックエンドに効果がなく、pcvstOnlineRetrievalのチェックポイントも発行されません。進捗を報告するためのフェッチ経路が存在しないのです
これは一般に擁護する価値のある設計姿勢です。失効をチェックできない検証器は、そう言うべきです。チェックしていない証明書を失効していないと報告することは、署名検証ツールがユーザーを誤導する最もありふれた方法であり、検証器がPAdES署名を拒否する理由で掘り下げた紛らわしさの類型そのものです
// チェックポイントがあればUIはどの段階が実行中かを示せて、どの段階を
// バックエンドが実際に実行するのかも分かる
type
TSignatureProbe = class
procedure Checkpoint(Stage: TPdfCmsVerifyStage);
end;
procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
case Stage of
pcvstCryptographicSignature: Status('checking the signature');
pcvstChainBuild: Status('building the certificate chain');
pcvstOnlineRetrieval: Status('fetching validation data');
pcvstRevocationCheck: Status('checking revocation');
end;
end;
// 3つの判定は別々に読む。食い違うことは許されている
if Result.SignatureStatus = pcvsValid then
case Result.TrustStatus of
pcvsValid: Report('signed and trusted');
pcvsInvalid: Report('signed, chain rejected');
pcvsUnsupported,
pcvsIndeterminate: Report('signed, trust not established');
end;
if Result.RevocationStatus = pcvsUnsupported then
Report('revocation was not checked on this backend');
固定できないライブラリへのバインド
OpenSSLは1.0から1.1の間でスタックアクセサの名前を変えました。そのため同じ論理関数が、ホストがたまたま持っているビルドに応じて、2通りのエクスポート名を持ち得ます。バインディングは新しい名前を先に解決し、旧名へフォールバックし、どちらも解決しないときだけ欠落シンボルを記録します。自分で出荷しないライブラリへの動的バインドとしては、これが正しい形です。現行の名前を優先し、歴史的な名前を許容し、本当に無いときだけ報告する
SslMissingSymbolsこそが、ロード失敗を診断可能なイベントへ変えます。libcryptoが明らかにインストールされているホストで結果が空でなければ、インストール済みバージョンがこのビルドのターゲットとするAPIより古いことを意味し、ライブラリが無い場合とはまったく違うサポートの会話になります。ConfigureSslLibraryPathはもう1つのありがちなケース、デフォルトのサーチパスにあるOpenSSLビルドが欲しいものでないホストをカバーします
プラットフォームごとのバックエンド選択
実務的な構成は、起動時に選択し、どれが答えたかを記録するものです。Windowsでは、プラットフォームバックエンドが企業が既に運用している証明書ストアと統合されます。たいていはそれが望むものでしょう。macOSではキーチェーンバックエンドが同じ道理に適合し、macOSでSecTrustにより署名を検証するで説明しています。OpenSSLはポータブルな選択肢であり、プラットフォームのトラストストアに従うのではなく、プラットフォーム間で同一の検証ポリシーが必要なときにも正しい選択です
どれをインストールする場合でも、記録するすべての判定の隣にPadesCmsVerificationBackendNameをログとして残してください。それを生み出したバックエンド抜きの保存済み検証結果は、後から再現できません。3つのステータス値が微妙に違う意味を持つのは、どのスタックが答えたかによるからです。この全体の上にある署名検査のレイヤーは、PAdESレベルの報告方法を含め、PDF電子署名とPAdESレベルの検査で扱っています
すべてはソース付きでPDFium Delphiコンポーネントに同梱されています。これはいつもより意味が大きい点です。署名検証器にとって、バックエンドがどのフラグを立て、どのチェックを飛ばすのかを正確に読めることは、あって当たり前の機能ではなく、自分のアプリケーションの緑のチェックマークが実際には何を主張しているのかを知る唯一の方法です