技術文章

用 HotPDF 在 Delphi 中做 PDF 數位簽章與 PAdES

一枚 PDF 簽章多半是位元組的記帳工作,而出錯的地方也正是位元組記帳。密碼學跑的是被審視了二十年的程式碼,那一塊幾乎從不出事。在生產環境裡出事的東西謙卑得多:一個為真正的簽章預留得太小的佔位空間、一份取自檔案錯誤區段的雜湊,或是簽署後一次悄悄改寫了簽章已經凍結之位元組的「儲存」。把位元組排對,那個綠色勾勾自己會出現

HotPDF 為 Delphi 與 C++Builder 涵蓋了三個層級的簽署,而您靠回答一個問題在它們之間做選擇:私密金鑰住在哪裡?磁碟上的一個 PFX 檔只需要單一函式呼叫。鎖在 HSM 或遠端簽署服務裡的金鑰需要預留、雜湊、插入這套序列,因為沒有任何函式庫能伸進權杖裡把金鑰拉出來。一枚必須滿足歐洲法規的簽章,還要在那之上加上 PAdES 基線結構。下面幾節就順著這條進程走

決策圖:在 HotPDF 的 PFX 一次呼叫簽署、金鑰位於 HSM 或遠端服務時的預留雜湊插入路徑,以及受規範的歐洲簽署所需的 PAdES 基線結構之間做選擇
問私密金鑰住在哪裡,就能挑出簽署層級;一個讀得到的 PFX 檔把簽署收攏成單一呼叫,而權杖持有的金鑰則逼出位元組層級的繞道,歐洲法規再加上 PAdES 那一層

/ByteRange 如何釘住被簽署的位元組

一枚簽章必須住在它所簽署的檔案裡,而它不能簽自己。PDF 靠留一個洞來繞開這個悖論。簽署之前,寫入器會預留一個固定大小、填滿零的 /Contents 項目,並為它兩側的兩段區間記下一個 /ByteRange 陣列:洞之前的一切,洞之後的一切。簽署者把那兩段區間做雜湊,並把產生的 CMS 二進位資料以十六進位寫進那個洞。陷阱就在固定這個詞。您得在知道完成後的簽章有多大之前,就先把那個洞的大小定下來,所以這個預留必須是一個有把握的高估。八 KB 綽綽有餘地容得下一枚憑證鏈短的分離式 CMS 簽章

HotPDF 把這兩種情況拆成兩個呼叫,而把它們搞混是早期常犯的錯誤。AddSignatureField 放下一個空的、看得見的欄位,供人日後在檢視器裡簽。AddSignedSignatureField 則建立欄位並預留 /Contents 那個洞,而只要是由程式碼、而不是由人來完成簽章,您要的就是它。丟給外部簽署者一個空欄位,它就沒有東西可填

一次呼叫的路徑:從 PFX 簽署

當憑證與它的私密金鑰位在一個您的行程讀得到的 PFX/PKCS#12 檔案裡時,整條管線收攏成一個類別函式:

if THotPDF.SignPDFWithPFX('invoice-unsigned.pdf', 'invoice-signed.pdf',
    'company-cert.pfx', 'pfx-password') then
  Writeln('Signed: invoice-signed.pdf')
else
  raise Exception.Create('PFX signing failed');

當這件事失敗時,問題很少出在 PDF。是 PFX 出了問題。HotPDF 讀得懂以 PBES2 保護的容器,也就是以 PBKDF2 金鑰推導搭配 AES-256-CBC。由較舊的 Windows 憑證精靈匯出、或由 3.0 之前的 OpenSSL 匯出的 PFX,通常改用舊式的 RC2 或 3DES 包起來,那就是剖析不了。修法是用現代保護把容器重新匯出一次;今天的 OpenSSL 預設就這麼做,而且這不是程式碼變更。所以當簽署在一張「在別的地方都能用」的憑證上瞬間掛掉時,請先看那個 PFX 是怎麼產生的,再去懷疑自己的程式碼

給 HSM 與權杖用的預留、雜湊、插入路徑

