技术文章

Delphi PKCS#11:CK_ULONG 与打包陷阱

PDFiumPas 在 Windows、Linux 和 macOS 上通过 PKCS#11 令牌签署 PAdES 文档,而两个平台事实决定这套绑定能否工作:CK_ULONG 是 C 的 unsigned long,因此 Windows 上是 4 字节,Linux 和 macOS 上是 8 字节;PKCS#11 头文件只在 Windows 上应用 #pragma pack(1),这会移动函数表中的每个指针。任何一个弄错,模块仍然会加载,调用仍然会返回,但返回的数字都是垃圾。这正是你应该预期的 bug 形态。没有链接器错误,因为这里没有链接:模块是运行时按路径打开的 .so.dylib.dll,整个接口面只是一个由你转换并调用的函数指针结构体。编译器不知道另一端的 C 头文件长什么样。每个不匹配都要到崩溃前才会静默暴露

PKCS#11 绑定为什么会返回随机 CKR 代码,而不是干净错误

因为 ABI 不匹配根本不会产生错误条件,它只会产生错误地址或错误偏移,而令牌会尽职地回答由此实际得到的问题。你的记录声明和模块之间没有任何层能够发现分歧。这里会出现两种不同的失败模式。如果打包错误,你读取为 C_GetSlotList 的槽位中有一个指针的六个字节和下一个指针的两个字节,调用它会跳进未映射内存,或者更糟,跳到另一个函数的中间。这就是访问冲突。如果 CK_ULONG 宽度错误,地址是正确的,但数据不是:在 LP64 模块写入 8 字节时,声明为 4 字节的 var Count: CK_ULONG 输出参数会悄悄覆盖栈帧的后四个字节;而 CK_ATTRIBUTE 模板中的 ValueLen 位于错误偏移时,模块会从你的 Value 指针中读取长度字段。于是令牌会针对一个你从未提出的问题返回完全合法的 CKR_BUFFER_TOO_SMALLCKR_ATTRIBUTE_VALUE_INVALID。这些代码会让人花上数小时去检查令牌配置,而 bug 实际上在类型声明上方四行

CK_ULONG 是 C 的 unsigned long,而不是固定宽度类型

PKCS#11 头文件将 CK_ULONG 定义为 C 的 unsigned long,因此其宽度跟随平台数据模型,而不是规范固定值。Windows 使用 LLP64,所以即使在 64 位进程中,unsigned long 仍保持 32 位。Linux 和 macOS 使用 LP64,因此它跟随指针并变为 64 位。这是整个单元最关键的一行,因为在 PKCS#11 中几乎所有标量都是 CK_ULONG:槽 ID、会话句柄、对象句柄、对象类、密钥类型、属性类型、机制类型、缓冲区长度,以及 CK_RV 返回值本身

type
{$IFDEF MSWINDOWS}
  // Windows 是 LLP64:C 的 unsigned long 在这里保持 32 位
  CK_ULONG = LongWord;
{$ELSE}
  // Linux 和 macOS 是 LP64:unsigned long 跟随指针宽度
  CK_ULONG = PtrUInt;
{$ENDIF}
  CK_RV = CK_ULONG;
  CK_FLAGS = CK_ULONG;
  CK_SLOT_ID = CK_ULONG;
  CK_SESSION_HANDLE = CK_ULONG;
  CK_OBJECT_HANDLE = CK_ULONG;
  CK_OBJECT_CLASS = CK_ULONG;
  CK_ATTRIBUTE_TYPE = CK_ULONG;
  CK_MECHANISM_TYPE = CK_ULONG;
  PCK_ULONG = ^CK_ULONG;

把所有这些类型都别名到 CK_ULONG,而不是直接别名到 LongWordUInt64,正是练习的目的。这样条件分支只出现一次。把其中任何一个具体写死,就会埋下一个未来移植时才会踩到的地雷,而且会在你忘记的那个位置爆炸

pragma pack(1) 会怎样改变 PKCS#11 函数表

它会移动 CK_FUNCTION_LIST 中的每个函数指针,因为表以两字节的 CK_VERSION 开头。自然对齐时,编译器会在版本之后插入六个填充字节,因此第一个函数指针位于偏移 8。字节打包时没有填充,因此它位于偏移 2。之后的每个条目都继承同一个位移,所以打包错误不是一个字段的问题,而是整张表的问题。陷阱在于,PKCS#11 头文件只在 Windows 上应用 #pragma pack(1)。这是平台差异而不是模块差异:同一家供应商库的两个构建,会根据来源主机不同而在这里不一致。还要注意,打包不会改变字段全为指针宽度的结构体,而大多数结构体正是如此,因此只接触 CK_SLOT_INFO 的浅层测试会顺利通过,而下面的表仍然整体偏移六字节

