PDFium VCL 在 Windows 上離線檢查 PDF 簽章撤銷的做法,是給 CertGetCertificateChain 的撤銷階段加上 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,因為鏈建置用的僅快取旗標完全涵蓋不到 CRL 或 OCSP 的抓取。因此離線的 ValidatePadesTrust 呼叫完全不碰網路,而一個乾淨的結果需要真實的逐憑證撤銷證據。這篇文章要講的,就是這句話的每一半為什麼都需要刻意為之
這個陷阱出現在一個再普通不過的環境裡。驗證服務跑在一台防護嚴密的 Windows 主機上,TPadesTrustValidationOptions.NetworkPolicy 設為 ptnpOffline(這也是預設值),操作者預期每個答案都來自本機憑證快取。撤銷階段只掛了鏈建置的旗標,很快就有人的防火牆記錄裡出現連往 CA 發佈點的對外請求,或者某個批次工作在每張簽章上卡滿 UrlRetrievalTimeoutMs 的 15000 ms。程式碼裡沒有任何地方要求網路,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 請求會告訴響應者你在查哪張憑證,而這正是氣隙驗證器理應避免的事
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。第二次呼叫加上撤銷旗標。如果第二次呼叫的失敗把 GetLastError 寫進 TrustError,那麼一把才剛驗過可信的鏈,可能只因為某個撤銷提供者打了個嗝,就回頭被看成不可信。PDFium VCL 立刻讀取 GetLastError,把它存進 TPdfCmsVerifyResult.RevocationError,不動第一輪的判決。第二次呼叫回傳 True 也不當成成功;它只代表 Windows 交回了一個值得檢查的鏈內容
全零的信任錯誤遮罩到底證明了什麼?
單獨看,什麼也不證明。撤銷階段之後彙總的 TrustStatus.dwErrorStatus 為零,只說沒有任何錯誤位元被舉起,而一把完全沒有元素帶撤銷資訊的鏈,恰好就能產出這個結果。把「沒有 revoked 位元、沒有 unknown 位元、沒有 offline 位元」直接對應成 valid,是驗證器把未檢查的憑證報成乾淨憑證的經典做法。PDFium VCL 裡的 ReadWinRevocationEvidence 會走過每一條 simple chain 的每一個元素,拒收 cbSize 小到無法安全讀取的結構,並且只在「至少存在一個非根元素,且每個這類元素都帶著 dwRevocationResult 為零的 CERT_REVOCATION_INFO」時才回報成功
// 節錄自證據走訪:一個元素只有在撤銷提供者
// 真的為它回答過時才算數
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,兩者在 TPadesSignatureValidation 上各有自己的診斷欄位:簽署者用 RevocationReason 與 NativeRevocationError,TSA 用 TimeStampRevocationReason 與 NativeTimeStampRevocationError。因此被撤銷的 TSA 憑證無法假扮成被撤銷的簽署者,時間戳記的失敗也不會抹掉已經確立的完整性結果。當 CheckRevocation 為 False,或驗證根本沒走到那個階段時,這些欄位保持 pcrrNone 與 0,所以永遠要把它們跟 RevocationStatus 與 TimeStampRevocationStatus 放在一起讀
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 匯出加入對應欄位,也不改變舊欄位的意義。某台機器的撤銷快取某天裡有什麼,是 Windows 的事;空的快取會產出「unknown」,而不是網路請求或假的「valid」。如果離線驗證一直回傳不確定,治本的辦法在上游:簽署當下就把驗證材料備齊,如以 RFC 3161 時間戳記與 DSS 實現長期 PDF 簽章所述,而不是指望驗證那台機器有個還熱著的快取
離線撤銷處理、逐欄位診斷與證據匯出都是 PDFium VCL for Delphi and C++Builder 裡 PDF 簽章驗證 API 的一部分,與跨平台部署用的 OpenSSL 及 macOS 後端並列