技术文章

Delphi 在 macOS 使用 SecTrust 验证 PDF 签名

PDFium Delphi Component 在 macOS 上通过 TPdfKeychainCmsVerifier 验证 PDF 签名,这是一个建立在 Apple CMSDecoder 和 SecTrust 之上的 CMS 验证后端,而不是手工解析 CMS。ConfigureKeychainCmsVerifier 安装它,单次 CMSDecoderCopySignerStatus 调用就会返回签名结论、SecTrust 句柄和证书结果代码,正好对应 TPdfCmsVerifyResult 在 Windows 上已经携带的两列

促成这项工作的场景很普通。文档归档的 Lazarus 构建在 Mac 上运行,打开一份已签名合同,所有签名都返回 pcsUnsupported。文件没有问题。签名验证在 Windows 之外根本没有后端,而 PAdES 验证器在缺少后端时拒绝猜测。PDFiumPas 3.111.0 通过 IPdfCmsVerifierConfigurePadesCmsVerifier 打开了接缝;3.113.0 又在 macOS 上填补了它。移植中真正有意思的不是连接代码,而是 Apple API 与 Windows API 形状不同的三个地方

PDF 签名为什么覆盖两个字节范围

因为签名不能覆盖保存签名本身的字节。ISO 32000-1 第 12.8.1 节将 CMS SignedData 数据块放入签名字典的 /Contents 字符串,并通过 /ByteRange 描述签名覆盖范围;它是一组偏移和长度对,覆盖这个空洞两侧的所有内容。无论在哪个平台,都是两个片段,中间有一个间隙

这些片段如何进入加密层,平台之间并不一致,而这种差异会消耗内存。在 Windows 上,CryptVerifyDetachedMessageSignature 接受指针和长度数组,因此两个范围可以按它们在缓冲区中的原样传入,不需要复制。Apple 的 CMSDecoderSetDetachedContent 只接受一个 CFData,没有多段形式,因此 macOS 后端必须先把两个范围拼接到连续缓冲区中再解码。这相当于完整复制第二份签名字节。在 400 MB 的扫描归档上,这是真实的内存峰值,它随文档而不是随签名大小增长,也没有其他 API 可用。应当据此规划批处理工作进程,而不是在客户机器上才发现

一次调用填充 TPdfCmsVerifyResult 的两列

CMSDecoderCopySignerStatus 对 Security.framework 入口点来说异常慷慨:一次调用返回签名者状态、它构建的链对应的 SecTrustRef,以及证书评估的 OSStatus。这些值会直接进入 PAdES 验证器已经消费的记录,签名者状态成为 SignatureStatus,证书结果成为 TrustStatus,原始值还保存在 SignatureErrorTrustError 中,这样支持工单可以引用数字而不是形容词。调用方永远不会直接接触 IPdfCmsVerifier——ValidatePadesComplianceValidatePadesTrust 会将每次验证路由到已安装的后端,因此读取 TPadesSignatureValidation 的代码在两个平台上逐字节相同,正如在 Delphi 中检查 PDF 数字签名字典和 PAdES 等级的说明所述

uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // 签名和验证解析不同的框架符号,因此一方可能存在
  // 而另一方不存在
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName 现在返回 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

kCMSSignerInvalidCert 为什么会报告有效签名

因为 Apple 赋予这个值的含义比名称暗示的范围更窄:签名本身已经验证通过,只有证书链无法建立。TPdfKeychainCmsVerifier 因此会将 kCMSSignerInvalidCert 映射为 SignatureStatus 列中的 pcvsValid,让证书问题通过 TrustStatus 暴露,因为链问题就应当属于那里。将它折叠进签名结论,会让组件告诉操作员一个完整性未受破坏的文档已被修改,这是签名验证器能发出的最糟糕的误报

function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // 签名已验证通过,只有链没有通过,这由信任状态
  // 单独报告
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

把两个状态作为有序对读取,报告逻辑就会自行展开。SignatureStatus = pcvsValidTrustStatus = pcvsInvalid 一起表示:文档字节完好,但当前 Mac 不信任其签发者,可能是 Keychain 中缺少信任锚、过期的中间证书,或离线时无法完成的链。这是操作员策略问题,而不是文档完整性问题;这个区分正是为什么验证器会拒绝密码学上正确的 PAdES 签名中大多数案例背后的原因

macOS 在哪里实际检查吊销

检查发生在信任评估内部,这也是 TPdfCmsVerifyResult.RevocationStatus 跟在 TrustStatus 之后,而不是拥有独立结论的原因。SecPolicyCreateRevocation 生成一个策略,该策略与 SecPolicyCreateBasicX509 一起放进传给 CMSDecoderCopySignerStatus 的数组,OCSP 或 CRL 工作发生在构建证书链的过程中。API 不会返回独立答案,因此报告一个独立结果就意味着凭空捏造。数组本身还有一条值得命名的所有权规则:CFArrayCreate 会保留两个策略,因此两个局部引用可以立即释放;只有一个策略时则完全跳过数组,直接传入策略,API 也接受这种形式

离线操作是显式标志,而不是网络连接恰好断开后的偶然结果。当 TPdfCmsVerifyOptions.OnlineRetrieval 为 False 时,后端会加入 kSecRevocationNetworkAccessDisabled,将评估限制在机器上已经缓存的响应;检查点回调仍按 Windows 后端报告的相同顺序触发 pcvstCryptographicSignaturepcvstChainBuildpcvstRevocationCheck。应用代码通过更高层的选项记录设置这一切

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // 仅使用缓存响应
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get 与 copy:在别处才失败的释放

SecTrustGetCertificateAtIndex 采用 get 语义,返回的引用绝不能释放;同一例程附近的 CMSDecoderCopySignerCertSecCertificateCopyData 采用 copy 语义,必须释放。Core Foundation 将整个规则编码在函数名的一个动词中,而类型系统完全不会强制它。释放借用的引用时,调用点不会立刻出问题:信任对象只会变得不可靠,崩溃会在稍后、某个看不出与证书链有关的位置到达

ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Get 语义:这个引用是借用的,不在这里释放
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Copy 语义:这个引用由我们拥有,必须释放
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

没有后端响应时,验证器保证什么

它保证答案是 unsupported,而不是静默通过。如果 ConfigurePadesCmsVerifier 没有安装任何内容,平台默认后端也无法帮助,TPdfCmsVerifyResult 的每一列都会是不可用,PAdES 验证器会把它映射为 pcsUnsupported;因此没有加密后端的构建会诚实报告,而不会声称签名有什么结果。macOS 绑定也同样保守:Security.framework 和 CoreFoundation 通过 dlopendlsym 访问,因此缺少框架或绑定写错符号名,会表现为 KeychainVerificationAvailable 返回 False,并由 KeychainMissingSymbols 指出问题符号,而不是链接失败,也不是错误结论。这与组件寻找原生库时采用的失败关闭姿态相同,详见在任意目标平台加载 PDFium 原生库的文章

PDF 栈中,签名验证属于静默错误比明确不可用更糟的部分,而 macOS 给了你一个足够强大的 API,让两种结果都很容易达到。拼接字节范围并接受这次复制,将签名结论和证书链结论保存在不同列中,尊重 get 与 copy 这两个动词,让缺失后端明确说出自己缺失。如果你要把 Delphi 或 Free Pascal 文档流程迁移到 Mac,并需要两端都支持 PAdES 签名和验证,PDFium Delphi Component 会在统一接口后同时提供 Keychain 后端和 Windows 后端