PDFium VCL 在 Windows 上离线检查 PDF 签名吊销的办法,是往 CertGetCertificateChain 的吊销环节里加上 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,因为建链用的那个 cache-only 标志根本管不到 CRL 或 OCSP 的获取。从 v3.119.1 起,离线的 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 请求,哪怕外面的调用看着是离线的。PDFium VCL 现在只要 OnlineRetrieval 为 False,就把这个标志 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。第二次调用加上吊销标志。v3.119.1 之前,第二次调用的失败会把 GetLastError 写进 TrustError,于是一条刚刚被验证为可信的链,可能因为某个吊销提供者打了个嗝就回来变成不可信。修法是立刻读 GetLastError 并存进 TPdfCmsVerifyResult.RevocationError,第一遍的裁决不动。第二次调用返回 True 也不当作成功;它只说明 Windows 递回了一个值得检查的链上下文
一个为零的信任错误掩码究竟能证明什么?
单凭它自己,什么都证明不了。吊销环节之后聚合的 TrustStatus.dwErrorStatus 为零,只说明没有置起任何错误位,而一条压根没有任何元素带吊销信息的链恰恰能产出这个。早先的代码把「没有吊销位、没有未知位、没有离线位」直接映射成有效,这是验证器把没查过的证书报成干净证书的经典方式。新的 ReadWinRevocationEvidence 例程遍历每条简单链和每个元素,拒掉 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 和 0,意思是「没有详细诊断」,不是「通过了」
根豁免在哪里止步
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT 豁免锚点是合理的,因为没人会发布一个吊销根自己的 CRL。陷阱在于判断哪个元素是根。PDFium VCL 只在简单链最后一个元素的 dwInfoStatus 标记它为自签($00000008)或显式 CA 信任($00004000)时才豁免它。离线主机常常抓不到缺失的签发者,链就停在一个中间证书上;把这条部分链的末端元素当根,会悄悄漏掉那张吊销状态最可能不在缓存里的证书。那个元素留在必需集合里、没有提供者应答,结果保持 pcvsIndeterminate
签名与时间戳的吊销结果怎么分开?
用互不覆盖的独立字段。PAdES 验证器用两次独立调用分别验证文档签名的 detached CMS 和 RFC 3161 时间戳 token 的 attached CMS,v3.119.0 在 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 导出加上对应字段,不改旧字段的含义。如果离线验证总是回来 Indeterminate,治本的办法在上游:签名时就把验证材料收齐,如RFC 3161 时间戳与 DSS 的长期 PDF 签名所讲,而不是指望验证那台机器缓存还热着
测试矩阵证明了什么、没证明什么
Windows 验证矩阵在每个 Delphi 和 FPC 的 Win32 与 Win64 目标上通过了 30 个受控的链 API 场景和一次真实的离线 CMS 冒烟。真实冒烟验证的是不可信私有 CA 下的一张有效签名,干净与显式吊销的结果来自打桩的 CertGetCertificateChain 响应,而不是安装的信任锚或实时检索。这个边界值得如实说清:标志处理、错误隔离和证据遍历都被钉死了,但某台机器的吊销缓存某天装着什么仍是 Windows 的事,而空缓存现在会正确地给出「未知」,而不是一次网络请求或一个假「有效」
离线吊销处理、逐字段诊断和证据导出都是 PDFium VCL for Delphi and C++Builder 里 PDF 签名验证 API 的一部分,与面向跨平台部署的 OpenSSL 和 macOS 后端并列