技術文章

在 Delphi 中以 HotPDF 進行後量子與 EdDSA PDF 簽章

帶著 ML-DSA-65 簽章的 PDF,可以通過產出它的工具鏈自己的檢查,卻在一個堅持檔案必須聲明「哪個延伸定義了這個演算法」的驗證器那裡得到不支援演算法的答覆。公鑰 1,952 位元組,恰好是 FIPS 204 為該參數集定的尺寸。簽章位元組完好,位元組範圍覆蓋整份檔案。這種驗證器要找的,是文件 Catalog 裡指名那個延伸的一條條目,因為 ISO 32000-2 根本沒有列出 ML-DSA

簽章這一側的牆不一樣。硬體權杖、雲端 HSM 或國民 eID 卡都願意按要求算一張簽章,但沒有一個會把私鑰交給你的應用程式,所以任何期待載入 PFX 檔、行程內簽章的簽署程式碼,根本無物可載。兩個問題都關乎決定住在哪裡:演算法宣告住在檔案裡,金鑰住在你的 Delphi 程式碼摸不到的地方

為什麼有效的 ML-DSA 簽章會換來不支援?

HotPDF 只有在載入的文件於 Catalog 中聲明了 HotPDF 開發者延伸時,才驗證 ML-DSA-44、ML-DSA-65 與 ML-DSA-87 CMS 簽章。沒有那條聲明,驗證器就對格狀簽章回傳 svUnsupportedAlgorithm,密碼學本身再健全也一樣。道理是可重現性:一份使用基礎標準未命名演算法、又不說明自己依賴哪個延伸的檔案,得出的判定,別的實作沒有依據可以重現

產出這一側,THotPDF.EnsureMLDSAExtensions 把 /Extensions << /HotPDF << /BaseVersion /2.0 /ExtensionLevel 1 >> >> 寫進 Catalog,文件若還停在較舊版本就一併升到 PDF 2.0。讀取這一側,LoadedDocumentDeclaresMLDSAExtension 只在該字典存在、/BaseVersion 等於 2.0 且 /ExtensionLevel 至少為 1 時回傳 True。VerifyLoadedSignatureWithOptions 把兩件事合起來:它拿呼叫端的 AllowMLDSA 旗標,在任何驗證開跑之前先與聲明檢查做 AND,所以在未聲明的檔案上打開旗標什麼也不會改變

HotPDF 把 ML-DSA PDF 驗證關在明確的 Catalog 延伸聲明後面,這道聲明由 EnsureMLDSAExtensions 在文件儲存前寫入;未聲明的檔案即使設了 AllowMLDSA 也會被拒用格狀演算法
HotPDF 只在 Catalog 仍聲明延伸時接受 ML-DSA,讓後量子判定得以重現;在 EndDoc 之前呼叫 EnsureMLDSAExtensions,聲明便落在已簽署位元組範圍之內而不是之外
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'contract-pq.pdf';
    Pdf.StrictVersionLock := False;   // 預設值;讓這次呼叫能把檔案升到 PDF 2.0
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Supply agreement 2026-114');
    Pdf.EnsureMLDSAExtensions;        // 在任何簽章蓋住 Catalog 之前聲明
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

那段程式裡有兩個細節有分量。呼叫必須發生在文件儲存與簽署之前,因為 Catalog 坐在已簽署位元組範圍裡;事後補寫,要嘛是對已簽署檔案的一次未簽署變更,要嘛是一個任何驗證器都會列為修改的新的增量修訂。第二個細節是 StrictVersionLock。鎖打開、文件被釘在 PDF 2.0 以下時,EnsureMLDSAExtensions 什麼也不寫就返回,因為 PDF 2.0 的延伸無法在 1.7 檔案裡聲明。若你的產生器為了封存理由釘死版本,依賴聲明之前先確認 Version = pdf20

EdDSA 與 ML-DSA 共用一個驗證呼叫

VerifyLoadedSignatureWithOptions 接收簽章索引、來源串流、一份 THPDFCMSVerifyOptions 記錄與一個 out THPDFSignatureInfo。記錄從 THPDFCMSVerifyOptions.Default 起步:AllowMLDSA 關著、CMS 容器上限 256 個簽署者、副簽章巢狀深度上限 8。對新演算法要緊的是三個欄位。SignatureProvider 讓你自己的驗證器優先作答。OpenSSLLibraryPath 指名執行純模式 Ed25519 與 Ed448 驗證的 OpenSSL 3 libcrypto。AllowMLDSA 選入格狀演算法,仍受前述 Catalog 檢查約束。記錄還帶簽章政策釘選欄位(RequireSignaturePolicy、ExpectedSignaturePolicyOID、ExpectedSignaturePolicyHash),對每個演算法家族都適用

