技術文章

在 Delphi 中使用 HotPDF 驗證 PDF 數位簽章

HotPDF 透過 THotPDF 的三個方法(於 v2.259.0 引入)來驗證已載入 PDF 文件中的數位簽章:GetLoadedSignatureInfoVerifyLoadedSignature 以及 VerifyLoadedSignatureEx。此元件會對原始檔案的 /ByteRange 區段進行重新雜湊 (re-hash),檢查 CMS 的 messageDigest 屬性,並對內嵌的簽署者憑證執行 RSA PKCS#1 v1.5 驗證,當文件位元組完好無缺時,會傳回 svValid

這個情境非常日常,但其風險卻不容小覷。當交易對手送回一份已簽署的合約,您的工作流程需要將其歸檔,此時有人問了唯一重要的問題:這份文件是不是我們當初寄出的那份,逐位元組比對都正確,而且確實是由它所宣稱的憑證所簽署的嗎?用程式碼回答這個問題,就是簽章故事中「驗證」的一面;而「簽署」的一面,也就是一開始建立並內嵌 PAdES 簽章的部分,則涵蓋在使用 HotPDF 建立 PAdES 數位簽章的姊妹文章中。本文探討的是另一個方向:收到一份已經簽署的 PDF,而您需要一個程式化的判斷結果,而不僅僅是 Acrobat 綠色打勾標記的螢幕截圖

已簽署的 PDF 如何證明自己未被竄改?

PDF 簽章保護的是檔案中特定的位元組範圍,而不是一個抽象的「文件」概念。ISO 32000-1 §12.8 定義了這個機制:簽章表單欄位帶有一個字典,其 /Contents 條目包含一個 CMS SignedData 容器 (RFC 5652),而其 /ByteRange 陣列則精確指出了簽章所涵蓋的檔案區域(依據 §12.8.1)。該陣列是一個包含偏移量與長度對的清單,在實務上通常分為兩個區段:/Contents 十六進位字串之前的所有內容,以及其後的所有內容。簽章值無法涵蓋它自己,因此檔案會繞過這個「洞」來進行雜湊計算

這個設計帶來了一個影響整個 API 的結果:驗證必須對原始序列化的位元組進行雜湊計算,也就是它們存在磁碟上的原始樣貌。已解析的物件模型對此毫無用處,因為即便是未經修改的文件,只要重新序列化就會產生不同的位元組。因此,HotPDF 驗證的對象是載入文件時的來源檔案,或是您提供的一串原始位元組(TStream),而絕不是其記憶體中的表示形式

在驗證任何東西之前先讀取簽章詮釋資料 (Metadata)

GetLoadedSignatureInfo 會解析簽章字典及其 CMS 容器,而不觸碰任何一個文件位元組,這使得它成為您只需要顯示是誰在何時簽署時,最正確的首選呼叫。簽章欄位依據表單欄位順序從 0 開始索引,而 GetLoadedSignatureFieldCount 能告訴您有多少個簽章存在。傳回的 THPDFSignatureInfo 記錄帶有欄位名稱、/SubFilter、簽署者憑證的通用名稱 (Common Name)、主體與簽發者辨識名稱 (Distinguished Names)、序號、有效日期、簽署時間(若存在則來自已簽署的屬性,否則來自字典的 /M 條目)、摘要演算法名稱,以及 /Reason/Location/ContactInfo 字串。其 Status 成員會維持在 svNotVerified,這是對「已解析,但未檢查」最誠實的標籤

