技術文章

在 Delphi 中使用 PDFium 進行 PAdES B-B 簽署 PDF

PDFium 元件透過其 SignPades 方法為 PDF 進行 PAdES B-B 數位簽章:它載入文件、計算已簽署位元組範圍的雜湊值、建立 CAdES CMS 結構,並將簽章作為增量更新(incremental update)附加至檔案末尾。密碼學後端僅限 Windows,因此在簽署之前,請使用 PadesCryptoAvailable 保護每次呼叫

這種情況很常見。一份合約 PDF 送到您的桌上,法務部門希望在發送前對其進行數位簽署,此時您想使用已用於轉譯和檢查文件的同一個 PDFium 建置版本,卻發現 PDFium 完全無法寫入簽章。其簽章 API 嚴格限制為唯讀。PDFium 元件透過在 Pascal 中擁有整個簽署管線(從雜湊函數到位元組級寫入)填補了這一空白,而本文將端到端引導您瞭解該管線的細節

為什麼 PDFium 無法寫入數位簽章?

PDFium 將簽章公開為唯讀物件,且不提供任何用於建立簽章的功能。FPDFSignatureObj_* 系列可讓您列舉現有簽章、讀取其 /Contents,並檢查其 /ByteRange,但沒有對應的功能來建置簽章字典、預留 /Contents 插槽或寫入位元組範圍;雖然存在增量儲存(帶有 FPDF_INCREMENTALFPDF_SaveAsCopy),但其中完全不包含簽署掛鉤。因此,在 PDFium 之上進行 PDF 簽署的任何元件都必須自行產生每個簽章位元組,這就是為什麼 PDFium 元件使用三個純 Pascal 單元來建置該機制的原因。FPC 3.2.2 隨附了 MD5 和 SHA-1,但完全沒有隨附 SHA-2,且 Delphi System.Hash SHA-256 API 與 FPC 在原始碼級不相容,因此 FPdfSha256 是一個獨立的 FIPS 180-4 實作,它在單個 TSHA256Digest 型別上保留了每個 CMS 程式碼路徑,而無需進行編譯器分支。FPdfAsn1 提供了 CMS 結構所需的 DER 編碼器和讀取器,且 FPdfCms 在這兩者的基礎上組裝了 CAdES SignedData

如何在 Delphi 中進行 PDF 數位簽署?

載入文件,然後使用憑證指紋(thumbprint)呼叫 SignPades。PDFium 元件會針對「目前使用者」的「個人」(MY)憑證存放區解析該憑證指紋,提取符合的憑證及其私鑰,並將已簽署的複本寫入您指定的路徑

uses
  PDFium, FPdfCrypto;

procedure SignContract(const AThumbprint: string);
var
  Pdf: TPdf;
begin
  if not PadesCryptoAvailable then
    raise Exception.Create('PAdES 簽署需要 Windows CNG 後端');

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';   // 要簽署的文件
    Pdf.Active := True;
    // 第二個引數:「目前使用者」個人(MY)存放區中憑證的
    // SHA-1 憑證指紋。第一個引數:已簽署複本的目標路徑。
    if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
      raise Exception.Create('簽署失敗');
  finally
    Pdf.Free;
  end;
end;

PadesCryptoAvailable 是您每次首先需要檢查的探測函數。在 Windows 上它傳回 True,且 crypt32/ncrypt 後端處於動態;在任何其他平台上它傳回 False,且簽署呼叫會引發 EPadesCrypto。將此防護視為強制性的,可以防止 Linux 或 macOS 建置版本在執行期於無法運作的路徑上失敗。憑證指紋本身是憑證的 SHA-1 雜湊值(Windows 憑證管理員在其「詳細資料」索引標籤上顯示的相同數值),它指定了具體的簽署者,而無需在您的原始碼中放置金鑰資料

CMS 中包含的內容:已簽署屬性與 RFC 5652

PAdES 基準簽章不是針對檔案的原始 RSA 簽章;它是一個 CAdES CMS SignedData 結構,攜帶了一組規定的已簽署屬性,且 FPdfCms.BuildSignedData 恰好輸出該組合:content-type、message-digest 以及 signing-certificate-v2(藉由雜湊值將簽章與簽署者憑證繫結的 ESS 屬性)。此處的一個細節擊敗了幾乎所有手動撰寫的 CMS 實作。RFC 5652 §5.4 要求針對 DER SET OF 編碼(標籤 0x31)計算已簽署屬性的摘要,而相同的屬性在 SignerInfo 中是以 IMPLICIT [0] 標籤(0xA0)傳輸的。PDFium 元件對該屬性集進行一次編碼、計算 0x31 形式 of 摘要,然後僅將前導標籤位元組重寫為 0xA0 進行輸出,因此單個緩衝區即可同時扮演這兩個角色,而無需對樹結構進行第二次掃描

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;

    Opts := TPadesSignOptions.Default;
    Opts.CertificateThumbprint := 'a1b2c3d4e5f6...';  // 個人(MY)存放區中的簽署者
    Opts.Reason := 'I approve this agreement';
    Opts.Location := 'Berlin, DE';
    Opts.ContentsSize := 16384;                        // /Contents 的十六進位寬度

    if not Pdf.SignPades('contract-signed.pdf', Opts) then
      raise Exception.Create('簽署失敗');
  finally
    Pdf.Free;
  end;
