技術文章

分類 PDF 簽章之後發生的內容變更

對 PDF 的簽章並不禁止後續變更。它固定的是一個位元組範圍,而增量更新會在其後附加新位元組,所以簽章在數學上保持有效,文件卻獲得了新內容。這些內容能否接受是政策問題,而 DocMDP 正是作者聲明政策的地方:完全不允許變更、只允許表單填寫與簽署,或再加上註解。執行政策意味著分類實際改了什麼,這正是 AnalyzeModifications 所做的事。把它指向較早的版本,然後讀取 GetModificationLevel 得到整體判定,再用逐項發現的存取器讀取每個差異的層級、物件編號與描述

有了這些,DocMDP 執行就化約成一次比較:計算出的層級是否落在政策允許的層級或以下

PDFlibPas 修改層級階梯示意圖,從 mlNone 到 mlUnclassified,展示 Delphi 中 DocMDP 政策執行只需一次比較
TPLModificationLevel 階梯從 mlNone 排到 mlUnclassified,DocMDP 執行化約為把計算出的層級與政策比較

為什麼已簽章的 PDF 本來就會變

有三種正當情況,涵蓋您會見到的大多數場景。第二位簽署者加上自己的簽章。收件者填寫作者開放的表單欄位。以及長期驗證資料被附加進來:OCSP 回應與 CRL 寫入文件安全儲存庫,讓簽章在回應伺服器消失後仍可驗證。最後一項不只是被允許,而是管理良好的檔案館對已簽章文件刻意做的事

所以「檔案在簽章後變大了」不攜帶任何資訊。問題永遠是加了什麼,而答案必須來自比較文件狀態,而不是盯著位元組。附加機制本身在增量更新文章中涵蓋

按物件的形狀分類,而不是按產生它的路徑

分類器看的是變更後物件是什麼,而不是哪個程式庫呼叫建立了它。這是刻意的,因為分析要面對其他軟體產出的檔案,那裡沒有任何呼叫路徑可供檢視

有四種形狀會被辨識。文件安全儲存庫與驗證相關的資訊字典、交叉參照串流物件、catalog 的 metadata 項目,以及帶位元組範圍的簽章字典,屬於長期歸檔素材。同時帶欄位型別與欄位值的物件是表單填寫。型別為 annotation、或子型別屬於 ISO 32000-2 表 168 所列的物件,是註解變更。其餘一切都是未分類

PDFlibPas 對每個變更 PDF 物件套用的決策樹,把更新歸入歸檔、表單填寫、註解或未分類層級
每個變更物件按它是什麼分類——安全儲存庫、xref 串流、欄位、註解——永遠不按產生它的呼叫分類

移除比新增處理得更嚴格。只有當舊側的物件本身是歸檔素材時,被移除的物件才進白名單,這涵蓋安全儲存庫被較新版本替換的正常情況。其他所有移除都是未分類,因為從已簽章文件中刪除內容不是任何權限層級會授權的事。文件層級差異更嚴格:頁數變化直接判為未分類,不逐物件檢查,因為沒有任何 DocMDP 層級允許新增或移除頁面

白名單寧可誤拒

這是治理每個邊界決策的設計規則。被錯誤歸為允許的變更,是一份對作者從未授權的內容仍然驗證通過的簽章。被錯誤歸為未分類的變更,是一份會被標記並交由人工審閱的文件。這兩種錯誤不對稱,所以白名單保持狹窄,無法辨識的形狀直接落到未分類,而不是靠猜

這有一個值得預期的實際後果:來自罕見產生器的檔案,有時會報告出檢視後其實無害的未分類變更。正確的回應是查看發現細節與物件編號,而不是擴大白名單,因為一個為了讓個別報告閉嘴而不斷長大的白名單,就不再是安全控制了

uses
  PDFlibrary, PDFlibCompare;