一次呼叫的路徑假設您的行程能把金鑰當成檔案讀。它愈來愈常做不到。金鑰住在 HSM 裡、在 USB 權杖上,或在某個簽署服務的 API 背後,函式庫沒有辦法直接搆到它。HotPDF 的處理方式是把簽署拆成位元組層級的步驟:寫出一份帶佔位空間的文件、向函式庫要雜湊區間、把雜湊輸入交給持有金鑰的那一方,再把傳回來的 CMS 接回那個洞裡

HotPDF:在 placeholder.pdf 上的四步驟預留雜湊插入管線,顯示夾在兩段 ByteRange 區間之間預留的 /Contents 洞,以及一台 HSM 以摘要換回 CMS 十六進位
HotPDF 預留那個洞並回報兩段 ByteRange 區間,您的金鑰持有方在外部簽署它們,傳回的 CMS 再逐位元組接回去,不碰任何一個已凍結的位元組
var
  Doc: THotPDF;
  Fs: TFileStream;
  PdfBytes, HashInput, SigHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, CStart, CLen: Integer;
begin
  // 1. 寫出帶有預留 /Contents 洞的文件
  Doc := THotPDF.Create(nil);
  try
    Doc.FileName := 'placeholder.pdf';
    Doc.BeginDoc;
    Doc.CurrentPage.AddSignedSignatureField('Sig1',
      Rect(50, 100, 350, 150), 8192, 'adbe.pkcs7.detached',
      'Contract approval', 'Boston, MA', 'legal@example.com');
    Doc.EndDoc;
  finally
    Doc.Free;
  end;

  // 2. 載入已存下的位元組;傳回的位移是從 0 起算
  Fs := TFileStream.Create('placeholder.pdf', fmOpenRead);
  try
    SetLength(PdfBytes, Fs.Size);
    Fs.ReadBuffer(PdfBytes[1], Fs.Size);
  finally
    Fs.Free;
  end;
  THotPDF.PreparePDFForSigning(PdfBytes, R1Start, R1Len, R2Start, R2Len,
    CStart, CLen);

  // 3. 對兩段區間做雜湊,並在外部簽署(HSM、權杖、服務)
  HashInput := Copy(PdfBytes, R1Start + 1, R1Len) +
               Copy(PdfBytes, R2Start + 1, R2Len);
  SigHex := SignWithHsm(HashInput);  // 您的整合:以十六進位傳回 CMS

  // 4. 把簽章接進預留的洞裡
  THotPDF.InsertSignatureHex(PdfBytes, SigHex);
  Fs := TFileStream.Create('signed.pdf', fmCreate);
  try
    Fs.WriteBuffer(PdfBytes[1], Length(PdfBytes));
  finally
    Fs.Free;
  end;
end;

這段序列裡有兩個細節造成了大多數的間歇性失敗。第一個是 PreparePDFForSigning 作用在一份已完成檔案的位元組上。佔位文件必須先完整寫出並存好,那些位移才有意義;拿一份還在組裝中的串流去算它們,它們就對不上您最終拿去雜湊的位元組。第二個又是預留大小。您要的那 8192 位元組必須裝得下最終的 CMS,而一枚帶著中繼憑證的簽章,或是一枚被某項服務加上已簽署屬性的簽章,可能會撐爆它。InsertSignatureHex 不會把洞撐大來挪出空間。徵兆是一條用某張憑證簽得好好的、換下一張就失敗的管線;療法是重新產生佔位文件,其預留量要量自實際簽署者所產出的一枚真實簽章,不是猜的

PAdES 基線,以及讓簽章活下去的那些時間戳記

如果您是在歐洲規範下簽署,正在起作用的標準是 ETSI EN 319 142-1,它疊出四個 PAdES 基線層級。B-B 是單純的簽章。B-T 加上一枚可信時間戳記,證明它是何時做出來的。B-LT 把驗證材料,也就是憑證與撤銷資料,嵌進文件裡,好讓它多年之後仍檢查得了。B-LTA 再往上疊定期的文件時間戳記,讓證據活得比它所建立於其上的演算法還久。HotPDF 為每一個層級發出文件端的結構:

