HotPDFは、Windows証明書ストアにすでに存在する証明書に対してPDFに署名する際、ダイジェストをWindows自身に渡し、Windowsは2つの秘密鍵バックエンドのいずれかを通じてその要求を完了する:RSA署名をビッグエンディアンで返すCNG、またはリトルエンディアンで返す従来のCryptoAPI CSPである。この2つを取り違えると、HotPDFが埋め込むCMS署名は実際に応答したバックエンドに応じてバイト順が反転してしまい、文書のバイト自体には一切手が加えられていないにもかかわらず、準拠したバリデータは署名を無効と報告する
この一文の裏には無関係な2つの問題が隠れており、HotPDFのシステム証明書署名機能は何かに署名する前にその両方を解決しなければならない。バイトオーダーの不一致は静かに起こる:署名呼び出しはそれでもTrueを返し、PDFはそれでも開けるが、失敗が表面化するのはビューアがCMS構造を辿って拒否した時だけである。2つ目の問題は騒がしく、C++Builder特有のものだ:crypt32の関数のうち半ダースがリンクを拒否する、RAD Studioが同梱するインポートライブラリがそれらをエクスポートしていないからだ。この2つの問題はPFXファイルだけで署名している限り一切現れないため、PFXベースのワンコール署名からIT部門がすでにユーザープロファイルにインストール済みの証明書へ移行しようとする開発者を捕まえがちな落とし穴になっている
ストアから証明書を選択する
HotPDFはこの経路をHPDFSignPDFStreamWithSystemCertificateとHPDFSignPDFFileWithSystemCertificateとして公開しており、いずれもTHPDFCertificateStoreSelectorレコードによって駆動される:Location(cslCurrentUserまたはcslLocalMachine)、StoreName(既定では個人ストアを表す'MY')、SHA-1のThumbprint、そしてAllowUIフラグである。サムプリントは内部で正規化されるため、証明書マネージャーUIからそのままコピーしたハイフンやスペースは比較を行う前に取り除かれる
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
AllowUI = Falseは見た目以上に重要である、なぜならこれは直接CRYPT_ACQUIRE_SILENT_FLAGにマップされ、Windowsはこれを文字通り尊重するからだ:一致した証明書の秘密鍵がPINプロンプトを必要とするスマートカードやトークン上にあり、Windowsがまだそのプロンプトをキャッシュしていない場合、CryptAcquireCertificatePrivateKeyはサービスプロセスかもしれない何かからダイアログをポップアップさせるのではなく失敗する。この失敗はすぐに現れるEHPDFCMSErrorだが、実際の原因が誰も入力しないPINを待っているトークンであるにもかかわらず「証明書が見つからない」と誤解しやすい
なぜCNGとCAPIはバイトオーダーで食い違うのか
どちらのバックエンドが応答するかは推測ではない:CryptAcquireCertificatePrivateKeyはKeySpec出力引数を通じて直接それを報告し、HotPDFの署名機能はこの単一の値によって分岐する。CNGキーストレージプロバイダーのキーは、番兵値であるCERT_NCRYPT_KEY_SPEC($FFFFFFFF)が設定されたKeySpecとともに返ってくる。それ以外は従来のCryptoAPI CSPキーである。現行のWindowsインストールで発行またはインポートされたほとんどの個人証明書は、互換性のために従来のCSPシムがまだ存在するにもかかわらずCNGに解決されるため、HotPDFはどちらの値が返ってきたかを見る前にCRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAGとともにCRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAGを要求する
この2つのバックエンドは異なる関数を呼ぶだけでなく(CNGキーにはNCryptSignHash、CSPキーにはCryptSignHashA)、生のRSA署名を正反対のバイトオーダーで返す。CNGの出力はすでにPKCS#1が期待する形式に一致している:ビッグエンディアンのオクテット文字列で、最上位バイトが先頭にあり、これはRFC 8017のI2OSP変換が生成するものであり、ISO 32000-1 §12.8.3のもとでCMS SignerInfo(RFC 5652)が署名フィールドに必要とするものそのものである。対照的にCryptoAPIのCryptSignHashは署名をリトルエンディアンで返す。これは従来のCSPが内部で大きな数値をどう表現していたかに由来する、文書化された癖である。CAPI側でこの反転をスキップすると、署名の全バイトが誤った位置に収まってしまう。RSAの計算自体は正しいままだが、検証者が読むオクテット文字列はPKCS#1が定義するものではなくなる
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
カスタム署名コールバックの場合はどうか
HotPDF組み込みの証明書ストア署名機能を迂回する者は誰でも同じバイトオーダーの規則を引き継ぐことになる。HPDFCMSSignPDFStreamWithExternalSignerはTHPDFCMSSignDigestCallback、すなわちreference to function(const SignedAttributesSHA256: TBytes): TBytes型のクロージャを受け取り、HSM、スマートカードのミドルウェアスタック、あるいはWindowsストアがキーハンドルを渡してくれる証明書ではない何かを通じた署名に使われる。このコールバックの背後にどんなバックエンドがあろうとも、それが返すバイトはHotPDFがCMS構造に組み込む前にビッグエンディアン順になっていなければならない
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
ここで一つの境界を明確にしておく価値がある:HotPDFの2つの組み込み署名経路、すなわちNCryptSignHashによるPKCS#1パディング付きのCNGと、CryptSignHashAによるCAPIは、いずれも32バイトのSHA-256ダイジェストに署名するRSA鍵を対象としている。どちらもECDSA署名形式を扱わない。秘密鍵がEC方式の証明書には、固定長のRSAバイト文字列を前提とせずCMSが期待する方法でECDSA署名をエンコードする、自分で書いた署名機能をHPDFCMSSignPDFStreamWithExternalSignerに対して用意する必要がある。したがってEC証明書が発行されたトークンに対して組み込みの証明書ストア署名機能が正しく動作することは期待しないでほしい
なぜC++BuilderはCertOpenStoreのリンクに失敗するのか
それはRAD Studioの既定のC++Builderインポートライブラリであるimport32.libがCertOpenStoreや、その近隣の5つの関数——CertEnumCertificatesInStore、CertGetCertificateContextProperty、CertFreeCertificateContext、CertCloseStore、CryptAcquireCertificatePrivateKey——をエクスポートしていないためである。Delphiのビルドではこの問題は決して現れない、なぜならdcc32/dcc64は静的なexternal 'crypt32.dll'インポートをPEのインポートテーブルへ直接解決するからだ。C++Builderは事情が異なる:Delphiコンパイラがパッケージビルド用のOMF .objを生成し、ilink32がそれをリンクする。その時点で同じexternal宣言は単に、コマンドラインのインポートライブラリを待つ未解決シンボルにすぎない。Windows SDKのpsdkディレクトリ(そこにある完全なcrypt32.libは6つのシンボルすべてをエクスポートしている)をリンカに指定してもこれは解決しない:ilink32はコマンドラインに実際に名前が挙がっているインポートライブラリ(既定ではimport32.lib cp32mt.lib)だけをリンクし、検索パスを追加してもそこから何かを余分に取り込むことにはならない。import32.libに対してtdumpを実行するとこのギャップが直接確認できる、CertOpenStoreのヒットはゼロで、SDKのcrypt32.libでは6件のクリーンなヒットがある
HotPDFは、ライブラリの他の箇所ですでに証明書列挙を処理している方法と同じ手法でこれを解決する:リンカにこれらのシンボルを求める代わりに、実行時にそれらをロードするのだ。内部のTHPDFCryptoProcsレコードはcrypt32.dllのハンドル、advapi32.dllのハンドル、そして11個の関数ポインタフィールドを保持している。LoadCryptoProcsは両方のDLLをロードし、HPDFSignPDFStreamWithSystemCertificateの開始時に一度だけGetProcAddressで全エントリポイントを解決し、署名フロー深部でアクセス違反として後で失敗するのではなく、何かが欠けていれば即座にEHPDFCMSErrorを発生させる
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
ロードは各ヘルパー内で遅延的に行うのではなく呼び出しごとに一度だけ行われる。なぜならCNGとCAPIを選択するクロージャがロード済みの関数テーブルを値渡しで捕捉しており、HPDFCMSSignPDFStreamWithExternalSignerへのコールバックを含む署名フロー全体の間、それが生き続けなければならないからだ。両方のDLLハンドルは署名が完了するか例外を発生させた時点で、最も外側のfinallyブロックで解放される。これはいずれも公開インターフェースには影響しない:HPDFSignPDFStreamWithSystemCertificate、HPDFSignPDFFileWithSystemCertificate、THPDFCertificateStoreSelectorはすべて以前と全く同じシグネチャを保っているため、この修正を取り込むには既存の呼び出し元にとって再ビルドだけで済み、コード変更は不要である
この対策が扱わない範囲
バイトオーダーとC++Builderのリンクを正しくすることで、バリデータが解析でき算術的にチェックできるCMS SignerInfoが生成される。しかしそのバリデータがその背後の証明書を信頼すべきかどうかについては何も語らない、なぜならチェーン構築、失効確認、タイムスタンプポリシーはCMSオプションを通じてその上に積み重ねられる別個の関心事であり、バイトオーダーの正しさがただで買ってくれるものではないからだ。2つの管理上の詳細が暗号処理と同じくらい重要である:証明書検索から返されるPCCERT_CONTEXTはストアを閉じる前にCertFreeCertificateContextで解放しなければならず、取得したCNGまたはCSPのキーハンドルは、APIが呼び出し元がそれを所有すると報告している場合、決してもう一方ではなく対応するバックエンド自身の呼び出しを通じて解放しなければならない。この一連の処理の後で得られるsvValidの結果が想定より狭い範囲に見えるなら、PDF電子署名の検証に関する記事がそのフラグが正確に何を約束し、何を約束しないのかを詳しく説明している。証明書がここではずっとWindowsの管理下にあり続けるため、証明書ストア署名は攻撃対象領域全体を回避する:解析すべきPKCS#12ファイルもなく、自分でたどるべきASN.1もない。これはPFXファイル署名経路のためにHotPDFのPKCS#12とASN.1の堅牢化が代わりに対応している問題である
証明書ストア署名、PFX署名、外部署名コールバックは、DelphiおよびC++Builder向けHotPDF PDFコンポーネント内にある同一のCMS/PKCS#7パイプラインへの3つの入口であり、どれを選ぶべきかは主に誰が秘密鍵を保持できるかで決まる:自分のプロセスか、PFXファイルか、それともWindows自身か