技術文章

PDFium VCL 的 Windows 離線撤銷檢查

PDFium VCL 在 Windows 上離線檢查 PDF 簽章撤銷的方式,是給 CertGetCertificateChain 的撤銷階段加上 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,因為鏈建置用的僅快取旗標完全管不到 CRL 或 OCSP 的擷取。從 v3.119.1 起,一次離線的 ValidatePadesTrust 呼叫完全不碰網路,而乾淨的結果需要每張憑證都有真實的撤銷證據。這篇文章剩下的部分,就是這句話的兩半各自為什麼需要修

暴露問題的環境再普通不過。驗證服務跑在一台防護嚴密的 Windows 主機上,TPadesTrustValidationOptions.NetworkPolicy 是 ptnpOffline(這也是預設值),操作者預期每個答案都來自本機憑證快取。然後有人在防火牆記錄裡注意到發往 CA 發佈點的對外請求,或某個批次工作在每一個簽章上卡滿整整 15000 ms 的 UrlRetrievalTimeoutMs。程式碼裡沒有任何地方要求網路。Windows 自己去了

離線的鏈建置為什麼還會在 Windows 上抓 CRL?

因為 CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL 只限制鏈建置做的 URL 擷取:AIA 簽發者抓取、根憑證與 CTL 更新。微軟的 CertGetCertificateChain 文件明確說了這個旗標不適用於撤銷檢查。撤銷有自己的開關,CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY($80000000),沒有它,撤銷提供者照樣可以下載 CRL 或發 OCSP 請求,即使外圍的呼叫看起來是離線的。只要 OnlineRetrieval 為 False,PDFium VCL 現在就會把那個旗標 OR 進撤銷階段,疊在鏈旗標、CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT 與 CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT 之上。這件事要緊的不只延遲:一個 OCSP 請求會告訴應答器您正在看哪張憑證,而這恰恰是氣隙隔離的驗證器理應避免的事

兩個獨立的開關守護 Windows 上的離線 PDF 簽章驗證:CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL 只限制鏈建置,如 AIA 簽發者與根憑證抓取,而撤銷需要 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,因為沒有它,提供者照樣下載 CRL、發出洩漏正在驗證哪張憑證的 OCSP 請求
只要 OnlineRetrieval 是 False,PDFium VCL 就把撤銷的僅快取旗標 OR 進撤銷階段,ptnpOffline 的信任驗證兩個階段都不碰網路
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline、15000 ms
  Options.CheckRevocation := True;                 // 預設 False
  Options.CheckTimeStamps := True;
  // 離線現在對撤銷也是離線:只用快取的 CRL 與 OCSP
  // 回應,不舉起 pcvstOnlineRetrieval 檢查點
  Report := Pdf.ValidatePadesTrust(Options);
end;

兩次鏈建置、兩個錯誤欄位

Windows 後端把鏈建兩次,而每次建置現在擁有自己的錯誤槽。第一個 CertGetCertificateChain 呼叫不帶撤銷旗標、以基礎政策餵給 CertVerifyCertificateChainPolicy,產出 TrustStatus 與 TrustError。第二個呼叫加上撤銷旗標。v3.119.1 之前,第二次呼叫的失敗會把 GetLastError 寫進 TrustError,所以一把剛被驗為可信的鏈,可能只因為某個撤銷提供者出了一次岔子,回來就變成不可信。修法是立刻讀取 GetLastError 並存進 TPdfCmsVerifyResult.RevocationError,第一輪的裁決原樣不動。而第二次呼叫回傳 True 也不當成成功;它只代表 Windows 交回了一個值得檢視的鏈 context

全零的信任錯誤遮罩到底證明了什麼?

單獨看,什麼也不證明。撤銷階段之後,彙總的 TrustStatus.dwErrorStatus 為零只說沒有任何錯誤位元被舉起,而一把完全沒有任何元素帶撤銷資訊的鏈,恰好能產出這個結果。早先的程式碼把「沒有 revoked 位元、沒有 unknown 位元、沒有 offline 位元」直接映射成有效,這正是驗證器把沒查過的憑證回報成乾淨憑證的經典方式。新的 ReadWinRevocationEvidence 常式走訪每條 simple chain 的每個元素,拒絕 cbSize 小到無法安全讀取的結構,並且只在至少存在一個非根元素、且每個這種元素都帶著 dwRevocationResult 為零的 CERT_REVOCATION_INFO 時才回報成功

ReadWinRevocationEvidence 在 Delphi 裡對每個 Windows 鏈元素做的證據走訪:pRevocationInfo 必須存在、cbSize 必須大到讀得安全、dwRevocationResult 必須為零,而尾端元素只在標記為自我簽署或 CA 信任時才排除,全零的信任錯誤遮罩再也藏不住一張沒查過的憑證
乾淨的裁決需要至少一個非根元素、以及每個必要元素的一個答案,RevocationError 保留提供者的原始 DWORD,例如 CRYPT_E_REVOKED
// 節錄自證據走訪:一個元素只有在撤銷提供者
// 真的為它回答過時才算數
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // 只有根的鏈什麼也證明不了