內建路徑按演算法分家。Ed25519 與 Ed448 走 OpenSSL 3;沒給路徑時,平台提供者在 Win64 上按正常 DLL 搜尋順序嘗試 libcrypto-3-x64.dll、libcrypto-3.dll 與 libcrypto.dll。ML-DSA 走 Windows CNG 的 ML-DSA 演算法提供者,Windows 版本不提供它時結果就是不支援。把 OpenSSLLibraryPath 指向你隨執行檔出貨的那份。留空,判定就取決於哪個安裝程式碰巧在搜尋路徑上丟了哪個 libcrypto,這種事在稽核裡很難解釋

你自己的 SignatureProvider 先被問到。它回傳 spsUnsupported 時,內建平台提供者會接到同一個請求,所以只懂 ML-DSA 的提供者可以擋在預設 EdDSA 處理前面,而不必重寫那套處理

var
  Pdf: THotPDF;
  PQ: THPDFCallbackSignatureProvider;
  Opts: THPDFCMSVerifyOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Src: TFileStream;
begin
  PQ := THPDFCallbackSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest): THPDFSignatureProviderStatus
    begin
      if not (Req.Algorithm in [hsaMLDSA44, hsaMLDSA65, hsaMLDSA87]) then
        Exit(spsUnsupported);            // 把 EdDSA 交給平台提供者
      // MyFips204Verify 是你自己包的 ML-DSA 程式庫轉接層;
      // Req.Input 放著 DER 編碼的 signed attributes(純模式)
      Result := MyFips204Verify(Req.AlgorithmOID, Req.PublicKey,
        Req.Input, Req.Signature);
    end);
  Pdf := THotPDF.Create(nil);
  Src := TFileStream.Create('contract-pq.pdf', fmOpenRead or fmShareDenyNone);
  try
    Pdf.LoadFromFile('contract-pq.pdf');
    Opts := THPDFCMSVerifyOptions.Default;
    Opts.SignatureProvider := PQ;
    Opts.OpenSSLLibraryPath := ExtractFilePath(ParamStr(0)) + 'libcrypto-3-x64.dll';
    Opts.AllowMLDSA := True;             // 仍會與 Catalog 聲明做 AND
    Status := Pdf.VerifyLoadedSignatureWithOptions(0, Src, Opts, Info);
    if Status = svValid then
      Memo1.Lines.Add(string(Info.SignatureAlgorithm) + ' / ' +
        string(Info.SignatureAlgorithmOID));
  finally
    Src.Free;
    Pdf.Free;
    PQ.Free;                             // 選項記錄不擁有它
  end;
end;

記錄 Info.SignatureAlgorithmOID,而不是你設定檔裡以為的那個演算法。ML-DSA-65 對應 2.16.840.1.101.3.4.3.18,一份記下檔案中實際找到之 OID 的稽核軌跡,日後能回答設定值回答不了的問題。完全不需要自訂提供者的話,較短的 VerifyLoadedSignature(SigIndex, Source, Info) 超載會建好預設選項,並替你從 Catalog 聲明設定 AllowMLDSA

讓外部產出的 ML-DSA 與 EdDSA CMS 踩坑的編碼規則

兩個演算法家族都以純模式對完整的 DER 編碼 signed attributes 驗證,沒有 signed attributes 的 SignerInfo 回報為不支援。EdDSA 的 AlgorithmIdentifier parameters 必須缺席。手搓 CMS 常見的錯,是先用 SHA-512 或 SHAKE256 雜湊 signed attributes 再簽那個摘要,這悄悄把 PureEdDSA 變成 CMS 裡無人聲明的 prehash 變體。ML-DSA 的驗證器還檢查參數集與訊息摘要的配對:ML-DSA-44 接受 SHA-256、SHA-384、SHA-512 或 SHAKE256,ML-DSA-65 要 SHA-384 或更強,ML-DSA-87 要 SHA-512 或 SHAKE256。配對錯了會出現在 Info.DigestConsistencyIssue,別家工具鏈產出的檔案驗不過時,先讀這個欄位

EdDSA 有自己的註冊故事。ISO/TS 32002 是為 PDF 2.0 簽章註冊 Ed25519 與 Ed448 的延伸,EnsureISO32002Extensions 把對應的 ISO_ 條目連同 ExtensionLevel 32002 加進 Catalog。驗證在沒有該條目時也接受 EdDSA,但你自己產出的檔案仍應帶著它,理由與 ML-DSA 聲明一樣:可重現性

