PDFium VCL 用一把放在 macOS Keychain 裡的私鑰簽署 PAdES 文件,靠的是一個在執行階段以 dlopen 與 dlsym 解析每個 Security 與 CoreFoundation 符號的後端。什麼都不在連結期繫結,所以打錯的符號名稱會以 KeychainAvailable 回傳 False、KeychainMissingSymbols 指名禍首的形式現身,而不是一個連結器錯誤或一場當機
這個選擇是被一個不舒服的限制逼出來的,而處理它的方式可以推廣。這個 unit 寫在一台沒有 macOS SDK 的機器上,每個 framework 符號名稱與每個常數都來自文件,沒有一個能對著標頭檔核對。面對這種處境,錯的回應是小心翼翼地寫、然後祈禱;對的回應是安排那些無可避免的錯誤,讓它們用最容易定位的形式自己報到
為什麼即使在目標平台上,動態繫結也是對的選擇
因為它把一類會讓程式停擺的失敗,換成一類會自我報告的失敗。靜態連結的 framework 參照錯了,在目標平台上連結期就失敗,在別處永遠連不起來。動態繫結的參照錯了,產出的是一個不可用的後端加一串未解析名稱,Mac 上第一次執行,問題就從「為什麼不可用」變成一行指名某個錯字的訊息
還有一個每天兌現、而不是一次兌現的好處。因為這個 unit 不連結任何 framework,它在每個平台都編得過,日常的 Windows 建置於是持續檢查它的語法、型別與 uses 子句。一個只在團隊裡沒人擁有的平台上編得過的 unit,是沒有編譯器看著的 unit,共用型別每重構一次,它就安靜地朽壞一點
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 呼叫匯出兩種範疇完全不同的東西,程式碼必須知道哪個是哪個
具名常數——例如 keychain 項目類別鍵與 CoreFoundation 的布林 singleton——匯出的是變數,變數內容才是您要的 CFStringRef 或 CFBooleanRef。dlsym 回傳那個變數的位址,所以必須解參照一次才拿得到值。回呼表結構——例如字典的鍵與值回呼——匯出的是結構,dlsym 回傳結構的位址,而這恰恰是字典建立函式期待的那個指標。把這種解參照了,您遞出去的就是結構的第一個機器字組,還當它是個指標
兩種錯都不產生編譯錯誤,也都不產生清楚的執行階段錯誤。您拿到的是一個垃圾指標,在下游某處才爆。讓這個區分不可能弄錯的方法,是不再依賴記性:兩個輔助函式,一個繫結並解參照、一個只繫結不解參照,呼叫端藉此宣告自己要的是哪種符號,其餘由輔助函式把關
// 匯出變數:dlsym 給的是持有 CFTypeRef 的變數位址,
// 所以解參照一次
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');
// 匯出結構:dlsym 給的是結構「本身」的位址,API 要的
// 就是這個。不要解參照
FKeyCallbacks := BindStruct(CoreFoundationLib,
'kCFTypeDictionaryKeyCallBacks');
為什麼 RSA-PSS 簽章需要兩條獨立的 fallback?
因為這個演算法有兩種互不相干的缺席方式,其中只有一種是版本問題。PSS 摘要簽署演算法常數在 macOS 10.13 才出現,所以較舊的系統上符號根本不在,繫結拿到 nil。這是版本檢查。另一條線:在常數存在的系統上,特定的金鑰照樣可能拒絕它,而 framework 透過針對那把金鑰的 SecKeyIsAlgorithmSupported 回答這個問題。硬體保護的金鑰或帶限制屬性的金鑰可以對 PSS 說不,同一台機器上的軟體金鑰卻接受它
兩條路都必須通往同一個 fallback:改用 PKCS#1 v1.5。而關鍵在於,fallback 不只改簽章呼叫,還必須改寫進 CMS 結構的演算法識別碼。宣告 PSS 演算法識別碼、實際產出的卻是 v1.5 簽章,做出的是一份每個驗證器都直接拒收的文件,這比回報 PSS 不支援嚴格地糟。降級可以接受,宣告與所做不一致不可以——這是簽章程式碼的通則,不是 macOS 的怪癖。簽章層級的意涵展開在以 PAdES B-B 簽署 PDF
ECDSA 簽章編碼,以及一個值得注意的反轉
橢圓曲線路徑在 macOS 上完全不需要轉換,這跟 PKCS#11 繫結的要求正好相反。Security framework 的 ECDSA 摘要簽署演算法回傳的簽章已經是 X9.62 DER 形式,恰恰是 CMS 要的。PKCS#11 權杖回傳的卻是原始固定寬度的 P1363 對,得先重新編碼才能進簽章結構
於是實作同一介面的兩個後端,對同一個演算法需要相反的待遇,而兩者都沒錯。這正是抽象必須吸收、而不是暴露的那種差異:PAdES 層叫供應者簽,編碼慣例留在供應者體內。一旦往上漏,每個呼叫端最後都得揹一份按後端切換的條件。同樣的形狀也出現在對 HSM 的遠端 PAdES 簽章工作階段描述的遠端簽章故事裡
// 供應者介面在每個平台都相同,所以選擇是啟動時的決定,
// 不是每次呼叫的決定
{$IFDEF DARWIN}
if KeychainAvailable then
ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
// Windows CNG provider 由平台 unit 安裝
{$ENDIF}
if not PadesCryptoAvailable then
raise Exception.Create('no signing backend on this platform');
// 從這裡開始,簽章程式碼與平台無關
Signer := ResolvePadesSigner(Options);
相隔三行的參考計數規則
Core Foundation 的記憶體管理遵循命名慣例,而陷阱在於不同慣例的函式在同一小段程式裡比鄰而居。從 trust 物件取得(Get)憑證的函式,回傳的是不得釋放的借用參照。複製(Copy)簽署憑證或其資料的函式,回傳的是必須釋放的持有參照。三個呼叫排在一起、兩套所有權規則,釋放那個借來的不會在那一行失敗——它弄壞一個保留計數,之後拖垮某個毫不相干的東西
緩解辦法是:每次、無例外地,在寫清理程式碼之前先讀每個 framework 函式名稱裡的動詞。這相當於 CoreFoundation 版的「先查這個 API 回傳的是副本還是視圖」,而弄錯的代價是一場間歇性當機,不是一則錯誤訊息
這個後端不宣稱什麼
撰寫此文時,它從未在 macOS 上執行過,把這件事講白,比一句暗示性的保證有用。可證明為真的部分更窄、但依然有價值:這個 unit 作為每日建置的一部分在 Windows 上編譯;每個 framework 符號都在執行階段按名稱繫結、失敗會被逐一列舉;包含兩條 PSS fallback 在內的演算法選擇邏輯,是可以審閱與推演的普通 Pascal。Mac 上第一次執行,要嘛能動,要嘛交給您一張待修名稱清單
驗證那一側——用的是高階 CMS 解碼器、而不是手工拼裝 CMS 結構——記錄在在 macOS 用 SecTrust 驗證 PDF 簽章,它共用同一套繫結基礎建設與同一套診斷思路
這裡可帶走的想法關於風險擺位,與 macOS 無關。當您必須對著一個無法驗證的介面寫程式,選那個錯誤定位起來最便宜的構造。帶明確未解析名稱清單的動態繫結,把二十個無法驗證的假設變成一行診斷訊息。兩個後端都以原始碼形式隨 PDFium Delphi component 出貨,所以萬一某個符號名稱真的要修,那是您自己樹裡的一行改動,不是一張支援單