HotPDF 會將摘要交給 Windows,使用已存在於 Windows 憑證存放區中的憑證簽署 PDF,而 Windows 會透過兩種私密金鑰後端之一完成要求:CNG 回傳大端序的 RSA 簽章,傳統 CryptoAPI CSP 則回傳小端序的簽章。若混淆兩者,HotPDF 嵌入的 CMS 簽章就會依實際回應的後端產生反轉位元組,因此即使文件位元組從未被碰觸,符合規範的驗證器仍會回報簽章無效
這句話背後其實藏著兩個互不相關的問題,而 HotPDF 的系統憑證簽署器必須在開始簽署前同時解決兩者。位元組順序不一致是無聲的:簽署呼叫仍會回傳 True,PDF 仍可開啟,只有檢視器走訪 CMS 結構並拒絕它時才會顯示失敗。第二個問題則明顯而且是 C++Builder 特有的:大約六個 crypt32 函式拒絕連結,因為 RAD Studio 隨附的匯入程式庫沒有匯出它們。如果你始終只使用 PFX 檔案簽署,這兩個問題都不會出現,因此從以 PFX 為基礎的單次呼叫簽署轉向使用 IT 部門已安裝在使用者設定檔中的憑證時,開發人員很容易遇到這些問題
從存放區選取憑證
HotPDF 以 HPDFSignPDFStreamWithSystemCertificate 和 HPDFSignPDFFileWithSystemCertificate 提供這條路徑,兩者都由 THPDFCertificateStoreSelector 記錄驅動:Location(cslCurrentUser 或 cslLocalMachine)、StoreName(預設為個人存放區 'MY')、SHA-1 Thumbprint,以及 AllowUI 旗標。指紋會在內部正規化,因此直接從憑證管理員 UI 複製的連字號或空格,會在比對前先被移除
var
Selector: THPDFCertificateStoreSelector;
Options: THPDFCMSSignOptions;
begin
Selector := THPDFCertificateStoreSelector.Default; // cslCurrentUser, store 'MY'
Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
Selector.AllowUI := False;
Options := HPDFCMSDefaultOptions(palBaseline_B_B);
if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
'invoice-signed.pdf', Selector, Options) then
raise Exception.Create('Certificate-store signing failed');
end;
AllowUI = False 的重要性超乎表面,因為它直接對應 CRYPT_ACQUIRE_SILENT_FLAG,而 Windows 會照字面遵守:如果符合條件的憑證私密金鑰位於智慧卡或權杖上,且 Windows 尚未快取所需的 PIN 提示,CryptAcquireCertificatePrivateKey 會失敗,而不是在可能是服務程序的環境中彈出對話方塊。這個失敗很明顯,會立即看到 EHPDFCMSError,但很容易被誤讀為「找不到憑證」,實際原因是權杖正在等待沒有人會輸入的 PIN
為什麼 CNG 和 CAPI 的位元組順序不同
由哪個後端回應並不是猜測:CryptAcquireCertificatePrivateKey 會透過 KeySpec 輸出參數直接回報,而 HotPDF 的簽署器就是根據這個單一值分支。CNG 金鑰儲存提供者的金鑰回傳時,KeySpec 會設為哨兵值 CERT_NCRYPT_KEY_SPEC ($FFFFFFFF);其他任何值都代表傳統 CryptoAPI CSP 金鑰。目前 Windows 安裝環境中簽發或匯入的大多數個人憑證都會解析為 CNG,即使為了相容性仍存在舊版 CSP 轉接層,這也是 HotPDF 在檢查回傳值前,會同時要求 CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG 和 CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG 的原因
兩個後端不只是呼叫不同函式,CNG 金鑰使用 NCryptSignHash,CSP 金鑰使用 CryptSignHashA;它們還會以相反的位元組順序交回原始 RSA 簽章。CNG 的輸出已符合 PKCS#1 的期待:大端序八位元字串,最高有效位元組在前,正是 RFC 8017 的 I2OSP 轉換產生的形式,也是 ISO 32000-1 §12.8.3 下 CMS SignerInfo(RFC 5652)簽章欄位所需的形式。相較之下,CryptoAPI 的 CryptSignHash 會回傳小端序簽章,這是有文件記載的特性,源自傳統 CSP 在內部表示大數值的方式。若在 CAPI 路徑略過反轉,簽章中的每個位元組都會位於錯誤位置;RSA 數學仍然正確,但驗證器讀取的八位元字串並不是 PKCS#1 所定義的字串
// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
Temp := Signature[I];
Signature[I] := Signature[High(Signature) - I];
Signature[High(Signature) - I] := Temp;
end;
自訂簽署器回呼如何處理
任何繞過 HotPDF 內建憑證存放區簽署器的作法,都必須遵守相同的位元組順序規則。HPDFCMSSignPDFStreamWithExternalSigner 接受 THPDFCMSSignDigestCallback,其閉包型別為 reference to function(const SignedAttributesSHA256: TBytes): TBytes,可用於透過 HSM、智慧卡中介軟體堆疊,或任何 Windows 存放區無法提供金鑰控制代碼的其他方式進行簽署。無論該回呼背後使用哪個後端,它回傳的位元組都必須先採大端序,HotPDF 才能將其整合至 CMS 結構
Signer :=
function(const SignedAttributesSHA256: TBytes): TBytes
begin
if UsesCngKeyStorageProvider then
Result := SignWithMyCngKey(SignedAttributesSHA256) // already big-endian
else
Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
CertificateDER, Signer, Options);
這裡值得明確劃出界線:HotPDF 的兩條內建簽署路徑,CNG 透過帶有 PKCS#1 填補的 NCryptSignHash,CAPI 透過 CryptSignHashA,兩者都針對簽署 32 位元組 SHA-256 摘要的 RSA 金鑰。兩者都不會協商 ECDSA 簽章格式。私密金鑰採 EC 的憑證需要你自行針對 HPDFCMSSignPDFStreamWithExternalSigner 撰寫簽署器,按照 CMS 的期待編碼 ECDSA 簽章,而不是假設固定長度的 RSA 位元組字串,因此不要期待內建憑證存放區簽署器能對配備 EC 憑證的權杖正確運作
為什麼 C++Builder 無法連結 CertOpenStore
因為 RAD Studio 的預設 C++Builder 匯入程式庫 import32.lib 沒有匯出 CertOpenStore,也沒有匯出另外五個相鄰函式:CertEnumCertificatesInStore、CertGetCertificateContextProperty、CertFreeCertificateContext、CertCloseStore 和 CryptAcquireCertificatePrivateKey。Delphi 建置永遠不會遇到這點,因為 dcc32/dcc64 會將靜態 external 'crypt32.dll' 匯入直接解析至 PE 匯入表。C++Builder 則不同:Delphi 編譯器會為套件建置產生 OMF .obj,由 ilink32 連結,而此時相同的 external 宣告只是一個等待命令列匯入程式庫的未解析符號。即使將連結器指向 Windows SDK 的 psdk 目錄也無法修正,雖然完整的 crypt32.lib 確實匯出全部六個符號:ilink32 只會連結命令列實際命名的匯入程式庫,預設是 import32.lib cp32mt.lib,加入搜尋路徑不會讓它從該路徑額外載入任何內容。對 import32.lib 執行 tdump 可直接確認這個缺口,其中找不到 CertOpenStore,而 SDK 的 crypt32.lib 則能找到六個清楚的結果
HotPDF 以與程式庫其他位置處理憑證列舉相同的方式解決此問題:不要求連結器提供這些符號,而是在執行階段載入它們。內部 THPDFCryptoProcs 記錄包含一個 crypt32.dll 控制代碼、一個 advapi32.dll 控制代碼,以及十一個函式指標欄位;LoadCryptoProcs 會載入兩個 DLL,並在 HPDFSignPDFStreamWithSystemCertificate 開始時,使用 GetProcAddress 將每個進入點解析一次。如果缺少任何項目,會立即引發 EHPDFCMSError,而不是稍後在簽署流程深處因存取違規而失敗
type
TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
Crypt32Handle: HMODULE;
CertOpenStore: TCertOpenStoreFn;
begin
Crypt32Handle := LoadLibrary('crypt32.dll');
if Crypt32Handle = 0 then
raise Exception.Create('crypt32.dll could not be loaded');
@CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
// ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;
載入會在每次呼叫時完成,而不是在各個輔助函式內延遲載入,因為在 CNG 與 CAPI 之間選擇的閉包會依值擷取已載入的函式表,並且必須在整個簽署流程中保持有效,包括回呼至 HPDFCMSSignPDFStreamWithExternalSigner;簽署完成或引發例外後,兩個 DLL 控制代碼都會在最外層的 finally 區塊中釋放。這些變更完全不會碰觸公開介面:HPDFSignPDFStreamWithSystemCertificate、HPDFSignPDFFileWithSystemCertificate 和 THPDFCertificateStoreSelector 保持原本的確切簽章,因此現有呼叫端採用修正只需要重新建置,不需要修改程式碼
這項修正不涵蓋哪些內容
修正位元組順序和 C++Builder 連結後,會產生驗證器可解析的 CMS SignerInfo,以及可透過算術檢查的簽章;但這並不表示驗證器應該信任背後的憑證,因為鏈結建置、撤銷檢查和時間戳記原則是透過 CMS 選項疊加的獨立考量,不是位元組順序正確就能免費取得的保障。兩個維護細節與密碼學本身同樣重要:憑證查詢回傳的 PCCERT_CONTEXT 必須在存放區關閉前使用 CertFreeCertificateContext 釋放,而已取得的 CNG 或 CSP 金鑰控制代碼若 API 回報由呼叫端擁有,就必須透過相符後端自己的呼叫釋放,絕不能使用另一個後端的呼叫。如果完成這些步驟後取得的 svValid 結果比預期更狹窄,驗證 PDF 簽章的文章會準確說明該旗標承諾和不承諾的內容。由於憑證在整個過程中都由 Windows 保管,憑證存放區簽署避開了整個攻擊面:不需要解析 PKCS#12 檔案,也不需要自行走訪 ASN.1,後者正是 HotPDF 的 PKCS#12 與 ASN.1 強化針對 PFX 檔案簽署路徑所處理的問題
憑證存放區簽署、PFX 簽署和外部簽署器回呼,是 適用於 Delphi 和 C++Builder 的 HotPDF PDF 元件內部同一 CMS/PKCS#7 管線的三個入口,而選擇哪一個,主要取決於誰可以持有私密金鑰:你的程序、PFX 檔案,或 Windows 本身