PDFium VCL 用一把存放在 macOS 钥匙串里的私钥签署 PAdES 文档,做法是通过一个在运行时用 dlopen 和 dlsym 解析每个 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 返回结构的地址,而这恰好就是字典创建函数期待的指针。把这个解引用,你就是把结构的第一个机器字当成指针递了过去
两种错误都不产生编译错误,也都不产生清晰的运行时错误。你得到的是一个垃圾指针,在下游某个地方失效。让这个区分不可能弄错的办法,是不再依赖记性:写两个辅助函数,一个绑定并解引用,一个绑定但不解引用,调用点声明自己在要哪种符号,其余由辅助函数强制
// 导出变量: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中铺开
ECDSA 签名编码,以及一个值得注意的反转
椭圆曲线路径在 macOS 上完全不需要转换,而这与 PKCS#11 绑定的要求正好相反。Security 框架的 ECDSA 摘要签名算法返回的签名已经是 X9.62 DER 形式,恰好是 CMS 想要的。PKCS#11 令牌返回的却是原始定宽的 P1363 数对,必须在进入签名结构之前重新编码
于是,实现同一个接口的两个后端,对同一个算法需要相反的待遇,而且谁都没错。抽象需要吸收而不是暴露的,恰恰就是这类差异:PAdES 层向提供程序要一个签名,编码约定留在提供程序内部。一旦泄漏上去,每个调用方都得随身携带一份按后端分支的条件代码。同样的形状也出现在 对 HSM 的远程 PAdES 签名会话描述的远程签名故事里
// 提供程序接口在每个平台上都一样,所以选择是启动期
// 决策,不是每次调用各选一次
{$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 提供,所以如果某个符号名真的需要修正,那是你自己代码树里的一行改动,不是一张工单