提供者的結果原樣保留。RevocationError 持有提供者原樣回傳的 dwRevocationResult DWORD,有被撤銷元素時優先取它的錯誤(CRYPT_E_REVOKED 是 $80092010),而信任位元遮罩絕不會被包裝成原生錯誤碼。到 TPdfCmsRevocationReason 的映射刻意保持粗糙:明確撤銷用 pcrrCertificateRevoked 配 pcvsInvalid,鏈因與撤銷無關的原因失敗時是 pcrrChainUntrusted,其他一切都是 pcrrUnknown。Windows 可能試的是 OCSP 而不是 CRL,所以離線或未知的結果不會被翻譯成 pcrrCrlExpired。OpenSSL CMS 驗證後端能做出那些 CRL 特定的區分,因為它只評估您遞給它的 CRL;而 macOS SecTrust 後端把欄位留在 pcrrNone 與零,意思是「沒有詳細診斷」,不是「通過」

根排除停在哪裡

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT 跳過錨點是合理的,因為沒有人會發佈一個撤銷根憑證自己的 CRL。陷阱在於判定哪個元素是根。PDFium VCL 只在一條 simple chain 的最後一個元素被 dwInfoStatus 標為自我簽署($00000008)或明確 CA 信任($00004000)時才排除它。離線主機往往抓不到缺席的簽發者,所以鏈停在某個中繼 CA;把那條不完整鏈的末元素當成根,會悄悄丟掉撤銷狀態最可能不在快取裡的那張憑證。那個元素留在必要集合裡、沒有提供者的答案,結果保持 pcvsIndeterminate

簽章與時間戳記的撤銷結果怎麼分開?

靠永遠不互相覆寫的獨立欄位。PAdES 驗證器以兩次獨立呼叫分別驗證文件簽章的分離式 CMS 與 RFC 3161 時間戳記 token 的附加式 CMS,v3.119.0 給了兩者在 TPadesSignatureValidation 上各自的診斷:簽署者用 RevocationReason 與 NativeRevocationError,TSA 用 TimeStampRevocationReason 與 NativeTimeStampRevocationError。所以被撤銷的 TSA 憑證冒充不了被撤銷的簽署者,時間戳記的失敗也抹不掉已經確立的完整性結果。CheckRevocation 為 False 或驗證根本沒走到那個階段時,欄位保持 pcrrNone 與 0,所以永遠要對著 RevocationStatus 與 TimeStampRevocationStatus 一起讀

PDFium VCL 裡簽章與時間戳記的撤銷保持分離:文件簽章的分離式 CMS 填 RevocationReason 與 NativeRevocationError,RFC 3161 token 的附加式 CMS 填 TimeStampRevocationReason 與 NativeTimeStampRevocationError,未檢查的階段在各自狀態欄位旁留下 pcrrNone 與零
被撤銷的 TSA 憑證因此冒充不了被撤銷的簽署者,時間戳記的失敗也永遠抹不掉簽章驗證已經確立的完整性結果
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

證據報告遵循同一條規則。CSV 匯出在既有欄位順序的尾端追加 revocationReason、nativeRevocationError 與到 nativeTimeStampRevocationError 為止的時間戳記欄位,舊解析器照常工作,JSON 匯出加上對應欄位而不改變舊欄位的意義。如果離線驗證老是回來無法判定,治本的修法在上游:在簽署時就把驗證材料收集好,如搭配 RFC 3161 時間戳記與 DSS 的長期 PDF 簽章所述,而不是指望驗證那台機器有一份溫熱的快取

測試矩陣證明了什麼、沒證明什麼

Windows 驗證矩陣在每個 Delphi 與 FPC 的 Win32 與 Win64 目標上,通過了 30 個受控的鏈 API 情境與一個真實的離線 CMS 煙霧測試。真實煙霧測試驗證的是不可信私有 CA 底下的一個有效簽章,而乾淨與明確被撤銷的結果來自 stub 掉的 CertGetCertificateChain 回應,而不是已安裝的信任錨點或實際的擷取。這是一個值得講清楚的誠實邊界:旗標處理、錯誤隔離與證據走訪都被釘死了,但某台機器的撤銷快取在某天裝了什麼,仍然是 Windows 的事務,而空快取現在正確地產出「未知」,既不是網路請求、也不是假的「有效」

離線撤銷處理、逐欄位診斷與證據匯出都是 PDFium VCL for Delphi and C++Builder 裡 PDF 簽章驗證 API 的一部分,與跨平台部署用的 OpenSSL 及 macOS 後端並列