end;

多載選項加入了 ISO 32000-1 §12.8.1 定義的簽章字典中介資料:ReasonLocationContactInfoName,均為選用項目,且均寫入至簽章值字典中。有一個限制很容易被忽略:如果您設定了 CommitmentTypeOid 以加入 CAdES commitment-type-indication 已簽署屬性,請不要同時設定 Reason;ETSI EN 319 142-1 §6.3 禁止同時攜帶這兩者,因為兩者透過不同的方式表達相同的意圖

ByteRange 與 /Contents 插槽如何協同運作?

簽章必須覆蓋除保存簽章本身的位元組之外的整個檔案,而 PAdES 透過 SignPadesBytes 精確管理的固定寬度預留位置解決了這一循環相依性。它預留了 ContentsSize 位元組(預設為 16384,明顯大於典型的 CMS SignedData)的 /Contents 十六進位字串、將增量更新進行序列化以定位確切的插槽位移量,然後將 /ByteRange 計算為夾住該插槽的兩個範圍(十六進位字串的開啟分隔符號之前的所有內容,以及關閉分隔符號之後的所有內容)。SHA-256 僅在該兩個範圍上執行。完成的 CMS 會被十六進位編碼至預留的插槽中、以零填滿至固定寬度,並附加交叉參照更新。因為寬度是在事前固定的,填滿插槽不會使後續的位元組發生偏移,這正是位元組範圍能保持有效的完整原因;原始的文件位元組被逐字元地保留下來,因此同一個檔案上的先前簽章得以完好無損地存活下來,完全符合 ISO 32000-1 §12.8.1 增量簽署的要求

Windows CNG 後端及其限制

PDFium 元件僅在 Windows 上進行簽署,該界限是刻意設定的。FPdfCryptoWin 動態繫結 crypt32.dll 和 ncrypt.dll,沒有加入編譯時的 DLL 相依性,且簽署鏈是標準的 CNG:開啟個人(MY)存放區、依雜湊值尋找憑證、透過 CryptAcquireCertificatePrivateKey 獲取其私鑰控點,並呼叫 NCryptSignHash。RSA 與 PKCS#1 v1.5、RSA-PSS 以及 ECDSA 均受支援。ECDSA 需要一個其他方法不需要的修正,因為 NCryptSignHash 傳回原始的 IEEE P1363 r-and-s 配對,而 CMS 期望獲得 DER ECDSA-Sig-Value SEQUENCE,因此後端會依 RFC 5480 對其進行重新編碼

var
  Pdf: TPdf;
  Opts: TPadesSignOptions;
  Output: TFileStream;
begin
  if not PadesCryptoAvailable then
    Exit;   // 該平台上沒有簽署後端

  Opts := TPadesSignOptions.Default;
  Opts.CertificateThumbprint := ReadThumbprintFromConfig;

  Pdf := TPdf.Create(nil);
  Output := TFileStream.Create('contract-signed.pdf', fmCreate);
  try
    Pdf.FileName := 'contract.pdf';
    Pdf.Active := True;
    Pdf.SignPadesToStream(Output, Opts);
  finally
    Output.Free;
    Pdf.Free;
  end;
end;

實際的後果是私鑰必須存在於 Windows 憑證存放區中。PFX 檔案中保存的憑證只有在您將其匯入至「目前使用者」存放區後才能運作,此時其憑證指紋即為您傳遞給 SignPades 的數值。該版本沒有 PKCS#11 或 HSM路徑,也沒有軟體金鑰檔案後端,因此當 PadesCryptoAvailable 傳回 False 時,在該電腦上根本無法進行任何簽署

PAdES B-B 的限制

PAdES B-B 是基準,是四個 PAdES 等級的底線:它證明了是誰簽署的,以及自簽署以來位元組未被修改,除此之外別無其他。B-B 簽章不攜帶受信任的時間戳記,因此無法證明簽署發生的時間,且不嵌入任何撤銷資料,因此多年後的驗證器必須自行獲取憑證鏈及其狀態。這些空白正是更高階等級所填補的。當您需要稽核人員接受的簽署時間時,加入 RFC 3161 時間戳記和 DSS 進行長期驗證可將簽章提升至 B-T 及以上等級;當您想讀回完成的簽章並確認其達到的等級時,檢查 PDF 簽章與 PAdES 等級是相對應的工具;而在簽署任何內容之前,稽核 PDF 安全風險會告訴您準備簽署的是什麼內容

此處介紹的 SignPades 方法隨附於適用於 Delphi 和 C++Builder 的 PDFium 元件 中,同時隨附的還有 PDFium 開箱即用的唯讀簽章檢查功能