技術文章

在 Delphi 裡為 PDF 簽章抓取 OpenSSL AIA 與 CRL

PDFium VCL 的 OpenSSL 後端現在能透過網路補完 PDF 簽章鏈、檢查廢止狀態:啟用 OnlineRetrieval 後,ConfigureSslCmsVerifier 會安裝一個驗證器,從 AIA caIssuers URL 下載缺席的中繼憑證、從 CRL 發佈點抓取 CRL,全部圈在每次驗證呼叫固定的時間、請求數與位元組預算之內。下載來的憑證永遠只是鏈材。信任仍然只來自系統存放區與您設定的錨點

這個缺口在 Linux 伺服器上第一次驗真實世界的 PDF 時就會現形。相當多的簽署者只在 CMS 裡內嵌自己的葉憑證,OpenSSL 於是摸不到任何根憑證,TrustStatus 回報無效,而鏈從頭到尾都不可信,廢止檢查自然一次也沒跑。v3.121.0 之前,用 PDFium VCL 的 OpenSSL 驗證 PDF 簽章一文描述的 OpenSSL 後端是純離線的,OnlineRetrieval 對它毫無作用。有一件事值得先講明白:PDFium 引擎本身完全不做 CMS 驗證,所以下面的每一條規則都住在元件的 PAdES 層與它的 OpenSSL 綁定裡,您讀得到

OpenSSL 後端驗證、抓取、檢查的順序是什麼?

先完整性,再信任,再廢止,網路只在需要它的步驟之間才碰。VerifyCmsWithSsl 在壓抑鏈評估的狀態下檢查 CMS 簽章與 signed attributes(RFC 5652),這一步失敗就立刻返回,抓取工作階段連影子都還沒有,所以位元組損壞的文件不會觸發任何外連請求。只有鏈驗不過、而 OnlineRetrieval 又開著時,才會跟著 AIA 連結走一遍、再驗一次。CRL 發佈點要等鏈受信任之後才抓,因為掛在不受信任路徑上的 CRL 什麼也證明不了。三個判定從頭到尾各自獨立:簽章有效、鏈不完整,回報的仍是有效簽章

PDFium Component OpenSSL 後端裡 VerifyCmsWithSsl 的順序:CMS 簽章檢查在壓抑鏈評估的狀態下執行,損壞的位元組絕不碰網路;鏈驗證失敗且 OnlineRetrieval 開啟後,RetrieveIntermediates 才跟著 AIA caIssuers URL 走,鏈受信任後 RetrieveCrls 才把發佈點的 CRL 抓進獨立的存放區
先完整性,再信任,再廢止:網路只在需要它的步驟之間才碰,掛在不受信任路徑上的 CRL 什麼也證明不了
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER;唯一的額外信任來源
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // 預設為 ptnpOffline
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // 每次驗證呼叫

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

下載來的憑證為什麼絕不進信任存放區?

因為這些 URL 來自受驗的憑證本身,而挑它們的是簽署者。authorityInfoAccess 的 caIssuers 條目(RFC 5280 §4.2.2.1)只是關於簽發者住在哪裡的提示,僅此而已。要是回應那個 URL 的傢伙能進信任錨點存放區,任何人都能拿自製金鑰簽章、把 AIA 指向自己的伺服器、然後領到一個綠燈判定。所以 RetrieveIntermediates 把每張解析出的憑證交給 CMS_add1_cert,放進這個 CMS 結構自己的不受信任集合,OpenSSL 仍得從那裡建出一條通往您設定的錨點或系統存放區的路徑。還有個更隱蔽的理由:CMS_verify 的憑證參數並不是 CMS 內嵌憑證的直接替代品,所以往 CMS 本身裡加才是靠譜的路

抓取迴圈刻意收得很窄。RetrieveIntermediates 最多跑 4 輪,每輪從此刻 CMS 裡的每張憑證收集 caIssuers URL,某一輪一無所獲或時間預算用完就停。回應必須能用 d2i_X509 解成單張吃掉整個 body 的 DER 憑證;尾巴多出位元組就拒收,從 .p7c URL 送來的 PKCS#7 純憑證包則直接跳過、不解包。同一個 AIA 擴充裡的 OCSP 存取方法一律無視,因為這個後端不說 OCSP。廢止這邊,RetrieveCrls 只從 CMS 憑證與設定的錨點讀每個 DistributionPoint 的 fullName URI(RFC 5280 §4.2.1.13),下載的 CRL 進入第二個獨立的 X509_STORE,做全鏈 CRL 檢查,於是缺 CRL 或 CRL 過期只會動到 RevocationStatus,碰都不碰 TrustStatus

// 節錄自 VerifyCmsWithSsl(FPdfCryptoSsl.pas);省略 BIO 設定。
// 每次 _CMS_verify 呼叫都拿到全新的 content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // 簽章已壞:完全不碰網路
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert,只進不受信任集合
  // 對同一個錨點存放區再驗一次鏈
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// 第二個獨立存放區:設定的 CRL 加上下載的 CRL
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

一次驗證呼叫最多花掉多少?

一個固定上限,由單一 TPdfCryptoFetchSession 執行,同一次驗證呼叫的 AIA 與 CRL 步驟共用。這些限制是 FPdfCryptoHttp 裡的常數,不是建議值:

  • 時間:UrlRetrievalTimeoutMs,在 TPdfCmsVerifyOptions.Default 與 TPadesTrustValidationOptions.Default 裡都預設 15000;以 0 建立的工作階段退回 30000,時鐘從簽章通過那一刻起算,涵蓋之後的每個請求
  • 請求數:每個工作階段最多 8 個,嘗試傳輸之前就計數,所以連不上的主機照樣吃掉一個名額
  • 位元組:每個回應 1 MiB、總計 4 MiB,超過 2048 字元的 URL 在建立連線之前就拒收
