技术文章

在 Delphi 中用 macOS 钥匙串身份签署 PAdES

PDFium VCL 用一把存放在 macOS 钥匙串里的私钥签署 PAdES 文档,做法是通过一个在运行时用 dlopendlsym 解析每个 Security 与 CoreFoundation 符号的后端。没有任何东西是链接期绑定的,这意味着拼错的符号名表现为 KeychainAvailable 返回 False、KeychainMissingSymbols 点名罪魁,而不是一个链接错误或一次崩溃

这个选择是被一个令人不快的约束逼出来的,而它的处理方式可以推广。这个单元是在一台没有 macOS SDK 的机器上写的,所以每个框架符号名、每个常量都来自文档,没有任何一个能对着头文件核对。对这种境况,错误的回应是小心地写代码然后祈祷;正确的回应是让那些不可避免的错误,以最可定位的形式自己跳出来

为什么即使在目标平台上,动态绑定也是正确选择

因为它把一类「让程序停摆」的失败,变成了一类「自己报告自己」的失败。静态链接的框架引用错了,在目标平台链接期失败,在别处永远链接不上。动态绑定的引用错了,产出的只是一个不可用的后端加一份未解析名字的清单,在 Mac 上的第一次运行会把「为什么它不可用」这个问题变成点名一个笔误的一行输出

还有第二个好处,是天天兑现而不是一次性的。因为这个单元不链接任何框架,它在所有平台都能编译,日常的 Windows 构建于是持续检查它的语法、类型和 uses 子句。一个只能在团队里没人拥有的平台上编译的单元,就是一个没有编译器看管的单元,它会在共享类型的每一次重构中悄悄腐化

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // 安装为 PAdES 签名后端
  ConfigureKeychainCmsVerifier;      // 同时安装为验证后端

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1,大小写均可
  Options.PaddingScheme := psRsaPss;
end;

两类导出符号,两种读法

这是整个绑定里最容易混淆的一个细节,弄反了照样编译干净、然后在运行时失败。CoreFoundation 和 Security 经由同一个 dlsym 调用导出两种截然不同的东西,代码必须分清哪个是哪个

具名常量——钥匙串条目类键、CoreFoundation 的布尔单例之类——导出的是变量,变量的内容才是你要的 CFStringRef 或 CFBooleanRef。dlsym 返回那个变量的地址,所以必须解引用一次才能拿到值。回调表结构——字典的键回调、值回调之类——导出的是结构dlsym 返回结构的地址,而这恰好就是字典创建函数期待的指针。把这个解引用,你就是把结构的第一个机器字当成指针递了过去

两种错误都不产生编译错误,也都不产生清晰的运行时错误。你得到的是一个垃圾指针,在下游某个地方失效。让这个区分不可能弄错的办法,是不再依赖记性:写两个辅助函数,一个绑定并解引用,一个绑定但不解引用,调用点声明自己在要哪种符号,其余由辅助函数强制

PDFium VCL macOS 钥匙串后端经 dlsym 解析 Security 与 CoreFoundation 符号的示意图:kSecClass 是导出变量,BindConstant 解引用一次取得 CFStringRef 值;kCFTypeDictionaryKeyCallBacks 是导出结构,BindStruct 按地址传递;两条规则弄混会在下游产出垃圾指针
同一个 dlsym 调用返回两种截然不同的东西:一个持有 CFTypeRef 的变量的地址,和一个回调结构的地址。两个辅助函数把「解引用与否」的决定放在绑定点,而不是留给内存
// 导出变量:dlsym 给出的是一个持有 CFTypeRef 的变量的
// 地址,所以解引用一次
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// 导出结构:dlsym 给出的是结构本身的地址,这正是 API
// 想要的。不要解引用
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

为什么 RSA-PSS 签名需要两个独立的回退?

因为这个算法有两种互不相关的缺席方式,其中只有一种是版本问题。PSS 的摘要签名算法常量在 macOS 10.13 才出现,所以在更老的系统上那个符号就是不存在,绑定拿到 nil。这是版本检查。另一条路上,在常量存在的系统上,某把特定的钥匙仍然可能拒绝它,框架通过 SecKeyIsAlgorithmSupported 针对那把钥匙回答这个问题。一把硬件背书的钥匙或属性受限的钥匙可以拒绝 PSS,而同一台机器上的软件钥匙接受它

