技術記事

PDFium VCLのlibcurl TSAトランスポート(非Windows)

PDFium VCLは非Windowsターゲットにおいて、RFC 3161のタイムスタンプ要求をlibcurl経由で送ります。8個のシンボルへ動的バインドする形で、WinHTTPにバインドするWindowsバックエンドの構図をなぞっています。トランスポートがロード下で信頼できるかどうかは2つのオプション設定で決まり、ユニット全体の検証は、ターゲットプラットフォーム向けにはコンパイルできないマシンで行われました

タイムスタンプは、署名を証明書の有効期限を乗り越えられるものへ変える仕掛けであり、署名操作の内部に居座るネットワーク操作です。この組み合わせにより、トランスポートの選択が、普段はあり得ないほど重大な結果を持ちます。ワーカースレッド上で動き、自分でコントロールできないサーバーと話をし、そこでハングすると、ページのロードではなく署名パイプラインが止まるのです

FPCのHTTPクライアントではなく、なぜlibcurlなのか

代替案は、TLSスタックをリポジトリに引きずり込んだ上で、そのバージョン検出の保守を押し付けてくるからです。Free PascalではfphttpclientとOpenSSLソケット層の組み合わせが自明な道に見えますが、細部で詰まります。FPC 3.2.2のOpenSSLバインディングは、多くの現行ディストリビューションでOpenSSL 3.xの検出が不安定であり、macOSはさらにLibreSSLの差異を上乗せしてきます。小さなHTTP呼び出しとして始まったものが、他人のTLS ABIの継続的な保守になります

libcurlは自分でTLSバックエンドを解決し、プラットフォームのトラストストアに対してチェーンを検証します。Pascal側はそのどれも要りません。バインディング層は8シンボルです。この数が論点です。自分のコードと動く依存の間の表面積が小さいほど、ディストリビューションのアップグレードに壊される場所が減ります。そしてこれは、少数のWinHTTPエントリポイントを同じやり方でバインドする既存のWindowsバックエンドとも整合します

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

Cの可変引数関数をPascalで宣言する

curl_easy_setoptcurl_easy_getinfoはC側では可変引数であり、Object Pascalにはそれを表現する手段がありません。うまくいくのは、引数の種類ごとに1つ、複数の固定プロトタイプを宣言し、すべてを同じエクスポートシンボルに向ける方法です。longを受け取る亜種、ポインタを受け取る亜種などが並び、呼び出し箇所で実際に渡すものによって使い分けます

これが安全なのには、丸ごと真似るのではなく理解する価値のある特定の理由があります。これらの引数型はそれぞれ、プレイしているプラットフォームの呼び出し規約のもとで整数レジスタに載せて渡され、C実装のva_argが読む場所もまさにそこです。したがってこのトリックは整数、ポインタ、ハンドルには成立しますが、浮動小数点引数には成立しません。浮動小数点は別のレジスタを旅します。パターンが一般化するという思い込みでdoubleを受け取る亜種を追加しないでください

// エクスポートシンボルは1つ、固定プロトタイプは複数。すべての亜種は
// 引数を整数レジスタに載せて渡し、C側が読むのはまさにそこ。浮動小数点の
// 亜種は動かないため、追加してはならない
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

要求の完遂を左右する2つの設定

1つ目は、明示的な空のExpect:ヘッダーです。libcurlは、約1キロバイトを越えるリクエストボディに対してHTTP 100-continueハンドシェイクを有効にしますが、証明書要求を含むタイムスタンプ照会はたいていこの閾値を越えます。TSAサーバーの中にはcontinuationに決して答えないものがあり、その場合クライアントは、サーバーが即座に受け付けたはずのボディを送る前に、タイムアウトを丸ごと使い切ります。空のExpect:ヘッダーを送ればハンドシェイクが抑止され、要求は1回のラウンドトリップで通ります

2つ目はCURLOPT_NOSIGNALで、必ず設定します。これがないと、libcurlは名前解決のタイムアウトをSIGALRMで実装しますが、この機構はスレッドセーフではありません。署名はワーカースレッド上で走るため、デフォルトの挙動は、並行実行時には現れ、シングルスレッドのテストでは決して現れない潜在クラッシュです。このフラグを立てればシグナルベースの経路が無効になり、失うのは名前解決タイムアウトの粒度だけです

2つの障害は、後から見つけると高くつくプロファイルを共有しています。よく振る舞うサーバー相手のシングルスレッドの機能テストでは、どちらも現れません。現れるのは本番環境で、特定の1つのTSA相手に、ロードがかかった状態でです。ネットワーキングライブラリをバインドするときは、デフォルトが自分のプロセスについて何を仮定しているかを、マッチすると思い込む前に読んでおいてください