{$IFDEF FPC}
  {$IFDEF MSWINDOWS}{$PACKRECORDS 1}{$ELSE}{$PACKRECORDS C}{$ENDIF}
{$ELSE}
  {$A1}
{$ENDIF}

  CK_VERSION = record
    Major: Byte;
    Minor: Byte;
  end;

  CK_ATTRIBUTE = record
    AttrType: CK_ATTRIBUTE_TYPE;
    Value: Pointer;
    ValueLen: CK_ULONG;
  end;

  CK_FUNCTION_LIST = record
    Version: CK_VERSION;      // 两个字节,也是表发生移动的原因
    C_Initialize: Pointer;    // 打包偏移 2,对齐偏移 8
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... 表的顺序固定;声明到 C_Sign 的前缀已足以
    // 访问后端调用的全部内容
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}

代码块中的三件事比看上去更重要。{$PACKRECORDS C} 不等于“没有指令”;它告诉 Free Pascal 遵循平台 C 编译器的对齐规则,这正是 Linux 和 macOS 所需的契约。Delphi 分支无条件使用 {$A1},因为 PDFiumPas 的 Delphi 构建目标是 Windows,而 FPC 承担 Linux 和 macOS 构建。底部的恢复行也不是装饰:让单元保持打包状态,会使此后声明的每个记录静默改变布局,这正是加固 PDFium 组件绑定以抵御 ABI 和内存安全故障要消除的那种跨距离影响缺陷

Pkcs11AbiLayout:把布局变成断言

Pkcs11AbiLayout 将构建实际解析出的布局报告为一个可断言的字符串,例如 ulong=4 attr=16 pss=12 table=2。64 位 Windows 构建必须精确报告这一结果,LP64 目标必须报告 ulong=8 attr=24 pss=24 table=8。任何其他结果都意味着通过函数表进行的调用会落到错误槽位,而这个函数存在的意义,就是让单元测试可以明确说出问题,而不是依靠声称它正确的注释

function Pkcs11AbiLayout: string;
var
  Table: CK_FUNCTION_LIST;
begin
  Result := 'ulong=' + IntToStr(SizeOf(CK_ULONG)) +
    ' attr=' + IntToStr(SizeOf(CK_ATTRIBUTE)) +
    ' pss=' + IntToStr(SizeOf(CK_RSA_PKCS_PSS_PARAMS)) +
    ' table=' + IntToStr(NativeUInt(@Table.C_Initialize) - NativeUInt(@Table));
end;

// 加载时,在 C_GetFunctionList 返回表之后:
// 不合理的版本或 nil 入口点意味着记录的打包或 CK_ULONG 宽度错误,
// 因此拒绝模块
if (FList^.Version.Major < 2) or (FList^.Version.Major > 3) or
  not Assigned(FList^.C_Initialize) or not Assigned(FList^.C_GetSlotList) or
  not Assigned(FList^.C_Sign) then
begin
  FList := nil;
  Exit;
end;

这四个数字并非随意。attrCK_ATTRIBUTE 的大小,它包含 CK_ULONG、指针和 CK_ULONG:Windows x64 打包时为 4 + 8 + 4,LP64 对齐时为 8 + 8 + 8。pssCK_RSA_PKCS_PSS_PARAMS 的大小,包含三个 CK_ULONG 字段,因此是 12 或 24。table 是第一个函数指针的偏移,最先捕获打包错误。Delphi 测试用例在 {$IFDEF MSWINDOWS} 下断言字符串,Lazarus 套件也断言同样内容。一次相等性检查就覆盖了一种布局,否则只能把 C 头文件与 Pascal 记录并排阅读,然后相信自己。加载时检查是同一思想的第二半。PDFiumPas 只按名称解析 C_GetFunctionList,通过 GetProcAddressGetProcedureAddress 获取它,再从该调用返回的表中取得其他所有入口点;这是 OASIS PKCS #11 基础规范希望模块被访问的方式,也避开了各供应商的符号命名。然后它会检查返回内容是否合理。主版本低于 2 或高于 3,或 C_InitializeC_GetSlotListC_Sign 任一为 nil,都表示记录错位;模块会被丢弃,而不会通过它调用

通过函数表签名:机制、DigestInfo 与两阶段 C_Sign