两条路都必须通往同一个回退:切换到 PKCS#1 v1.5。而关键在于,回退不仅要改签名调用,还必须改写入 CMS 结构的算法标识符。声明着 PSS 算法标识符、实际却产出 v1.5 签名的文档,任何验证器都会当场拒绝——这严格地糟于报告 PSS 不受支持。降级可以接受,声明与所做之间的不一致不可以,这是一条适用于一切签名代码的通用规则,不是 macOS 的怪癖。签名层面的推论在 用 PAdES B-B 签署 PDF中铺开

决策链:PDFium VCL 钥匙串后端的 RSA-PSS 签名为什么需要两个独立回退——macOS 10.13 之前 dlsym 对摘要签名常量返回 nil,SecKeyIsAlgorithmSupported 可能拒绝一把硬件背书的钥匙,两道闸门汇入同一个 PKCS#1 v1.5 降级,且 CMS 算法标识符必须随之改变
PSS 可以缺席两次:一次按 macOS 版本,一次按钥匙,只有版本那道闸是系统问题。两道闸汇入同一个 v1.5 降级,CMS 标识符跟着走

ECDSA 签名编码,以及一个值得注意的反转

椭圆曲线路径在 macOS 上完全不需要转换,而这与 PKCS#11 绑定的要求正好相反。Security 框架的 ECDSA 摘要签名算法返回的签名已经是 X9.62 DER 形式,恰好是 CMS 想要的。PKCS#11 令牌返回的却是原始定宽的 P1363 数对,必须在进入签名结构之前重新编码

于是,实现同一个接口的两个后端,对同一个算法需要相反的待遇,而且谁都没错。抽象需要吸收而不是暴露的,恰恰就是这类差异:PAdES 层向提供程序要一个签名,编码约定留在提供程序内部。一旦泄漏上去,每个调用方都得随身携带一份按后端分支的条件代码。同样的形状也出现在 对 HSM 的远程 PAdES 签名会话描述的远程签名故事里

PDFium VCL PAdES 签名器两个后端的 ECDSA 签名编码对比:macOS 钥匙串 Security 框架返回 CMS 零转换即可接受的 X9.62 DER,PKCS#11 令牌返回必须重新编码的原始定宽 P1363 数对,因此 ResolvePadesSigner 把编码约定留在提供程序内部
同一个 ECDSA 接口按后端需要相反的待遇:Security 递来的是成品 DER,PKCS#11 令牌递来的是原始 P1363,所以转换活在提供程序内部,调用方永远见不到按后端分支的条件代码
// 提供程序接口在每个平台上都一样,所以选择是启动期
// 决策,不是每次调用各选一次
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // Windows CNG 提供程序由平台单元安装
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// 从这里开始,签名代码与平台无关
Signer := ResolvePadesSigner(Options);

相隔三行的两条引用计数规则

Core Foundation 的内存管理跟着命名约定走,而这里的陷阱是:约定不同的函数在同一小段代码里比邻而居。从一个 trust 对象上get(取得)证书的函数返回一个不得释放的借用引用;copy(拷贝)签名者证书或其数据的函数返回必须释放的持有引用。三连调用、两条所有权规则,而且释放那个借用引用不会在那一行失败——它破坏的是一个 retain 计数,稍后拖垮的是不相干的东西

缓解办法是:每次、毫无例外地在写清理代码之前,把每个框架函数名里的动词读一遍。这相当于 CoreFoundation 版的「这个 API 返回的是拷贝还是视图」,弄错的代价是间歇性崩溃而不是报错

这个后端不声称什么

截至写作时,它从未在 macOS 上运行过,直说这一点比一句含蓄的保证更有用。可证明为真的东西更窄、但仍然有价值:这个单元作为日常构建的一部分在 Windows 上编译;每个框架符号都在运行时按名绑定,失败会被逐条枚举;算法选择逻辑——包括两个 PSS 回退——是普通的 Pascal,可以评审、可以推理。在 Mac 上的第一次运行,要么直接工作,要么给出一份待修的名字清单

验证一侧的对应实现——它用的是更高层的 CMS 解码器,而不是手工拼装 CMS 结构——在 用 SecTrust 在 macOS 上验证 PDF 签名中有覆盖,共享同一套绑定基础设施和同一套诊断思路

这里可迁移的思想关于风险的安放,而不是 macOS。当你必须对着一个无法验证的接口写代码时,选择那种错误最容易定位的构造。带显式未解析名字清单的动态绑定,把二十个无法验证的假设变成一行诊断输出。两个后端都以源码形式随 PDFium Delphi component 提供,所以如果某个符号名真的需要修正,那是你自己代码树里的一行改动,不是一张工单