var
  Pdf: TPDFlib;
  I, Level: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract-countersigned.pdf', '');
    if Pdf.AnalyzeModifications('contract-as-signed.pdf', '') < 0 then
      raise Exception.Create('the earlier revision could not be loaded');

    // TPLModificationLevel 依序為 mlNone、mlLTAUpdates、mlFormFilling、
    // mlAnnotations、mlUnclassified;getter 回傳其序數
    Level := Pdf.GetModificationLevel;
    // DocMDP 執行至此變成一次對政策的比較
    if Level > Ord(mlFormFilling) then
      for I := 0 to Pdf.GetModificationFindingCount - 1 do
        Report.Add(Format('object %d, level %d: %s',
          [Pdf.GetModificationFindingObjNum(I),
           Pdf.GetModificationFindingLevel(I),
           Pdf.GetModificationFindingDetail(I)]));
  finally
    Pdf.Free;
  end;
end;

整體層級是所有發現項的最大值,這是唯一站得住腳的聚合方式:一份包含九十九筆歸檔新增與一筆未分類變更的文件,就是一份有未分類變更的文件

底層:指紋,而不是密碼學雜湊

CompareWith 暴露、修改分析也構建其上的比較引擎,用一個非密碼學的 64 位元雜湊、以物件正規化本體的指紋來辨識物件,而不是 SHA-256。這是深思熟慮的選擇。結構比較需要的是決定性:同一個物件本體在一次執行內必須永遠產出相同指紋。它不需要抗碰撞,因為能同時控制比較兩側的攻擊者早已用其他手段得手,而對百萬物件的文件裡每個物件都付完整密碼學雜湊的成本是真實的,卻換不到任何好處

有兩條正規化規則比雜湊選擇更重要。間接參照摺疊成一個佔位符記號,而不是展開成被參照的內容:展開會把共享物件的本體複製進每個引用者,所以對共享字型描述器的一個小編輯,就會讓每個能到達它的物件指紋失效,報告也會變得不可讀。物件編號本身也被排除在指紋之外,因為重寫可以在不改變任何語意的情況下重新編號物件

配對接著分兩輪執行,先按指紋對齊,再把剩餘按物件編號配對,以識別出變更而不是一次新增加一次移除。便宜的檢查全場優先:頁數差異在任何物件遍歷開始之前就會被報告

PDFlibPas 的兩輪 PDF 版本差異比對:先檢查頁數,用 64 位元指紋,先指紋對齊再按物件編號配對
比較引擎對正規化的物件本體取指紋,先報告頁數差異,再按指紋與物件編號配對

一個陷阱:自我比較不保證完全相同

對差異引擎最自然的第一個測試,是把檔案與自身比較並斷言結果完全相同。這個斷言在這裡不成立,原因很有啟發性。公開載入路徑與較低階的文件載入路徑對解碼的設定不完全相同,所以同一個檔案經兩條路線載入,某些物件可能產出不同的指紋。引擎沒有錯;兩次載入確實產生了不同的記憶體內狀態

與其強行把兩條路徑拉齊,比較語意被狹義地陳述:分析比較的是目前文件狀態與較早版本,只有當兩組指紋集合完全一致時才報告相同。這才是使用者真正在問的問題,而且它不要求兩個載入器可以互換。當您在設計比較功能時,定義「相同」是什麼意思,比計算它更花功夫

用在哪裡

兩個地方。在驗證報告裡,與簽章檢查並列,讓審閱者不只看到簽章在密碼學上是否完好,還看到文件之後發生了什麼;簽章那一側在PAdES 簽章與驗證中涵蓋。以及在進件閘門裡,對外來文件與您寄出的副本比對,讓帶著新增註解退回的合約,與帶著被編輯頁面退回的合約受到不同對待

關於範圍有一個提醒。這個分析告訴您同一文件譜系的兩個版本之間改了什麼。它不告訴您可見內容是否具有誤導性、表單欄位的外觀串流是否與其值相符,或被覆蓋層遮住的文字是否仍存在於內容串流中。這些需要另外處理,其中內容移除的那一面在真正的塗銷文章中涵蓋。分析與比較進入點記錄於 losLab PDF Developer Library 產品頁