布局正确后,签名工作反而很小,因为 PDFiumPas 要求后端满足的 ICmsSigner 契约只有五个方法,其中四个只返回 OID 和签名者标识。只有 SignSignedAttrsDigest 真正做事:它接收签名属性的 32 字节 SHA-256 摘要,并返回签名字节。CMS 组装、ASN.1、RFC 3161 时间戳以及 DSS/LTV 都是平台无关的,已经完成;这正是连接 HSM 或云密钥服务的远程 PAdES 签名会话可以接入同一接缝的原因。忽略三个机制细节,会让验证失败。CKM_RSA_PKCS 会应用 PKCS#1 v1.5 填充,却不会构造 DigestInfo,因此调用方必须自行添加 RFC 8017 中的 19 字节 SHA-256 DigestInfo 前缀;把裸摘要交给令牌,你得到的会是一份形式正确、却签署了错误内容的签名。CKM_RSA_PKCS_PSSCKM_ECDSA 接收原样的摘要,但 CKM_ECDSA 返回原始的 r||s 对,而 CMS 需要 RFC 3279 第 2.2.3 节的 ECDSA-Sig-Value SEQUENCE,因此 PDFiumPas 会完成转换。C_Sign 则按设计分两阶段:先用 nil 缓冲区调用它,询问令牌签名长度,再用该大小的缓冲区调用一次

var
  Options: TPdfPkcs11Options;
  Provider: IPdfPkcs11SignerProvider;
  Slot: TPdfPkcs11Slot;
begin
  Options := TPdfPkcs11Options.Default;
  Options.ModulePath := '/usr/lib/softhsm/libsofthsm2.so';
  Options.Pin := ReadOperatorPin;
  Options.CertificateLabel := 'Signing Certificate';

  if not Pkcs11ModuleAvailable(Options.ModulePath) then
    raise Exception.Create('No usable PKCS#11 module at ' + Options.ModulePath);
  // 在新平台上令牌行为异常时,首先记录这个布局
  Writeln('PKCS#11 ABI layout: ' + Pkcs11AbiLayout);

  Provider := ConfigurePkcs11SignerProvider(Options);
  for Slot in Provider.EnumerateSlots do
    if Slot.TokenPresent then
      Writeln(Slot.SlotID, ' ', Slot.TokenLabel);
end;

第一次接触令牌前,还有几件小事值得知道。模块按路径缓存,因为每个进程每个模块只调用一次 C_Initialize;重复调用会返回 CKR_CRYPTOKI_ALREADY_INITIALIZED(0x00000190),PDFiumPas 假定宿主其他部分已经初始化了同一个库,因此将它视为成功。槽描述和令牌标签等令牌字符串是尾部填充的固定宽度字段,而不是以 NUL 终止,因此必须从尾部裁剪。还有,CKO_CERTIFICATE 是 1,不是 2——0 是 CKO_DATA,2 是 CKO_PUBLIC_KEY。凭记忆写错这个常量,会得到空搜索结果,却完全没有错误

验证了什么,保证在哪里停止

要明确边界,因为它比功能描述暗示的范围更窄。PDFiumPas 当前验证的是:两条分支上的 ABI 布局都逐字段匹配 C 头文件;缺失或无法加载的模块会退化为报告失败而不是崩溃;Delphi 和 FPC 工具链都能构建该单元。真正的令牌路径——C_Login、对象搜索以及针对硬件的 C_Sign——尚未执行,因为开发主机根本没有安装 PKCS#11 模块。接入物理令牌前,应先启动 SoftHSM2 并确认 Pkcs11AbiLayout,这样 ABI 问题和令牌问题就不会同时出现。还有一个不对称性也值得点名。签名侧现在是跨平台的,验证侧却不是。PDFiumPas 内部的 CMS 验证仍由 {$IFDEF MSWINDOWS} 保护,在其他平台返回 pcsUnsupported,也没有等价于签名后端的提供者注入点。因此 Linux 服务可以使用令牌持有的密钥生成 PAdES B-B 签名,却还不能在同一台机器上检查自己的输出。在这个缺口填补之前,应把验证步骤安排到 Windows 或外部验证器上

这个教训不只适用于 PKCS#11。任何映射条件打包 C 结构体的 Pascal 记录都需要三件东西:为平台可变标量提供一个条件别名,让宽度判断只存在于一个位置;用打包指令包住声明并在之后恢复;再提供一个运行时函数,报告解析出的布局,让测试可以断言。声称结构体匹配头文件的注释没有价值,而启动时打印的 SizeOf 和字段偏移则很有价值。PKCS#11 后端、CNG 后端以及其余签名栈都包含在 PDFium Component for Delphi and C++Builder中,其中 ABI 连接已经完成条件化处理,让你的代码可以停留在令牌侧的问题上