HotPDF:從 B-B 經 B-T、B-LT 到 B-LTA 層層堆疊的 PAdES 基線層級,並附上一條續期時間軸,顯示定期文件時間戳記如何讓簽章數十年後仍可驗證
每一層都在前一層之上疊起新的保護;B-LTA 反覆重新施加文件時間戳記,好讓證據活得比它最初所建立於其上的演算法還久
// PAdES 基線簽章欄位(ETSI EN 319 142-1)
Pdf.CurrentPage.AddPAdESSignatureField(
  'ApprovalSig', Rect(50, 100, 350, 150), 'B-B',
  'Contract approval', 'Boston, MA', 'legal@example.com');

// 文件時間戳記:為 TSA 權杖與憑證鏈預留更大空間
Pdf.CurrentPage.AddDocumentTimestampSignature('ArchiveTS', 16384);

時間戳記上那 16384 位元組的預留是刻意的。時間戳記機構傳回的權杖會拖著自己的憑證鏈一起來,所以它照例需要比一枚單純簽章滿足於的 8 KB 更多空間。那些文件時間戳記也正是 B-LTA 背後的機制:每隔幾年拿仍屬當代的演算法為一枚已封存的簽章重新蓋時間戳記,這就是讓您在 2026 年簽的一份文件到 2040 年仍可驗證的辦法

關於兩個欄位呼叫都接受的原因、地點與聯絡人字串,說一句:它們是便利用的中繼資料,如此而已。HotPDF 把它們存成單純的字典項目,並畫進看得見的簽章外觀裡,但沒有任何驗證器會拿它們去跟什麼比對。請從您的工作流程資料一致地填寫它們,因為稽核人員確實會讀,但也絕對別把它們誤當成證據。真正的密碼學主張完全住在 CMS 與它的憑證鏈裡,而驗證器徹底忽略那些看得見的文字

簽署之後,檔案只能長大

簽章存在的那一刻,它各區間內的位元組就凍結了。之後要改動這個檔案,唯一正當的方式是一次 ISO 32000-1 §7.5.6 的增量更新,它把新增與變更過的物件附加在原本的位元組之後,並串上一段回指它們的新交叉參照區段。這樣做,簽章對它那個修訂版仍然有效,而檢視器回報的是誠實的狀態:已簽署的修訂版完好無損,文件在那之後被延伸過。改成把整個檔案重新序列化,您就改寫了那些已簽署的區間,即使外觀上什麼都沒變,簽章也毀了。同一套修訂版機制也正是一份文件如何承載多枚簽章的辦法:每一枚新簽章都落在它自己的增量更新裡,而它的區間涵蓋它之前的一切,包括先前那些簽章。這套唯附加的機制,以及什麼時候可以安全地把它們壓實,涵蓋於 物件串流與增量更新那篇文章

設計時有兩條界線值得放在心上。HotPDF 的 PDF/A 輸出模式直接拒收簽章欄位,所以封存合規與內嵌簽章必須以分開的檔案出貨。而簽署對保密什麼都沒說:它證明的是誰產出了一份文件、以及它自那時起未曾變更,但任何人仍然讀得到它。遮住內容是另一項工作,由 AES-256 加密與權限政策負責

不論您做出什麼,都請用寫出這個檔案的程式碼以外的東西去測試它。在 Acrobat 的簽章面板裡開啟輸出,並確認三件事:簽章有效、身分串鏈到您預期的根憑證,以及面板回報自簽署以來沒有變更。然後在一份用完即丟的副本上,把已簽署區間裡的某一個位元組翻掉,並確認面板現在說這份文件被更動過。一條您從未親眼看它拒絕過一個被竄改檔案的簽署管線,它的驗證其實還沒真正被測試過

三個簽署層級都隨給 Delphi 與 C++Builder 的 HotPDF Delphi Component 一同出貨;產品頁連結了完整的簽章 API 參考