技术文章

在 PDFium VCL 中用 OpenSSL 验证 PDF 签名

PDFium VCL 把 CMS 验证当作 IPdfCmsVerifier 接口之后的一个可替换后端,于是 PAdES 校验器可以在 Windows 上经 CryptoAPI 运行、在 macOS 上经钥匙串运行、在有 OpenSSL 的任何地方经 ConfigureSslCmsVerifier 运行。接口很小。而它下面的三个 OpenSSL 行为,会让天真的实现产出自信而错误的答案

一旦 Delphi 应用离开 Windows,动机就很直白了。签名验证是少数几个平台密码栈不是实现细节的领域之一:它决定哪些证书被信任、哪些算法存在、吊销意味着什么。把它写死,代码无法移植;把它抽象得很糟,每个平台报告形状各异、调用方无法互相比较的答案

这个抽象真正要承载什么

两种验证形态,三个相互独立的判定。PDF 签名是分离式(detached)的:被签名的内容是 /Contents 洞两侧的两个字节区间,所以 VerifyDetached 接收两个段而不是一个缓冲。时间戳令牌是附接式(attached)的,自带内容,所以 VerifyAttached 只接收 DER

结果拆成三个状态,因为它们回答三个不同的问题,而且可以互相矛盾。SignatureStatus 说这些字节是否由签名者证书里的那把钥匙签的。TrustStatus 说那张证书是否链到你信任的某个根。RevocationStatus 说证书在相关时刻是否仍然有效。一份带着数学上完美签名、而证书你闻所未闻的文档,是有效、不受信任、吊销状态未知——把这三者坍缩成一个布尔,就是验证器开始对用户撒谎的方式

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER,允许为空
  ConfigureSslCrls(LoadFreshCrls);                // DER,允许为空
  ConfigureSslCmsVerifier;                        // 安装后端

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout 看起来像奇技淫巧,其实不是。每个 OpenSSL 错误码、每个存储标志都以 C unsigned long 的身份越过边界——在 Windows 上是四字节,在 Linux 和 macOS 上是八字节。把它声明成固定 32 位类型,代码在 Windows 上正常工作,然后在 LP64 上悄悄只读到半个值。把假定的宽度报告成一个可以在测试里断言的字符串,一整类平台 ABI 漂移就变成了一行检查。在 PKCS#11 绑定里跟 CK_ULONG 搏斗过的人会立刻认出这个问题;那段故事在 PKCS#11 结构打包与 CK_ULONG 宽度

为什么第二次验证看到的内容是空的?

因为 CMS_verify 会把分离内容 BIO 读到文件末尾,而一个被读过的 BIO 不会自动为你回卷。分两遍验证是合理的设计——第一遍只验密码学签名、抑制链评估,第二遍做完整评估——但如果两遍共用一个 BIO,它会以一种异乎寻常地具有欺骗性的方式失败

第二遍拿到零字节内容。在分离模式下这不是错误,因为空内容缓冲是合法输入。摘要就是不匹配,而失败以「链构建失败」而不是「内容失败」的面目浮现——于是你跑去检查证书和信任库,而真正的问题是一个流位置。每一遍都用 BIO_new_mem_buf 重建内存 BIO。代价是一次分配,换来这种可能性被整个移除

no-verify 标志压制什么,不压制什么

CMS_NO_SIGNER_CERT_VERIFY 压制的是链评估,不是签名者证书查找。OpenSSL 在内部先解析并附上签名者证书,然后才看这个标志,所以带这个标志跑完第一遍之后,签名者已经可用,它的算法标识符可以立刻读取。不需要为了拿一个签名者证书再跑一遍完整验证——虽然这个标志的名字就在引诱你这么想

随之而来的是一条所有权规则。签名者引用属于 CMS 结构,不得独立释放。它与结构同寿,提前释放产出的损坏,症状出现在完全无关的地方,通常是在清理某个不相干对象的时候

为什么一开 CRL 检查就拒绝所有签名?

