技術文章

為什麼驗證器會拒絕 PAdES 簽章:Delphi 中的 PDFium

在我們使用 Delphi 中的 PDFium 元件所偵錯的幾乎每一個案例中,驗證器拒絕 PAdES 簽章的原因通常是以下三者之一:/ByteRange 陣列仍保留其零佔位符、/M 簽署時間不是格式正確的 PDF 日期字串,或者是增量更新從加密文件中刪除了 /Encrypt 項目。這三種情況產生的檔案在開啟和轉譯時都能正常運作,但當合規的驗證器讀取簽章字典時,就會立即失敗

引發本文撰寫的情境非常具體。您使用PAdES B-B 簽署文章中的工作流程簽署了一份合約,您自己的檢查程式碼回報簽章存在且結構完好,每個本機測試都是綠燈 —— 然後交易對手將檔案上傳到他們的驗證平台,卻得到了一個紅色的叉叉。在檢視器中完全看不到此失效,因為這三個缺陷都未接觸到頁面內容。它們完全存在於簽章字典和檔案尾部(trailer)中,這正是驗證器會去查看而檢視器大多不看的地方

為什麼我的 PDF 簽章 ByteRange 無效?

ByteRange 被拒絕幾乎總是意味著這四個欄位從未被填入,而不是範圍有微小的錯誤。/ByteRange [A B C D] 陣列宣告了兩個區間,即位元組 AA+B 以及位元組 CC+D,且 EN 319 142-1 §6.3(要求 k)要求它們共同覆蓋除十六進位編碼的 /Contents 字串之外的整個檔案。用數字表示:A 是 0,C >= A+BC+D 等於檔案長度,且 BC 之間的間隙剛好包含 <...> 十六進位字串 —— 即兩個括號位元組加上每個位元組 CMS 資料對應的兩個十六進位字元。讀取到 [0 0 0 0] 的驗證器會判定該簽章沒有覆蓋任何內容並拒絕它,不論 /Contents 內部的 CMS 結構有多正確

其發生機制的細節值得去瞭解,因為每個簽署堆疊(signing stack)中都存在相同的模式。簽署者在檔案排版(layout)完成之前無法得知最終的偏移量,因此寫入器會輸出帶有固定寬度零佔位符的陣列,並在排版完成後回填它們。在 2.14.1 版本之前的 PDFium 元件中,該回填從 /Contents 位置開始搜尋佔位符模式 —— 但 /ByteRange 在字典中位於 /Contents 之前,因此搜尋一無所獲,且所有三個替換都默默地失敗了。CMS 摘要是在正確的範圍上計算的,因此密碼學上是健全的;但這些範圍的宣告保持為零,因此每個合規的驗證器都拒絕了該檔案。使用相同字典排版的 PAdES B-LTA 文件時間戳記也以同樣的方式失敗 —— 如果您是建置在長期 B-LT 和 B-LTA 簽章上,這點將會非常重要。2.14.1 版本改為從簽章物件的起點進行回填,且此修正已由一個迴歸測試所涵蓋,該測試會解析產生的檔案並對底下的算術運算進行斷言

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

二十行簡單的 RTL 程式碼,無需呼叫程式庫,即可在產出時擷取到整個失效類別。如果您要保留本文中的一個斷言,請保留這一個:在檔案離開您的處理程序之前,解析您自己簽署的輸出並檢查這三個等式

為什麼驗證器會將 /M 簽署時間標記為格式錯誤?

嚴格的驗證器會拒絕非完整 PDF 日期字串的簽署時間,而最常缺失的兩個部分是 D: 前綴和 UTC 偏移量標記。ISO 32000-1 §7.9.4 將日期格式定義為 D:YYYYMMDDHHmmSS 後跟偏移量 —— Z 代表 UTC,或者與其呈正負 +HH'mm' 的關係。對於寬容的讀取器而言,單純的 20260709143000 會被解析為日期,但字面套用此文法的驗證器會將其視為強制格式欄位中的格式錯誤字串,並標記該簽章。在 2.14.4 版本之前的 PDFium 元件會以完全那種單純的形式寫入 TPdf.SignPades and SignPadesBytes/M 項目;自 2.14.4 起,該項目會攜帶 D: 前綴和 Z 標記,因此宣告的簽署時間為 D:20260709143000Z

