技術文章

FPC 上 PDFium VCL 的 libcurl 時間戳記後端

PDFium VCL 在非 Windows 目標上經由 libcurl 發送 RFC 3161 時間戳記請求,動態繫結八個符號,與繫結 WinHTTP 的 Windows 後端形狀一致。有兩個選項設定決定這條傳輸在負載下可不可靠,而整個 unit 是在一台無法為其目標平台編譯它的機器上驗證的

加蓋時間戳記,是讓簽章活得過憑證到期的手段,而它是坐在簽章操作裡面的一次網路操作。這個組合讓傳輸選擇的分量與平日不同:它跑在工作執行緒上,它對話的伺服器不由您控制,那裡一卡,停擺的是簽章管線,不是頁面載入

為什麼用 libcurl 而不是 FPC 的 HTTP 用戶端?

因為另一條路會把一整套 TLS stack 拖進版本庫,然後要您維護它的版本偵測。Free Pascal 上顯而易見的路線是 fphttpclient 加 OpenSSL socket 層,它敗在細節上:FPC 3.2.2 的 OpenSSL 繫結在多數當前發行版上偵測 OpenSSL 3.x 不可靠,macOS 又在之上加了 LibreSSL 的差異。一件從小小的 HTTP 呼叫開始的事,變成替別人的 TLS ABI 做長期維護

libcurl 自己搞定 TLS 後端,並對平台的信任儲存庫驗證憑證鏈,Pascal 側什麼都不用管。繫結層就是八個符號。這個數字本身就是論點:您的程式碼與一個會移動的相依之間的接觸面越小,發行版升級弄壞您的位置就越少,而且它與既有的 Windows 後端對稱——後端同樣只繫結少數幾個 WinHTTP 進入點

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;

在 Pascal 裡宣告 C 的 variadic 函式

curl_easy_setoptcurl_easy_getinfo 在 C 側是 variadic 的,Object Pascal 沒有辦法表達這件事。行得通的做法是宣告幾個固定原型,每種參數類別一個,全部指向同一個匯出符號:吃 long 的變體、吃 pointer 的變體等等,由呼叫端按實際傳的東西選用

它安全的原因很具體,值得弄懂而不是照抄。在目前平台生效的呼叫慣例底下,那些參數型別的每一種都經整數暫存器傳遞,而這正是 C 實作 va_arg 讀取它的位置。所以這個手法對整數、指標與 handle 成立,對浮點數參數成立——後者走的是不同的暫存器。別因為假設這個模式可以推廣,就加一個吃 double 的變體

// 一個匯出符號,數個固定原型。每個變體都把參數
// 傳在整數暫存器裡,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;

決定請求完不完得成的兩個設定

第一個是明確送出空的 Expect: 標頭。libcurl 對超過大約一 KB 的請求主體會打開 HTTP 100-continue 交握,而帶憑證請求的時間戳記查詢通常越過這條線。有些 TSA 伺服器永遠不回答 continuation,客戶端只好等完整個 timeout,才送出一個伺服器本來會立刻收下的主體。送出空的 Expect: 標頭把交握壓掉,請求一個來回就走完

第二個是必須設定的 CURLOPT_NOSIGNAL。沒有它,libcurl 用 SIGALRM 實作名稱解析的 timeout,而那個機制不是執行緒安全的。簽章跑在工作執行緒上,所以預設行為是一顆地雷:只在併發下爆炸,單執行緒測試裡永遠安好。設了這個旗標,基於訊號的路徑停用,代價只是名稱解析 timeout 的粒度

兩個缺陷共用同一種畫像,讓它們事後找起來特別貴。單執行緒、對一個乖巧伺服器的功能測試,兩個都不現形;兩個都在正式環境、對某一家特定的 TSA、在負載下出現。繫結網路程式庫的時候,先讀讀它的預設對您的行程做了什麼假設,再假設那些假設與您相符

