技術文章

在 Delphi 中驗證 PDF MAC 修訂鏈(ISO 32004)

HotPDF 對 ISO/TS 32004 PDF MAC 的驗證是逐修訂、而非逐檔案進行。THotPDF.ValidatePDFMACChain 從鏈錨點開始向前走遍每一次增量更新,並針對以該修訂自己的 startxref%%EOF 結尾的唯讀前綴串流驗證每個 MAC。最新修訂上有一個有效的 MAC,對它底下的修訂什麼也證明不了

先說明觸發這一切的場景。您交付了一個加了 PDF MAC 的 AES-256 加密 PDF。有人用十六進位編輯器開啟檔案,翻轉第一個受 MAC 保護修訂裡的一個位元組,然後附加一個全新的修訂,帶著他自己算出的完全有效的 MAC。每個檢視器都毫無怨言地開啟檔案,而一個天真的檢查器——只用現行位元組範圍對作用中尾部裡的 MAC 做雜湊——回報成功,因為那個 MAC 對它涵蓋的位元組確實是正確的。破壞位於往下數兩個修訂之處,一個沒人重新檢查的區域

為什麼頂層 MAC 有效不能證明檔案完好?

因為 PDF MAC 涵蓋的是一段前綴,而不是整份文件。增量更新是此格式的頭等公民:每次儲存都會附加新的主體、新的交叉參照區段與新的尾部,而舊位元組原封不動留在原地。ISO/TS 32004 建立在這個模型上,所以每個修訂都帶著自己的 /AuthCode 字典,用來認證該時刻的檔案狀態;只驗證最新的那一個,等於讓所有更早的修訂完全不受檢驗。因此 HotPDF 把兩個問題拆成兩個呼叫,而它們之間的差異正是本文的重點。ValidatePDFMAC 回答「目前修訂是否可信」,並填寫 THPDFPDFMACValidationInfo 記錄;ValidatePDFMACChain 回答「這個檔案裡每個受 MAC 保護的修訂是否都可信」,並把逐修訂陣列加上機器可讀的失敗原因填進 THPDFPDFMACChainValidationInfo。對上面那個被竄改後重新加 MAC 的檔案,第一個呼叫回傳 True,第二個在修訂索引 1 上回傳 False

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // 失敗原因為 pmcfRevisionBoundary、pmcfNoPDFMAC、
      // pmcfRequiredRevisionMissing、pmcfRevisionInvalid、
      // pmcfKDFSaltChanged、pmcfDigestDowngrade、pmcfPermissionDowngrade 之一
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

每個 MAC 都在自己的前綴串流上驗證,絕不用檔案最終長度

這個領域代價最高的 bug,是在重算舊修訂的雜湊時拿檔案最終大小當上界——這會把尾端多餘的位元組摺進最新修訂以外的每個摘要裡,把完好的檔案誤判為遭竄改。HotPDF 的做法是為每個修訂重建一段有邊界的唯讀串流,結尾停在該修訂自己的 startxref 值加上隨後的 %%EOF,只對這段做雜湊。定位邊界比看起來更講究:%%EOF 字面量可能出現在內容串流或字串內,所以只有當緊鄰的前一個 startxref 可解析為一個數字、且等於受驗證區段的交叉參照偏移、兩者之間只有空白字元時,候選位置才被接受。該修訂在標記之後恰好吸收一個行尾序列——單個 CR、單個 LF 或一對 CRLF——不多不少。最後這條規則在實務上會咬人,因為如果某個寫入器在兩個修訂之間多輸出一個空行,這些位元組屬於下一個修訂;把所有尾隨空白吞進前一個修訂,會悄悄改變兩邊的摘要。區段列舉遵循同樣的紀律:HotPDF 把交叉參照區段從最舊到最新恰好走一遍,重放 free、direct 與物件串流條目,讓後面的區段覆寫先前的狀態——這與作用中 xref 解析器「先見為準」的語義正好相反

HotPDF 針對以該修訂自己的 startxref 與檔案結尾標記結束的前綴串流驗證每個 ISO 32004 PDF MAC,因此即使最新的 MAC 仍然驗證通過,在修訂 1 內翻轉一個位元組仍會讓整條鏈失敗
每個修訂的 MAC 都在自己的有界前綴上重新雜湊,所以編輯修訂 1 再附加一個重新加 MAC 的修訂仍能通過 ValidatePDFMAC,而 ValidatePDFMACChain 會停在修訂 1

