技术文章

Delphi 里 Windows 上的 PDF 签名离线吊销检查

PDFium VCL 在 Windows 上离线检查 PDF 签名吊销,靠的是往 CertGetCertificateChain 的吊销环节里加入 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,因为建链用的那个 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 请求会告诉响应器你在看哪张证书,而这正是气隙验证器应该避免的事

Windows 上离线 PDF 签名验证由两个独立开关把守示意图:CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL 只限制建链,如 AIA 签发者与根抓取,吊销则需要 CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY,否则提供者仍会下载 CRL、发 OCSP 请求,暴露正在验证哪张证书
只要 OnlineRetrieval 为 False,PDFium VCL 就把吊销 cache-only 标志 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。第二次调用加上吊销标志。如果第二次调用的失败把 GetLastError 写进了 TrustError,那么一条刚刚还被验证为可信的链,可能只因吊销提供者打了个嗝,回来就变成不可信。PDFium VCL 立即读取 GetLastError 并存进 TPdfCmsVerifyResult.RevocationError,第一轮裁决原样保留。而且第二次调用返回 True 也不当成成功,它只说明 Windows 交回了一个值得检查的链上下文

一个为零的信任错误掩码究竟能证明什么?

单看它本身,什么也证明不了。吊销环节之后聚合的 TrustStatus.dwErrorStatus 为零,只说明没有错误位被置起,而一条压根没有任何元素携带吊销信息的链,恰恰能产出这个结果。把「没有已吊销位、没有未知位、没有离线位」直接映射成有效,正是验证器把未检查的证书报告成干净证书的经典方式。PDFium VCL 的 ReadWinRevocationEvidence 例程遍历每条简单链和每个元素,拒绝 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 和 0,意思是「没有详细诊断」,不是「通过了」

根豁免在哪里止步

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT 豁免锚点是合理的,因为没人会发布一个吊销根自己的 CRL。陷阱在于判断哪个元素是根。PDFium VCL 只在简单链最后一个元素的 dwInfoStatus 标记它为自签($00000008)或显式 CA 信任($00004000)时才豁免它。离线主机常常抓不到缺失的签发者,链就停在一个中间证书上;把这条部分链的末端元素当根,会悄悄漏掉那张吊销状态最可能不在缓存里的证书。那个元素留在必需集合里、没有提供者应答,结果保持 pcvsIndeterminate

签名与时间戳的吊销结果怎么分开?

作为互不覆盖的独立字段。PAdES 验证器用两次独立的调用分别验证文档签名的 detached CMS 和 RFC 3161 时间戳 token 的 attached CMS,各自在 TPadesSignatureValidation 上有自己的诊断字段:签名者用 RevocationReason 和 NativeRevocationError,TSA 用 TimeStampRevocationReason 和 NativeTimeStampRevocationError。所以被吊销的 TSA 证书冒充不了被吊销的签名者,时间戳失败也不会抹掉已经确立的完整性结果。当 CheckRevocation 为 False,或验证根本没走到这一步时,这些字段保持 pcrrNone 和 0,所以请始终把它们和 RevocationStatus、TimeStampRevocationStatus 放在一起读

PDFium VCL 里签名与时间戳吊销互不混杂示意图:文档签名的 detached CMS 填 RevocationReason 和 NativeRevocationError,RFC 3161 token 的 attached CMS 填 TimeStampRevocationReason 和 NativeTimeStampRevocationError,未检查的阶段在各自状态字段旁留下 pcrrNone 和 0
被吊销的 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 导出加上对应字段,不改变老字段的含义。某台机器的吊销缓存某天到底有什么,是 Windows 的事;缓存为空时产出的是「unknown」,而不是一次网络请求,也不是一个假的「valid」。如果离线验证总是返回不确定,治本的办法在上游:按基于 RFC 3161 时间戳和 DSS 的长期 PDF 签名所述,在签名时就收集好验证材料,而不是指望验证机器有一份热缓存

离线吊销处理、逐字段诊断和证据导出都是 PDFium VCL for Delphi and C++Builder 里 PDF 签名验证 API 的一部分,与面向跨平台部署的 OpenSSL 和 macOS 后端并列