簽章提供者被要求做什麼

簽章提供者收到一個 THPDFSignatureProviderRequest,回傳一個 THPDFSignatureProviderStatus,成功時外加簽章位元組。請求帶著演算法與其 OID、摘要演算法 OID、PSS 鹽長度、Input 是訊息(sikMessage)還是預先算好的摘要(sikDigest)、輸入位元組、簽署者憑證、金鑰識別碼,以及這次呼叫唯一的操作識別碼。這套詞彙權杖驅動程式與簽章服務本來就會說,而且沒有一項需要金鑰跨進你的行程

每個提供者還公告一組能力,例如 spcSign、spcCancel、spcHardwareKey 與 spcRemoteKey。程式庫隨附幾種實作:

  • THPDFCallbackSignatureProvider 用匿名方法包住驗證、簽章與取消,是從既有內部簽章常式通往 PDF 簽章的最短路
  • THPDFRemoteSignatureProvider 包住一個傳輸函式,帶重試上限、取消登錄簿,以及輸入與簽章尺寸上限,行為失常的遠端服務沒辦法把超大回應塞進 CMS 編碼器
  • THPDFPKCS11SignatureProvider 借用你的程式碼已經開啟並登入的 session 與私鑰 handle。HotPDF 永遠看不到 PIN,也不管理權杖 session;它把 C_Sign 呼叫序列化、為 RSA PKCS#1 v1.5 前置 DigestInfo、透過 mechanism 參數把真正的鹽長度交給 RSA-PSS,並把原始 ECDSA r||s 結果轉成 CMS 期待的 DER 形式
  • THPDFWindowsKeyStorageSignatureProvider 透過 Windows CNG 金鑰儲存簽章,涵蓋智慧卡與 TPM 支撐的金鑰,並可要求硬體支撐的金鑰

PKCS#11 提供者收摘要輸入,所以處理 RSA PKCS#1 v1.5、RSA-PSS 與 ECDSA。Ed25519 與 Ed448 簽的是完整的 signed-attributes 訊息,那要經過 callback 或遠端提供者,由你的程式碼把 Req.Input 當訊息交給權杖或服務

HotPDF 可插拔簽章提供者交換單純的請求記錄、回傳六值狀態,圖中為 callback、遠端與 PKCS#11 三種實作,私鑰始終不進 Delphi 行程
callback、遠端與 PKCS#11 提供者說同一種請求記錄、回同樣的六值狀態,硬體權杖、遠端 HSM 服務與國民 eID 卡因此能留著自己的金鑰,不必匯出
var
  Provider: THPDFRemoteSignatureProvider;
  Opts: THPDFCMSSignOptions;
  CertDER: TBytes;
  InStream, OutStream: TFileStream;
begin
  CertDER := TFile.ReadAllBytes('signer.cer');   // System.IOUtils;遠端金鑰的 DER 憑證
  Provider := THPDFRemoteSignatureProvider.Create(
    function(const Req: THPDFSignatureProviderRequest; Attempt: Integer;
      out Signature: TBytes): THPDFSignatureProviderStatus
    begin
      // PostToSigningService 是你自己的 HTTP 用戶端:把逾時與 5xx 映射成
      // spsProviderError、被拒絕的核可映射成 spsCancelled、未知金鑰映射成 spsUnsupported
      Result := PostToSigningService(Req.KeyIdentifier, Req.AlgorithmOID,
        Req.Input, Signature);
    end,
    3,          // RetryLimit:總共至多四次嘗試
    1048576,    // MaxInputBytes
    65536);     // MaxSignatureBytes
  Opts := HPDFCMSDefaultOptions(palBaseline_B_B);
  Opts.SignatureScheme := cmsRSAPSS;
  Opts.DigestAlgorithm := cmsdaSHA256;
  Opts.PSSSaltLength := 32;
  // contract-unsigned.pdf 儲存時帶著 AddSignedSignatureField 排好的佔位符
  InStream := TFileStream.Create('contract-unsigned.pdf', fmOpenRead or fmShareDenyWrite);
  OutStream := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    HPDFCMSSignPDFStreamWithProvider(InStream, OutStream, CertDER,
      Provider, 'contracts-2026', Opts);
  finally
    OutStream.Free;
    InStream.Free;
    Provider.Free;
  end;
end;

六種狀態,各自該觸發什麼

THPDFSignatureProviderStatus 有六個值,因為每個值的正確反應不同。spsInvalid 是密碼學失敗,該進安全日誌。spsUnsupported 表示提供者沒實作這個演算法,屬於部署問題。spsMalformed 表示請求或回應形狀不對。spsProviderError 是傳輸或設備故障,重試可能成功。spsCancelled 表示有人拒絕了權杖提示,重試只會惹人煩