鏈在哪裡錨定,什麼會讓它斷裂?

第一個帶有有效 /AuthCode 的修訂是錨點,FirstMACRevisionIndex 回報保護從哪裡開始;錨點之前的內容按定義不受保護,這屬正常。錨點之後的所有內容都必須受 MAC 保護,所以對一個受 MAC 保護的檔案再附加一次普通增量更新會以 pmcfRequiredRevisionMissing 失敗,並帶上肇事修訂的索引——容忍缺口會讓攻擊者只要再儲存一次就能剝除保護。另有三條不變式橫跨整條鏈,各自對應專屬的失敗碼

  • pmcfKDFSaltChanged——從錨點起 /KDFSalt 必須保持穩定,因為旋轉的鹽值會讓偽造者用自選的參數重新推導金鑰
  • pmcfDigestDowngrade——摘要強度是與最後一個已驗證的 MAC 比較,而不是與緊鄰的前一個修訂比較,因此在 Modern 設定檔下以 SHA-384 開始的鏈不能悄悄改用 SHA-256 繼續
  • pmcfPermissionDowngrade——修訂不得清除先前修訂已認證的 PDF MAC 要求

值得內化的結論是:歷史 MAC 即使不再是作用中的尾部,仍會被獨立驗證。這就是為什麼開頭那個「編輯舊修訂再附加新 MAC」的攻擊活不下來:最新的 MAC 自身檢查通過,ValidatePDFMAC 很滿意,而整條鏈仍然帶著 pmcfRevisionInvalid 停在修訂 1

簽章順序:先寫尾部鍵,signatureDigest 最後

當 MAC 附著在 CMS 簽章上而非獨立存在時,寫入順序就不再是風格問題。HotPDF 要求 /AuthCode/KDFSalt、ISO 32004 開發者擴充與 /SigObjRef 必須在計算簽章 /ByteRange 之前寫進同一個修訂;之後才追加其中任何一個,那些位元組就落在簽章涵蓋範圍之外,產生一個簽章可驗證、MAC 繫結卻未簽的檔案。接著兩個摘要反向運作,乍看像循環依賴,其實不是。PDF MAC 的 signatureDigest 繫結的是 CMS SignerInfo.signature OCTET STRING 的原始內容八位組——不是整個 CMS DER,也不是已簽屬性——所以它必須在原始簽章值存在之後才建構,並以 id-attr-pdfMacData 未簽屬性的形式注入。由於 /Contents 被排除在簽章 ByteRange 之外,而未簽屬性從不進入簽章計算,「產生簽章、建構 MAC、包裝 CMS」這個序列乾淨收尾,沒有任何密碼學迴圈。由此得到兩個推論:/ByteRange 哨兵與 /Contents 佔位符即使在加密檔案裡也必須保持明文且位於物件串流之外,否則定寬修補器找不到它們;當 MAC 摘要同樣是 SHA-256 時直接重用簽章摘要,否則兩個摘要內容在輸出串流的單次走訪中同步更新

HotPDF 對附著於 CMS 簽章的 PDF MAC 的寫入順序:MAC 鍵在量測 ByteRange 之前進入修訂,簽章摘要則在之後由 SignerInfo 原始簽章八位組建構
在量測 ByteRange 之前寫入 AuthCode、KDFSalt、SigObjRef 與開發者擴充,正是讓 MAC 繫結留在簽章涵蓋範圍內的關鍵
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // SHA-384 文件摘要
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature,且兩個摘要
        // 分開回報
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

驗證從另一端重走同一條路:從目前作用中的傳統交叉參照尾部讀取直接 /AuthCode,跟隨感知世代的間接 /SigObjRef,確認它繫結的是那一個簽章欄位的 /V,並把文件摘要失敗與簽章摘要失敗分開回報。那是兩種不同的診斷,把它們壓成單一布林值會丟掉唯一能說明是頁面內容還是簽章值被動過手腳的資訊。如果您已經在做 CMS 相關工作,這與 PAdES 簽章文章以及在已載入文件中驗證簽章的指南相輔相成

永遠別信 /P:先解密 16 位元組的 /Perms