此欄位有兩點實用說明。首先,寫入 UTC 並明確表示:沒有偏移量標記的時間戳記會迫使驗證器去猜測時區關係,而 §7.9.4 將偏移量視為格式的一部分,而非可有可無的點綴。其次,記住 /M 是什麼 —— 這是簽署者宣告的時間,是一項聲明而非證明。驗證器在此處檢查的是其格式,而非其真實性;可證明的時間來自 PAdES B-T 及以上層級的 RFC 3161 時間戳記。正確格式化欄位與信任該欄位是不同的決定,而驗證器僅強制執行前者

為什麼 SignPades 會在加密的 PDF 上引發 EPadesCrypto 例外?

PDFium 元件拒絕簽署加密文件,因為另一種選擇是產生一個合規讀取器在開啟時會毀壞的檔案。PAdES 簽章是以增量更新的方式附加的,且 ISO 32000-1 §7.5.6 要求更新區段的新 trailer 攜帶前一個 trailer 的每個項目(除了 /Prev)—— 在加密文件中這包括 /Encrypt。丟棄它,最新的 trailer 就會宣告該檔案未加密,從而使合規的讀取器將加密主體解析為純文字並得到垃圾。更糟的是,簽署者附加的串流和字串本身必須使用文件金鑰進行加密才合法,而純文字簽章注入器無法做到這一點。沒有辦法在加密檔案中附加有效的純文字簽章,因此自 2.14.2 版本起,TPdf.SignPadesSignPadesBytesInjectPadesDssMarkers 會引發 EPadesCrypto,而不是輸出已損壞或無法驗證的結果

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

例外是正確的結果,因此請圍繞它設計工作流程,而不是捕獲例外並重試。先以擁有者權限進行解密,簽署純文字複本,且若分發管道需要加密,請接受「先加密後簽署」與「先簽署後加密」會產生具有不同驗證細節的不同成品。在純文字位元組上計算的簽章無法在這些位元組重新加密後存活,因此誠實的選擇是已簽署的純文字檔案,或是記錄在程式碼旁邊的策略決定

兩個 Bug 如何互相證明

ByteRange 缺陷隱藏了這麼久,是因為我們自己的驗證器在互補的方面也出錯了,這是本文中最具借鑑意義的教訓。ValidatePadesCompliance 中的覆蓋率檢查要求第二個區間正好從 A+B 開始(即零間隙)—— 這誤判了間隙包含 /Contents 十六進位字串的標準排版。因此,內部驗證器恰好拒絕了合規的排版,而內部產生器也從未產出過合規的排版,這使得端到端的「簽署後驗證」管線剛好保持綠燈。每個 Bug 都使測試套件失去了能揭露另一個 Bug 的反例。2.14.1 版本在同一個發行版中修正了雙方:產生器會回填所有四個欄位,且驗證器接受 C >= A+BC+D 等於檔案長度

方法論上的修正指針對非您所寫的實作進行交叉驗證。共用程式碼庫、作者甚至僅是對格式擁有相同心智模型的產生器 and 驗證器,可能會無限期地在共用的誤解上達成一致;而獨立的驗證器則打破了這種對稱性。在發布之前,將您簽署的輸出送經至少一個外部合規性檢查器,並在建置中保留結構性自我檢查作為快速的第一道防線 —— TPdf.ValidatePades(在簽章檢查文章中描述)會將覆蓋率失效回報為具名問題

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

簽署輸出的拒絕檢查清單

當平台拒絕您的 PAdES 簽章時,在懷疑憑證或信任鏈之前,請先檢查成本低廉的結構性原因。讀取 /ByteRange 陣列並驗證三個等式:第一個欄位為零、第二個區間起點在第一個區間終點或之後、第二個區間終點剛好在檔案結尾。讀取 /M 項目並驗證它是一個帶有 D: 前綴和偏移量標記的完整 §7.9.4 日期字串。確認附加簽章時來源文件未被加密 —— 且如果您的堆疊在沒有投訴的情況下仍然簽署了它,請將該沉默視為堆疊中的 Bug。所有三項檢查都能在數毫秒內於原始位元組上執行,且根據我們的經驗,它們解釋拒絕原因的頻率遠高於任何密碼學原因

此處使用的簽署、檢查和驗證呼叫 —— SignPadesValidatePades 和 PAdES 合規性檢查及其修正後的 ByteRange 算術、日期格式化和加密入口管控(encryption gating) —— 均隨附於適用於 Delphi、C++Builder 和 Lazarus 的 PDFium 元件