遠端提供者把這套政策內建了。它只在 spsProviderError 上重試,每次嘗試前先查取消登錄簿,呼叫傳輸之前就把空輸入或超尺寸輸入擋成 spsMalformed,並把帶著空簽章或超尺寸簽章的「有效」答案降級為 spsMalformed。驗證這一側,提供者的答案映射到驗證狀態:spsInvalid 變 svSignatureInvalid,spsMalformed 變 svMalformed,spsProviderError 與 spsCancelled 都變 svCryptoProviderError,spsUnsupported 在平台提供者也拒了這個請求之後變 svUnsupportedAlgorithm

自己寫提供者的話,抵抗把每個意外狀況都映射成 spsInvalid 的衝動。那會把缺一個 DLL 或服務權杖過期,變成「客戶的簽章是偽造」的報告,而總得有人去向客戶解釋這句話。數學真的失敗時才回 spsInvalid。簽章方向,只有連同非空簽章才回 spsValid;提供者回報成功卻不帶位元組時,兩個入口點都會引發 EHPDFCMSError

提供者的簽章落在檔案裡的哪裡

兩個函式把提供者接到真實的 PDF 位元組。HPDFCMSBuildSignedDataWithProvider 從你在別處算好的文件摘要建 detached CMS;摘要長度必須符合 Options.DigestAlgorithm,SHA-384 設定期待 48 位元組,收到 32 位元組的 SHA-256 摘要會以例外拒絕。HPDFCMSSignPDFStreamWithProvider 簽的是 AddSignedSignatureField 排好的佔位符:雜湊兩個 /ByteRange 視窗、向提供者要對 signed attributes 的簽章,然後把十六進位結果補進 /Contents。CMS 長過佔位符時,這個呼叫引發例外,並在輸出串流裡還原未簽署的佔位符,而不是截斷任何東西

版面與 PFX 簽章路徑寫出的相同:兩個跳過十六進位簽章視窗的範圍。驗證器拿這個形狀要求每一份檔案,檢查兩個範圍之間的空隙是否恰好只有這張簽章的 /Contents 字串而無其他,否則回報 svInvalidByteRange。因此透過 PKCS#11 權杖簽出的文件,用的是與簽章驗證程式碼相同的驗證路徑,跟從 PFX 檔簽出的一樣。演算法選擇之上的設定檔規則見Delphi 中的 PAdES 基線簽章,PKCS#11 轉換對付的 ECDSA 編碼陷阱見ECDSA CMS 驗證與 P1363 簽章格式

HotPDF 的兩個提供者簽章入口:給外部計算摘要的 HPDFCMSBuildSignedDataWithProvider 與給排好佔位符的 HPDFCMSSignPDFStreamWithProvider,收斂到同一個保留 /ByteRange 範圍的 CMS 版面——正是驗證器最先檢查的部分
任一入口寫出的都是同一種已簽署形狀:兩個覆蓋範圍夾著十六進位視窗,提供者簽出的文件與從 PFX 檔簽出的位元組版面相同

推出後量子簽章,而不把讀者晾在半路

沒實作 ML-DSA 的 PDF 閱讀器會把這種簽章回報為無法驗證,看文件的人看到的是警告而不是綠色勾勾。這使遷移變成排程問題。讓 RSA 或 ECDSA 繼續當今天驗證器要評判的那張簽章。政策要求量子抗性證據之處,用 EnsureMLDSAExtensions 聲明延伸,並在古典簽章旁邊加上第二張由支援 ML-DSA 的簽署器產出的簽章。那張簽章來自你自己的 FIPS 204 簽署器——HotPDF 的 CMS 建構器不產生 ML-DSA 簽章,程式庫的角色是驗證它。等消費你文件的系統能驗證了,再把主簽章換過去

在這個計畫裡,HotPDF 蓋住你從 Delphi 掌控的兩端。它的 CMS 建構器透過上述任一提供者以 RSA PKCS#1 v1.5、RSA-PSS、ECDSA、Ed25519 與 Ed448 簽章;它的驗證器從一次呼叫檢查 ML-DSA、EdDSA 與古典演算法,並把實際找到的 OID 記進結果。HotPDF 是 Delphi 與 C++Builder 的原生 VCL PDF 元件,不需要外部 PDF 執行階段,簽章與驗證程式碼直接編譯進你的執行檔。版本、簽章功能集與試用下載,都在 HotPDF Delphi PDF component 頁面