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_SMALL 或 CKR_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,而不是直接別名到 LongWord 或 UInt64,正是練習的目的。這樣條件分支只出現一次。把其中任何一個具體寫死,就會埋下一個未來移植時才會踩到的地雷,而且會在你忘記的那個位置爆炸
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;
這四個數字並非隨意。attr 是 CK_ATTRIBUTE 的大小,它包含 CK_ULONG、指標和 CK_ULONG:Windows x64 打包時為 4 + 8 + 4,LP64 對齊時為 8 + 8 + 8。pss 是 CK_RSA_PKCS_PSS_PARAMS 的大小,包含三個 CK_ULONG 欄位,因此是 12 或 24。table 是第一個函式指標的偏移,最先捕獲打包錯誤。Delphi 測試案例在 {$IFDEF MSWINDOWS} 下斷言字串,Lazarus 套件也斷言相同內容。一次相等性檢查就涵蓋了一種配置,否則只能把 C 標頭檔與 Pascal 記錄並排閱讀,然後相信自己。載入時檢查是同一思想的第二半。PDFiumPas 只按名稱解析 C_GetFunctionList,透過 GetProcAddress 或 GetProcedureAddress 取得它,再從該呼叫回傳的表中取得其他所有入口點;這是 OASIS PKCS #11 基礎規範希望模組被存取的方式,也避開了各供應商的符號命名。然後它會檢查回傳內容是否合理。主版本低於 2 或高於 3,或 C_Initialize、C_GetSlotList、C_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_PSS 和 CKM_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 連接已完成條件化處理,讓你的程式碼可以停留在權杖端的問題上