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 证明不了任何事。三个结论全程各自独立:签名有效但链不完整,仍然按有效签名上报
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 在任何连接建立前就拒收
记账比看上去更严。失败响应收到的字节照样计入总量,所以用大页面对 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 回调释放另一个,谁最后收尾谁来释放内存
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、最多下载多少