PDFium VCLは、macOSキーチェーンが保持する秘密鍵でPAdESドキュメントに署名します。バックエンドはすべてのSecurityとCoreFoundationのシンボルを実行時にdlopenとdlsymで解決します。リンク時にバインドされるものは何もなく、つまり打ち間違えたシンボル名は、リンカエラーやクラッシュとしてではなく、KeychainAvailableがFalseを返し、KeychainMissingSymbolsが犯人の名前を挙げるという形で表面化します
この選択は、居心地の悪い制約に強いられたものであり、その扱い方は一般化に値します。このユニットはmacOS SDKを持たないマシンで書かれたため、フレームワークのシンボル名も定数もすべてドキュメント由来で、ヘッダーと突き合わせて確認できるものは1つもありませんでした。この状況への間違った対応は、注意深く書いて祈ることです。正しい対応は、避けられないミスが、できる限り居場所を特定しやすい形で自分から名乗りを上げるように仕向けることです
ターゲットプラットフォーム上でも動的バインドが正解である理由
プログラムを止める種類の失敗を、自分で報告する種類の失敗へ変換するからです。静的にリンクされたフレームワーク参照が間違っていれば、ターゲット上でリンク時に失敗し、他ではリンクすらされません。動的にバインドされた参照が間違っていれば、利用不可能なバックエンドと未解決名のリストが手に入ります。Macでの初回実行は、「なぜ使えないのか」という問いを、タイプミスを指名する1行へ変えてくれます
もう1つの利点は、一度きりではなく日々の利回りになることです。ユニットはどのフレームワークもリンクしないため、あらゆるプラットフォームでコンパイルでき、通常のWindowsビルドが構文、型、uses節を検査し続けます。チームの誰も持っていないプラットフォームでしかコンパイルされないユニットは、コンパイラの目に一切触れないユニットであり、共有型がリファクタされるたびに静かに腐っていきます
uses
FPdfCrypto, FPdfCryptoMac;
var
Options: TPadesSignerOptions;
begin
if not KeychainAvailable then
raise Exception.Create('Keychain backend unavailable, unresolved: ' +
KeychainMissingSymbols);
ConfigureKeychainSignerProvider; // PAdES署名バックエンドとしてインストール
ConfigureKeychainCmsVerifier; // さらに検証バックエンドとしても
Writeln('signer backend : ', PadesCryptoBackendName);
Writeln('verify backend : ', PadesCmsVerificationBackendName);
Options := TPadesSignerOptions.Default;
Options.CertificateThumbprint := 'B1 3F 9C ...'; // SHA-1、大文字小文字は問わない
Options.PaddingScheme := psRsaPss;
end;
2種類のエクスポートシンボル、2通りの読み方
これはバインディング全体で最も混乱を招く細部であり、逆に取ってもクリーンにコンパイルが通り、実行時に壊れます。CoreFoundationとSecurityは、カテゴリ的に異なる2種類のものを同じdlsym呼び出しでエクスポートしており、コードはどちらがどちらかを知らなければなりません
キーチェーン項目クラスのキーやCoreFoundationのブーリアンシングルトンのような名前付き定数は、中身が欲しいCFStringRefやCFBooleanRefである変数としてエクスポートされています。dlsymが返すのはその変数のアドレスなので、値を得るには1回デリファレンスが必要です。一方、辞書のキーと値のコールバックのようなコールバックテーブル構造は構造体としてエクスポートされており、dlsymが返すのは構造体のアドレスです。辞書生成関数が期待しているのはまさにこのポインタです。こちらをデリファレンスすると、構造体の先頭マシンワードをポインタかのように渡してしまうことになります
どちらの間違いもコンパイルエラーを生まず、明確な実行時エラーも生みません。手元に残るのは、下流のどこかで壊れるゴミポインタです。区別を間違えようがなくする方法は、覚えることに頼るのをやめることです。ヘルパー関数を2つ用意します。1つはバインドしてデリファレンスし、もう1つはバインドだけします。呼び出し箇所がどんな種類のシンボルを求めているかを宣言し、残りはヘルパーが強制します
// エクスポート変数:dlsymが返すのはCFTypeRefを保持する変数のアドレス
// なので、1回デリファレンスする
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');
// エクスポート構造体:dlsymが返すのは構造体そのもののアドレスで、
// APIが求めているのはまさにそれ。デリファレンスしてはならない
FKeyCallbacks := BindStruct(CoreFoundationLib,
'kCFTypeDictionaryKeyCallBacks');
RSA-PSS署名に2つの別個のフォールバックが要る理由
アルゴリズムは2つの独立した形で欠け得るためで、そのうちバージョンの問いなのは1つだけです。PSSのダイジェスト署名アルゴリズム定数はmacOS 10.13で登場したため、より古いシステムにはそのシンボルが存在せず、バインドはnilを受け取ります。これがバージョンチェックです。別個に、定数が存在するシステムでも、特定の鍵は依然として拒むことがあり、フレームワークはその問いに、鍵ごとのSecKeyIsAlgorithmSupportedで答えます。ハードウェアバックアップの鍵や制限的な属性を持つ鍵は、同じマシンのソフトウェア鍵が受け付けるPSSを断ることがあります
どちらの経路も同じフォールバック、つまりPKCS#1 v1.5への切り替えに帰着しなければなりません。そして肝心なのは、フォールバックが署名呼び出しだけでなく、CMS構造に書き込まれるアルゴリズム識別子も変えなければならない点です。PSSのアルゴリズム識別子を出力しながら実際にはv1.5署名を生成すると、すべての検証器が即座に拒否するドキュメントができあがります。PSS非対応と報告するより明確に悪いのです。ダウングレードは許容でき、宣言したことと実際にしたことの不一致は許容できません。これはmacOSの癖ではなく、署名コード全般のルールです。署名レベルでの含意はPAdES B-BによるPDF署名に整理されています
ECDSA署名のエンコーディング、そして注目に値する逆転
楕円曲線の経路はmacOSではまったく変換を要せず、これはPKCS#11バインディングが要求するものの正反対です。SecurityフレームワークのECDSA向けダイジェスト署名アルゴリズムは、署名を最初からX9.62 DER形式で返します。CMSが欲しいのはまさにこれです。PKCS#11トークンはその代わり、生の固定幅P1363ペアを返し、署名構造に入る前に再エンコードしなければなりません
つまり同じインターフェースを実装する2つのバックエンドが、同じアルゴリズムに対して逆の処理を必要とするわけです。そしてどちらも間違いではありません。これはまさに、抽象化が晒すのではなく吸収すべき種類の違いです。PAdES層はプロバイダに署名を依頼するだけで、エンコーディングの慣習はプロバイダの内部にとどまります。上へ漏れ出すと、すべての呼び出し側がバックエンドごとの条件分岐を抱え込むことになります。同じ構図は、HSMに対するリモートPAdES署名セッションで述べたリモート署名の話にも現れます
// プロバイダインターフェースはどのプラットフォームでも同じなので、
// 選択は呼び出しごとではなく起動時の決定になる
{$IFDEF DARWIN}
if KeychainAvailable then
ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
// Windows CNGプロバイダはプラットフォームユニットがインストールする
{$ENDIF}
if not PadesCryptoAvailable then
raise Exception.Create('no signing backend on this platform');
// ここから先の署名コードはプラットフォーム中立
Signer := ResolvePadesSigner(Options);
3行しか離れていない参照カウントのルール
Core Foundationのメモリ管理は命名規約に従います。そしてここでの罠は、規約の異なる関数が、同じ短いブロックの中に隣り合わせで現れることです。トラストオブジェクトから証明書を取得する関数は、解放してはならない借用参照を返します。署名者証明書やそのデータをコピーする関数は、解放しなければならない所有参照を返します。3つの呼び出しが連なり、所有権のルールは2種。借用参照を解放しても、その行では失敗しません。リテインカウントを腐らせ、後に無関係な何かを道連れにします
緩和策は、クリーンアップを書く前に、フレームワークの関数名に含まれる動詞を毎回、例外なく読むことです。APIがコピーを返すのかビューを返すのかを確認することのCoreFoundation版であり、間違えた代償はエラーではなく間欠クラッシュです
このバックエンドが主張しないこと
執筆時点でこのコードはmacOS上で動いたことがなく、はっきりそう言うことは、ほのめかした保証より有用です。実証できる事実はより狭い範囲で、それでも価値があります。ユニットはデイリービルドの一部としてWindows上でコンパイルされ、すべてのフレームワークシンボルが実行時に名前でバインドされ、失敗は列挙され、両PSSフォールバックを含むアルゴリズム選択のロジックは、レビューし推論できる普通のPascalです。Macでの初回実行は、動くか、修正すべき名前のリストを出すかのどちらかです
CMS構造を手で組み立てるのではなく、より高水準のCMSデコーダを使う検証側については、SecTrustによるmacOS上のPDF署名検証で扱っています。同じバインディング基盤と、同じ診断アプローチを共有しています
ここから持ち帰るべき発想は、macOSではなくリスクの置き場所についてのものです。検証できないインターフェースに対するコードを書かざるを得ないときは、ミスの居場所を特定するのが最も安く済む構造を選びます。未解決名の明示的なリストを持つ動的バインドは、検証不能な20個の仮定を、診断の1行へ変えてくれます。両バックエンドはソース付きでPDFium Delphiコンポーネントに同梱されているため、シンボル名の修正が必要になっても、サポートチケットではなく自分のツリーでの1行変更で済みます