PDFium Component 裡一次驗證呼叫的 AIA 與 CRL 步驟共用同一個 TPdfCryptoFetchSession 的硬上限:UrlRetrievalTimeoutMs 預設 15000 ms、以 0 建立時退回 30000,每個工作階段最多 8 個請求,每個回應 1 MiB、總計 4 MiB 且失敗的回應照樣計入,超過 2048 字元的 URL 拒收
限制是常數不是建議值:失敗回應的位元組照樣消耗預算,而每枚簽章與每個時間戳記各自驗證,最壞情況隨簽章數量增長

記帳方式比乍看之下更嚴。失敗回應收到的位元組照樣計入總量,所以拿大頁面回 404 的伺服器別想免費吸乾預算。跨過單一回應上限的讀取會中止下載,而不是把截斷的 body 丟給 ASN.1 解析器;HTTP 200 配空 body 直接拒收,否則 AIA 路徑會去索引空陣列的 Data[0]。只有樸素的 http:// 與 https:// URL 放行,不跟重新導向、不收 cookie、不帶憑證資訊、不做自動 proxy 探測,HTTPS 照常做憑證與主機名稱檢查。URL 去重的範圍刻意限定在單次呼叫:下一次驗證必須看得見剛發佈的 CRL。預算也是按呼叫計、不是按文件計,而 ValidatePadesTrust 對每枚簽章、每個時間戳記權杖分開驗證,所以最壞情況隨簽章數量增長

逾時的 WinHTTP 請求為什麼還能寫進您的記憶體?

因為逾時返回並不會取消已經在路上的回呼。Windows 傳輸層以非同步方式驅動 WinHTTP,拿工作階段的剩餘時間等一個事件;等待放棄之後,請求仍可能完成一次讀取、事後再發訊號。要是把非同步讀取指向堆疊緩衝區,這個遲到的完成就會寫進某個無關函式的堆疊框架。修法靠的是所有權,不是時序:事件與 16 KB 讀取緩衝區住在一份有兩個參照的堆積記錄裡,一個由呼叫端持有,另一個只由最後的 HANDLE_CLOSING 回呼釋出,誰最後收工誰釋放記憶體

逾時的 WinHTTP 請求為何還能寫進記憶體:逾時返回留下還在路上的回呼,所以 PDFium Component 把非同步讀取指向堆積配置的 THttpState 記錄,其 16 KB 緩衝區與兩個參照——呼叫端持有一個、最後的 HANDLE_CLOSING 回呼釋出另一個——要等最後一方收工才釋放
遲到的完成可能在您的等待放棄之後才讀完;帶兩個參照的堆積所有權,讓這一筆寫入落在還活著的記憶體上
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // 呼叫端 + 最後的 HANDLE_CLOSING 回呼
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // 非同步讀取落在這裡,絕不落棧
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// 狀態回呼裡:HANDLE_CLOSING 是 WinHTTP 為這個請求發出的
// 最後一個通知,所以在這裡釋出第二個參照
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

FPC Unix 上 libcurl 必須具備什麼

非同步 resolver 加上執行緒安全的建置,缺一樣線上抓取就得保持關閉。在 FPC Unix 上,傳輸層走 libcurl——與非 Windows 目標的 libcurl 時間戳記後端背後是同一個相依套件——而綁定層拒收功能旗標缺 CURL_VERSION_ASYNCHDNS 或 CURL_VERSION_THREADSAFE 任一項的函式庫。原因在於 CURLOPT_NOSIGNAL:寄人籬下的函式庫必須設它,配上同步 resolver 就意味著一次 DNS 查詢可以活活拖過逾時。第二個陷阱是收尾:curl_global_cleanup 不等非同步 DNS 執行緒,所以 libcurl 一旦初始化,模組就得保持載入到行程結束,免得背景執行緒衝進已卸載的程式碼。任一條件不成立時,SslCapabilities.OnlineRetrieval 為 False,SslVerifyOptionsDiagnostics 回報 psvdOnlineRetrievalIgnored,而不是假裝查過網路

這個結果保證什麼、不保證什麼

這個後端給出有效的 RevocationStatus,意思是涵蓋整條鏈的當期 CRL 已找到——不論是設定好的還是下載來的——而且沒有任何一張把鏈上的憑證列為廢止;僅此而已。沒有 OCSP,所以只透過 OCSP 發佈廢止資訊的 CA 會讓結果落在不支援,而一次網路失敗看上去與什麼都不發佈的 CA 毫無分別。另外注意,psvdNoCrlsConfigured 描述的只是您設定的那批 CRL,開著線上抓取時它只是個提示,不是失敗預告。稽核軌跡必須在無網路環境下可重現時,把 NetworkPolicy 留在預設的 ptnpOffline:不建立任何抓取工作階段,後端絕不開連線,這與 CryptoAPI 端的離線契約一致,也就是Windows 上的離線 PDF 簽章廢止檢查一文描述的那套

抓取程式碼、預算與傳輸層綁定以原始碼形式隨 PDFium Delphi component 出貨,所以在處理不受信任文件的伺服器上啟用 ptnpOnline 之前,您可以確切核對一次驗證可能連絡哪些 URL、最多下載多少