技术文章

PDFium VCL 的 OpenSSL 后端联网取证书与 CRL

PDFium VCL 的 OpenSSL 后端现在能联网补全 PDF 签名链并检查吊销:启用 OnlineRetrieval 后,ConfigureSslCmsVerifier 安装的验证器会从 AIA caIssuers URL 下载缺失的中间证书、从 CRL 分发点下载 CRL,每次验证调用都有固定的时间、请求和字节预算。下载来的证书只配当链材料。信任仍然只来自系统存储和你配置的锚点

这个缺口在你第一次于 Linux 服务器上验证真实 PDF 时就会现形。相当一部分签名者只在 CMS 里嵌自己的叶证书,OpenSSL 够不着根证书,TrustStatus 返回 invalid,吊销检查也永远不会跑,因为链从未变得可信。v3.121.0 之前,用 OpenSSL 在 PDFium VCL 中验证 PDF 签名里描述的 OpenSSL 后端严格离线,OnlineRetrieval 对它毫无作用。有件事值得先说清楚:PDFium 引擎本身完全不做 CMS 验证,所以下文每条规则都住在组件的 PAdES 层和它的 OpenSSL 绑定里,你读得到源码

OpenSSL 后端按什么顺序验证、抓取、检查?

先完整性,再信任,再吊销,网络只在需要它的步骤之间被触碰。VerifyCmsWithSsl 在抑制链评估的状态下检查 CMS 签名和签名属性(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 来自被验证的那张证书,而选 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(RFC 5280 §4.2.1.13)的 fullName URI,下载的 CRL 进入第二个独立的 X509_STORE 做全链 CRL 检查,于是缺失或过期的 CRL 只改 RevocationStatus,绝不碰 TrustStatus

// 摘自 VerifyCmsWithSsl(FPdfCryptoSsl.pas);BIO 搭建部分省略
// 每次 _CMS_verify 调用都拿到全新的内容 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 加上抓来的
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

一次验证调用最多花多少?

一个固定上限,由单次验证调用的 AIA 和 CRL 步骤共享的一个 TPdfCryptoFetchSession 强制执行。限额是 FPdfCryptoHttp 里的常量,不是建议值:

  • 时间:UrlRetrievalTimeoutMs,在 TPdfCmsVerifyOptions.Default 和 TPadesTrustValidationOptions.Default 里都默认 15000;用 0 创建的会话回退到 30000,时钟在签名通过后启动,覆盖其后每个请求
  • 请求:每会话最多 8 个,在尝试传输之前就计数,所以死掉的主机照样占掉一个名额
  • 字节:每响应 1 MiB、总计 4 MiB,超过 2048 字符的 URL 在任何连接建立前就拒收
PDFium Component 中一次验证调用的 AIA 与 CRL 步骤共享的 TPdfCryptoFetchSession 硬上限:UrlRetrievalTimeoutMs 默认 15000 ms、零值回退 30000,每会话最多 8 个请求,每响应 1 MiB、总计 4 MiB 且失败响应照样计数,超过 2048 字符的 URL 拒收
限额是常量不是建议:失败响应的字节照样消耗预算,而每条签名和每个时间戳都单独验证,最坏情况随签名数量增长

记账比看上去更严。失败响应收到的字节照样计入总量,所以用大页面对 404 的服务器占不到预算的便宜。越过单响应上限的那次读取会中止下载,而不是把截断的 body 递给 ASN.1 解析器;HTTP 200 配空 body 直接拒收,否则 AIA 路径会去索引空数组的 Data[0]。只有朴素的 http:// 和 https:// URL 放行,没有重定向、cookie、凭据或自动代理发现,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 必须提供什么

异步解析器和线程安全构建,二者缺一则联网抓取保持关闭。FPC Unix 上传输走 libcurl——面向非 Windows 目标的 libcurl 时间戳后端背后是同一个依赖——绑定层拒绝任何 feature 掩码缺 CURL_VERSION_ASYNCHDNS 或 CURL_VERSION_THREADSAFE 的库。原因是:寄居在别人进程里的库必须设置 CURLOPT_NOSIGNAL,而它配上同步解析器意味着一次 DNS 查询可以轻松活过超时。第二个陷阱是关闭流程:curl_global_cleanup 不等异步 DNS 线程,所以 libcurl 一旦初始化,模块就保持映射直到进程退出,免得后台线程跑进已卸载的代码。任一条件不满足时,SslCapabilities.OnlineRetrieval 为 False,SslVerifyOptionsDiagnostics 报告 psvdOnlineRetrievalIgnored,而不是假装查过网络

这个结论保证什么、不保证什么

这个后端给出的 valid RevocationStatus,含义是:找到了、配置了或下载到了覆盖整条链的新鲜 CRL,而且没有一张把链内证书列了进去;仅此而已。没有 OCSP,所以只通过 OCSP 发布吊销信息的 CA 会让结果停在 unsupported,而一次网络故障看起来和什么都没发布的 CA 一模一样。另外注意 psvdNoCrlsConfigured 只描述你配置的 CRL,所以在联网抓取下它只是个提示,不是失败预告。审计追踪必须在无网络条件下可复现时,把 NetworkPolicy 留在 ptnpOffline 默认值:不创建抓取会话、后端永不打开连接,与 Windows 上的离线 PDF 签名吊销检查一文中 CryptoAPI 一侧的离线契约一致

抓取代码、预算和传输绑定以源码形式随PDFium Delphi component 交付,所以在处理不受信文档的服务器上启用 ptnpOnline 之前,你可以逐条确认一次验证可能联系哪些 URL、最多下载多少