ISO/TS 32004 透過權限位元 13 表達「本文件要求 PDF MAC」,而直觀的讀法恰恰是錯的,因為加密字典裡的 /P 整數是未經認證的明文——任何人都能在文字編輯器裡翻轉那個位元,把要求降級。ISO 32000-2 §7.6 在 /Perms 條目裡給出了答案,HotPDF 採用它:用檔案加密金鑰以 AES-256 CBC、零 IV、無填充解密 16 位元組的 /Perms 字串,然後在相信任何內容之前檢查明文的每個欄位。位元組 1 到 4 以小端序保存權限值,必須與 /P 整數完全一致;位元組 5 到 8 是 0xFF;位元組 9 是 TF 的中繼資料加密旗標;位元組 10 到 12 是字面標記 adb。只有這些全部成立,PermissionsAuthenticated 才變為 True,位元 13 才被讀取——還要留意它的極性,因為 MAC 要求是在 0x1000 位元為清除時成立。/P 與解密後權限不一致不是記錄下來就能放行的警告;那是一組偽造的權限,正確的回應是失敗即關閉

HotPDF 透過用檔案加密金鑰解密十六位元組的 Perms 字串,並在讀取位元 13 之前檢查小端權限值、FF 填充位元組、中繼資料旗標與 adb 標記,來認證 PDF 權限
明文 /P 整數未經認證,所以只有在解密後的 /Perms 每個欄位都檢查通過之後,才讀取 PDF MAC 要求

演算法敏捷性到摘要為止

ISO/TS 32004 讓您選擇文件摘要,也只能選文件摘要。HotPDF 固定保留 HMAC-SHA-256 做認證、依 RFC 5869 的 HKDF-SHA-256 做金鑰推導、依 RFC 3394 的 AES-256 金鑰包裝,底下才是可變的 THPDFPDFMACDigestAlgorithm,範圍從 pmdaSHA256pmdaSHA3_512——因為最常見的誤解是把「SHA3-512 設定檔」當成連 HMAC 也一起換掉的許可,那會產生一個在任何互通意義上都不再是 PDF MAC 的檔案。如果您要自己寫驗證器,有一個實作細節值得照抄:在對位元組範圍做雜湊之前先從 CMS AuthenticatedData 讀出摘要 OID,因為硬編碼 SHA-256、事後再對帳,會讓敏捷性淪為標籤,還讓惡意檔案騙您把整份文件串流完才發現該演算法從未受支援。CMSAlgorithmProtectionAuthenticatedData 摘要演算法、完整性資訊的 messageDigest 與位元組範圍摘要必須指向同一個演算法,任何不一致都失敗即關閉

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256,接受全部六種
  Options := THPDFPDFMACOptions.Modern;         // SHA-384,拒絕 256 位元
  Options := THPDFPDFMACOptions.HighAssurance;  // 僅 SHA3-512,AES-GCM

  // 自訂設定檔是合法的,但它產生時所用的演算法
  // 也必須出現在驗證允許清單中,否則該設定
  // 在寫出任何一個位元組之前就會被拒絕
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

PDF MAC 能證明什麼、不能證明什麼

一條驗證通過的 PDF MAC 鏈證明:每個受保護修訂與持有檔案加密金鑰的人當初寫出的內容逐位元組一致、沒有任何受保護修訂被移除或重排、錨點之後沒有被附加任何未受保護的修訂——這正是單純 AES-256 加密敞開不防的那類攻擊,因為機密性對完整性隻字未提,一個被拼接過修訂的加密 PDF 解密起來和完好的檔案一樣順暢。它不能證明的是作者身分。MAC 金鑰衍生自檔案加密金鑰,所以任何能開啟文件的人——包括每個合法收件者——都能為修改過的版本產生有效的 MAC;它是對稱式原語,而對稱式原語無法歸因。如果您需要知道是改了東西,您需要的是背後有憑證的數位簽章,PDF MAC 則補足簽章單獨無法涵蓋的增量結構保護。把兩者當成分層,讓兩個結論各自獨立回報,而不是壓成單一狀態圖示

本文介紹的 PDF MAC 進入點——AddStandalonePDFMACSignPDFWithPFXAndAttachedPDFMACValidatePDFMACValidatePDFMACChain——隨標準版 HotPDF Delphi Component(適用於 Delphi 與 C++Builder)提供,產品頁上有選項記錄、狀態列舉與逐修訂驗證陣列的完整參考