因为 OpenSSL 只对照存储里已有的 CRL 做检查,自己什么都不抓取。它不跟随 CRL 分发点,也不说 OCSP。在一个没有任何 CRL 的存储上设置 X509_V_FLAG_CRL_CHECK,每条链都会以「无法取得证书 CRL」失败。这个结果看起来像吊销检查正常运转并发现了问题,实际上是吊销检查根本没有运行

因此后端只有在 ConfigureSslCrls 确实提供了至少一条 CRL 时才设置该标志。没有时,RevocationStatus 返回 pcvsUnsupported——这是对「这个问题没有被回答」的诚实陈述。出于同样的原因,OnlineRetrieval 对这个后端没有效果,也不会发出 pcvstOnlineRetrieval 检查点:根本没有抓取路径可以报告进度

PDFium VCL OpenSSL CMS 验证器的三个陷阱示意图:共用内容 BIO 被读到文件末尾让第二遍验证拿到零字节;CMS_NO_SIGNER_CERT_VERIFY 压制链评估但不压制签名者查找;在空存储上做 CRL 检查会拒绝每条链而吊销检查从未运行
每个陷阱产出的都是自信的错误判定:一个流位置伪装成信任失败,no-verify 标志压制的比名字暗示的少,从未运行的吊销检查看起来像发现了问题的吊销检查

这作为一个设计立场普遍值得捍卫。无法检查吊销的验证器就应该这么说。把一个未检查的证书报告为「未吊销」,是签名验证工具误导用户的最常见方式,也正是 验证器为什么拒绝 PAdES 签名探讨的那类混淆

// 检查点让 UI 显示当前跑到哪个阶段,也告诉你后端
// 实际执行了哪些阶段
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// 三个判定分开读;允许它们不一致
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

绑定一个你钉不住版本的库

OpenSSL 在 1.0 和 1.1 之间改了栈访问器的名字,所以同一个逻辑函数有两种可能的导出名,取决于宿主机碰巧装的是哪个构建。绑定先解析新名字,失败回落旧名字,只有两者都解析不到才记录缺失符号。对任何你不随产品分发的库做动态绑定,这都是正确形状:优先当前名字,容忍历史名字,只报告真正的缺席

SslMissingSymbols 把一次加载失败变成一个可诊断的事件。在一台明明装着 libcrypto 的主机上拿到非空结果,意味着安装版本比本构建面向的 API 更老——这与「库缺失」是完全不同的支持对话。ConfigureSslLibraryPath 覆盖另一个常见情况:一台主机上有多个 OpenSSL 构建,默认搜索路径上的那个不是你想要的

按平台选择后端

务实的安排是:启动时选定,并记录是哪个后端应答。在 Windows 上,平台后端与企业已经管理的证书存储集成,这通常正是你想要的。在 macOS 上,钥匙串后端符合同样的推理,见 在 macOS 上用 SecTrust 验证签名。OpenSSL 是可移植选项;当你需要一份跨平台完全一致的验证策略、而不是各自跟随每个平台信任库的策略时,它也是正确选择

PDFium VCL 的 IPdfCmsVerifier 抽象示意图:VerifyDetached 承载 Contents 洞两侧的两个字节区间,VerifyAttached 承载时间戳令牌;三个相互独立的判定 SignatureStatus、TrustStatus 与 RevocationStatus;以及启动时经 CryptoAPI、SecTrust 或 ConfigureSslCmsVerifier 选定的按平台后端
接口承载两种验证形态、三个判定,因为它们回答不同的问题且可能互相矛盾;安装的后端记录在每个判定旁边,存储的结果才能复现

无论安装哪个,都在你记录的每条判定旁边记下 PadesCmsVerificationBackendName。一份没有记录产生它的后端的验证结果,事后无法复现,因为三个状态值的含义会随应答的密码栈产生微妙差异。架在这一切之上的签名检查层——包括 PAdES 等级如何报告——在 检查 PDF 数字签名与 PAdES 等级里有覆盖

这一切都以源码形式随 PDFium Delphi component 提供,这一点在这里比平时更重要:对一个签名验证器来说,能读到后端确切设置了哪些标志、跳过了哪些检查,不是锦上添花——它是弄清你应用程序里那个绿色对勾到底声称了什么的唯一途径