var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('signed-contract.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Info := Pdf.GetLoadedSignatureInfo(I);
      Writeln('Field:     ', Info.FieldName);
      Writeln('Signer:    ', Info.SignerName);
      Writeln('Issuer:    ', Info.IssuerDN);
      Writeln('Algorithm: ', Info.HashAlgorithm);
      Writeln('SubFilter: ', Info.SubFilter);
    end;
  finally
    Pdf.Free;
  end;
end;

執行密碼學檢查

VerifyLoadedSignatureEx 透過單一呼叫,針對檔案載入的文件執行完整的驗證,並傳回填妥資訊的記錄:它會重新開啟來源檔案,使用 SignerInfo 摘要演算法對 /ByteRange 區段進行雜湊,將結果與 messageDigest 已簽署屬性 (RFC 5652 §5.4) 進行對比,接著對已簽署屬性重新編碼的 DER SET 執行 RSA 驗證。當簽章未帶有已簽署的屬性時,RSA 檢查會直接對文件雜湊進行運算。支援的簽章包括 RSA PKCS#1 v1.5 搭配 SHA-1、SHA-256、SHA-384 或 SHA-512 摘要,這涵蓋了主流簽署工具所產生的 adbe.pkcs7.detachedETSI.CAdES.detached 子過濾器 (subfilter)

var
  Status: THPDFSignatureVerifyStatus;
  Info: THPDFSignatureInfo;
begin
  Status := Pdf.VerifyLoadedSignatureEx(0, Info);
  case Status of
    svValid:
      if Info.CoversWholeDocument then
        Writeln('Valid; signature covers the whole file')
      else
        Writeln('Valid; file was extended after signing');
    svDigestMismatch:
      Writeln('Document bytes changed after signing');
    svSignatureInvalid:
      Writeln('RSA check failed over signed attributes');
    svUnsupportedAlgorithm:
      Writeln('Non-RSA key or unknown digest algorithm');
    svMalformed:
      Writeln('CMS container could not be parsed');
    svSourceUnavailable:
      Writeln('No source bytes; use the TStream overload');
  end;
end;

有兩個實作細節值得了解,因為它們解釋了從外部看起來可能很神祕的失敗。首先,已簽署屬性的檢查對編碼非常挑剔:在檔案內部,這些屬性被標記為 [0] IMPLICIT,但簽章是在它們的 DER SET OF 形式上計算出來的,因此驗證器在進行雜湊之前會重新標記 (re-tag),這完全符合 RFC 5652 §5.4 的要求。如果一個自行編寫的驗證器直接將檔案中出現的位元組進行雜湊,它將會拒絕每一份正確簽署的文件。其次,/Contents 習慣上會填補零以達到預留的位元組預算,因此驗證器在解析之前,會將 DER Blob 截斷到其外部 SEQUENCE 的實際長度;看起來像垃圾資料的尾隨零是正常的,並不是資料損壞。在憑證匯入端,同樣的 ASN.1 解析風險系列主題,則探討在這篇關於 HotPDF 中 PKCS#12 與 ASN.1 安全強化的文章

一個有效的簽章到底保證了什麼?

svValid 的意義非常明確:由 /ByteRange 指定的位元組雜湊結果,與簽署者當初簽署的值相符,而且該簽章能在 CMS 容器中內嵌的憑證公鑰下驗證通過。這代表的是位元組完整性加上金鑰綁定,僅此而已。憑證鏈與信任驗證被明確排除在 HotPDF 驗證器的範圍之外:它不會沿著憑證鏈往上追溯至根憑證、不檢查憑證撤銷狀態,也不會查閱任何信任存放區。如果是攻擊者用自簽憑證對修改過的文件重新簽署,驗證結果也會是 svValid,因為在數學上它是內部一致的。該簽署者是否真的是他們所宣稱的身分,以及是否有人應該信任他們,這是一個政策決策,屬於另一個獨立的層級——無論那是您組織的憑證白名單、Windows 憑證存放區,還是一個驗證授權單位 (Validation Authority)

CoversWholeDocument 旗標守護著一個更微妙的漏洞。一個簽章永遠只涵蓋它的 /ByteRange,而 PDF 的增量更新機制允許在簽章之後附加內容而不使其失效,這是刻意設計的,也是多重簽章工作流程的運作基礎。這個旗標是在驗證期間計算的,並且只有當那兩個區段加上 /Contents 的空隙跨越了整個檔案時,才會為 true。當傳回 svValidCoversWholeDocument 為 false 時,代表已簽署的修訂版完好無缺,但檔案包含後續的附加內容,而這些附加內容改變了什麼,就是您的工作流程該決定是否要容忍的事情了

從串流載入與加密的文件需要它們自己的來源位元組

不帶參數的 VerifyLoadedSignatureVerifyLoadedSignatureEx 仰賴元件記住文件來自哪個檔案。如果是從串流載入文件,就沒有檔案名稱可以重新開啟;同樣的情況也適用於加密文件使用的密碼重新載入路徑,也就是在這篇關於 HotPDF AES-256 PDF 加密的文章中所描述的工作流程。在這兩種情況下,基於檔案的多載 (overload) 版本會傳回 svSourceUnavailable,而不是去胡亂猜測。解決方法是使用 TStream 多載版本,這讓您可以將原始位元組從您當初保存它們的地方(例如您手上仍保留的檔案、記憶體緩衝區或資料庫 Blob)交給它

var
  Src: TFileStream;
  Status: THPDFSignatureVerifyStatus;
  Info: THPDFSignatureInfo;
begin
  // 從串流載入的文件:元件沒有保留來源檔案名稱,
  // 因此您需要自己提供原始的位元組。
  Src := TFileStream.Create('signed-contract.pdf',
    fmOpenRead or fmShareDenyWrite);
  try
    Status := Pdf.VerifyLoadedSignature(0, Src, Info);
    if Status <> svValid then
      Writeln('Verification failed: ', Ord(Status));
  finally
    Src.Free;
  end;
end;

回報您無法驗證的內容

一個只懂「有效 (valid)」與「無效 (invalid)」的驗證器,將會錯誤回報那些它單純只是無法理解的文件,因此狀態列舉 (status enumeration) 區分了您的使用者介面 (UI) 應該辨別的各種情況。svDigestMismatch 意味著文件位元組在簽署後被更改了,這是典型的竄改訊號。svSignatureInvalid 意味著位元組雜湊正確,但 RSA 檢查失敗了,這指向一個損壞或偽造的簽章值。對於 ECDSA 金鑰與無法辨識的摘要,svUnsupportedAlgorithm 是一個誠實的回答:這個簽章可能完美無瑕,HotPDF 只是無法檢查它,將其回報為「無效」等同於誹謗一份健康的文件。svMalformed 標記了一個根本無法被解析的 CMS 容器。對於閘門式 (gate-style) 的檢查,VerifyAllLoadedSignatures 只有在至少存在一個簽章欄位,且每一個欄位都驗證為 svValid 時才會回傳 true,這對於拒絕接受任何瑕疵的封存擷取管線來說,是一個相當方便的單一布林值

簽章驗證、PAdES 簽署、AES-256 加密,以及已載入文件的編輯 API,全都在同一個適用於 Delphi 與 C++Builder 的原生 VCL 程式庫中提供,且沒有外部 DLL 相依性;完整的技術規格與支援的 IDE 版本詳見於 HotPDF Component 產品頁面