PDFium VCL 的 libcurl 時間戳記傳輸圖解:curl_easy_setopt 宣告為吃 long 與 pointer 的固定 Pascal 原型、參數經整數暫存器傳遞;壓掉 HTTP 100-continue 交握的空 Expect 標頭;把工作執行緒上的 SIGALRM 路徑移除的 CURLOPT_NOSIGNAL;以及傳輸層的回應上限
兩個設定決定請求完不完得成:空 Expect 標頭避開永遠不回答 continuation 的伺服器,NOSIGNAL 在簽章跑於工作執行緒時把名稱解析 timeout 擋在訊號路徑之外

怎麼驗證您的編譯器永遠看不到的程式碼?

辦法是透過一份受控副本,讓編譯器反正看見它。這裡的開發機沒有 Linux 或 macOS 的跨編譯器,所以時間戳記 unit 的非 Windows 分支在正常建置裡永遠到不了程式碼產生器。永遠不被編譯的程式碼,是默默爛掉的程式碼:共用型別改了名、參數清單變了、多了一個 unit 相依,幾個月沒人發現

手法是機械式的。把 unit 複製到暫存目錄、改個名,把每一個 Windows 條件——{$IFDEF MSWINDOWS} 形式與 {$IF DEFINED(MSWINDOWS) 形式都算——換成一個永不定義的符號,然後編譯那份副本。當全部 3,828 行編過,您就證明了非 Windows 路徑用的 unit 都存在、呼叫的後端函式簽名相符、參照的型別都在作用域內。這不是傳輸能動的證明,那個只有目標平台本身給得了;它證明的是分支沒有已經壞掉——而那才是真正會累積的失敗模式

配套的習慣是讓 libcurl unit 本身不帶任何平台防衛,這樣即使 Windows 建置裡沒有任何東西參照它,它也參與日常建置。每日建置於是順手幫它看著語法與型別,免費的。一個只在您沒有的平台上編得過的 unit,是沒有任何編譯器在檢查的 unit;同樣的道理貫穿Delphi 與 FPC 跨編譯器陷阱描述的整個跨編譯器工程

替回來的東西設上限

時間戳記回應是一個小的 DER 結構,而傳輸層沒有任何東西強制這一點。一台被入侵、設定錯誤、或只是被指向錯誤 URL 的伺服器,可以回傳任意串流,而一個讀到連線關閉才停手的客戶端會高高興興地把全部累積下來。所以兩條傳輸都給回應設上限,這也正是上限該在的位置:在傳輸層拒絕,超大主體根本沒機會被配置;解析器層級的檢查,要等記憶體都已經承諾出去之後才會觸發

同樣的道理適用於 URL。後端只接受它真能說得通的 scheme,組態錯誤於是立刻失敗、附帶清楚的訊息,而不是被丟給 libcurl,按它的協定支援允許的任何方式去詮釋

傳輸在簽章故事裡的位置

加蓋時間戳記是長期驗證故事的第一步,不是全部。權杖必須附到簽章上,驗證材料必須記錄進文件安全儲存庫(DSS),封存時間戳記必須在現有的弱化之前換新。整段弧線記錄在RFC 3161 時間戳記與 DSS 的長期 PDF 簽章

PDFium VCL 的 RFC 3161 時間戳記請求流程圖:從 DocumentDigest 經 BuildTimeStampQuery 與 PostTimeStampQuery 走 libcurl 抵達 TSA 伺服器,DER 回應在傳輸層設上限,再由 AttachTimeStampToken 餵給 DSS 與長期驗證裡的封存時間戳記換新
加蓋時間戳記是長期驗證故事的第一步:權杖必須附上、驗證材料必須記錄進文件安全儲存庫、封存時間戳記必須在現有的弱化之前換新

這條傳輸也是更大可攜性佈局的一塊:在任何目標上載入原生程式庫一文描述的原生程式庫載入器,為 PDFium 二進位檔本身處理同一類問題。兩邊的模式一模一樣:動態繫結少數幾個符號,精確回報什麼沒繫上,永遠不讓一個缺失的相依變成擋住應用程式啟動的連結期失敗

Windows 與非 Windows 的時間戳記後端都隨 PDFium Delphi component 出貨,按目標平台選用而不是按組態,Linux 上的 Lazarus 應用程式與 Windows 上的 Delphi 應用程式,因此透過不同的管線產出同一種帶時間戳記的簽章