PDFium VCLのlibcurlタイムスタンプトランスポート図。固定のlongとポインタのPascalプロトタイプとして宣言されたcurl_easy_setoptが引数を整数レジスタで渡すこと、HTTP 100-continueハンドシェイクを抑止する空のExpectヘッダー、ワーカースレッドからSIGALRM経路を外すCURLOPT_NOSIGNAL、そしてトランスポートレベルの応答上限を示す
要求が完遂されるかどうかは2つの設定で決まります。空のExpectヘッダーはcontinuationに答えないサーバーを回避し、NOSIGNALは、署名がワーカースレッドで動く間、名前解決タイムアウトをシグナル経路から外し続けます

コンパイラが決して見ないコードをどう検証するか

管理されたコピーを通じて、それでもコンパイラに見させるのです。この開発マシンにはLinuxやmacOSのクロスコンパイラがなく、通常のビルドではタイムスタンプユニットの非Windowsブランチがコードジェネレータに届くことはありません。コンパイルされないコードは静かに腐っていくコードです。共有型のリネーム、変わったパラメータリスト、追加されたユニット依存、そして誰も数か月気づきません

手法は機械的です。ユニットを一時ディレクトリへコピーし、リネームし、すべてのWindows条件、{$IFDEF MSWINDOWS}形式と{$IF DEFINED(MSWINDOWS)形式の両方を、決して定義されないシンボルへ置き換えます。そしてコピーをコンパイルします。3828行すべてがコンパイルを通れば、非Windowsパスが存在するユニットを使い、シグネチャの一致するバックエンド関数を呼び、スコープ内の型を参照していることは証明できたことになります。トランスポートが動くことの証明ではありません。それをくれるのはターゲットプラットフォームだけです。証明できるのは、ブランチがすでに壊れていないということ、そして実際に蓄積していくのはこちらの失敗形態だということです

付随する習慣は、libcurlユニット自体をプラットフォームガードなしのままにしておくことです。そうすれば、どこからも参照されなくても、通常のWindowsビルドに参加し続けます。デイリービルドがその構文と型をタダで検査し続けてくれるわけです。自分が持っていないプラットフォームでしかコンパイルされないユニットは、コンパイラの検査をまったく受けないユニットであり、同じ道理は、DelphiとFPCのクロスコンパイラの落とし穴で述べたクロスコンパイラ作業全体に当てはまります

返ってくるものに上限を課す

タイムスタンプ応答は小さなDER構造ですが、それを強制するものはトランスポートに何もありません。侵害された、設定を誤った、あるいは単に間違ったURLを向けられたサーバーは任意のストリームを返せますし、接続が閉じるまで読み続けるクライアントは喜んでそれを溜め込みます。そこで両トランスポートは応答に上限を課します。上限を課す場所として正しいのはここです。トランスポートで拒否すれば過大なボディが確保されることすらありませんが、パーサーレベルのチェックはメモリが確保された後でしか発火しません

同じ道理はURLにも当てはまります。バックエンドが受け付けるのは、意味のある形で話せるスキームだけです。設定ミスは、libcurlに渡されてプロトコル対応の許す限りの解釈をされる代わりに、明確なメッセージとともに即座に失敗します

署名の物語の中でトランスポートが座る場所

タイムスタンプは、長期検証の物語の最初の一歩であって、その全体ではありません。トークンを署名に添付し、検証素材をドキュメントセキュリティストアに記録し、現行のアーカイブタイムスタンプが弱まる前に更新し直す必要があります。この一連の弧全体は、RFC 3161タイムスタンプとDSSによる長期PDF署名で扱っています

RFC 3161のタイムスタンプ要求がDocumentDigestからBuildTimeStampQueryとPostTimeStampQueryを経由し、libcurlでTSAサーバーへ流れるPDFium VCLの図。DER応答はトランスポートで上限を切られ、その後AttachTimeStampTokenが長期検証におけるDSSとアーカイブタイムスタンプの更新へつながる
タイムスタンプは長期検証の物語の最初の一歩です。トークンの添付、検証素材のドキュメントセキュリティストアへの記録、そして現行のタイムスタンプが弱まる前のアーカイブタイムスタンプの更新が必要です

トランスポートは、より広いポータビリティ戦略の1ピースでもあります。任意のターゲットでネイティブライブラリをロードする仕組みは、PDFiumバイナリそのものについて同じ種類の問題を扱います。どちらでもパターンは同一です。少数のシンボルを動的にバインドし、バインドに失敗したものを正確に報告し、欠けた依存がアプリケーションの起動を止めるリンク時失敗に変わることは決して許さない

Windowsと非WindowsのタイムスタンプバックエンドはどちらもPDFium Delphiコンポーネントに同梱され、設定ではなくターゲットによって選択されます。Linux上のLazarusアプリケーションとWindows上のDelphiアプリケーションが、異なる配管を通じて同じタイムスタンプ付き署名を生み出すのです