技術文章

DocMDP 與 FieldMDP:Delphi 中稽核 PDF 修訂版

一份簽署後有變動的 PDF,不代表它就一定壞了。ISO 32000-1 允許在簽章之上疊加增量更新,只有其中一部分會違反簽署者設定的政策。Delphi 與 C++Builder 版的 HotPDF Component 用 AnalyzeLoadedSignatureRevisions 回答這個問題,它會分類每一個簽署後的修訂版,並依 DocMDP 與 FieldMDP 評分。這個情境對任何做合約軟體的人都不陌生:客戶簽署一份採購合約、寄出去,收回來時多附了一頁附錄。閱讀器顯示一條黃色橫幅,說簽章完好,但文件自簽署後已變更,會議室裡沒人說得出這究竟是正常的會簽流程,還是有人悄悄編輯了一份已簽署的合約

簽署後怎樣的變更算合法?

當一項變更的語意類別落在簽署簽章所宣告的權限之內,它就是合法的。ISO 32000-1 §12.8.2.2 定義了 DocMDP 轉換,/P 值可以是 1、2 或 3:1 完全不允許任何變更,2 允許填表與簽署,3 允許填表、簽署與加註解。HotPDF 把這些對應成 THPDFDocMDPPermission 的值 dmpNoChangesdmpFormFillAndSigndmpFormFillSignAndAnnotate,並保留 dmpNone 給不帶任何 DocMDP 轉換的檢查結果

這些分類是有順序的,而這個順序正是整套檢查機制的引擎。THPDFRevisionModificationLevel 依序是 rmlNonermlLongTermValidationrmlFormFillAndSignrmlAnnotationsrmlOther,刻意安排成序數越大絕不代表限制越少。整份文件會歸約成簽署之後每個修訂版中觀察到的最大等級,而 DocMDP 的比對就變成一次單純的整數測試。有一個細節值得早點提出:在 dmpNoChanges 之下,分析仍然接受 rmlLongTermValidation。對一份已認證的檔案新增 DSS 與 VRI 驗證素材,或加上文件時間戳記,屬於簽章維護,而非文件修改,若把這種情況視為違規,會破壞現存的每一套長期封存流程

HotPDF 如何重建修訂版鏈?

是靠結構,不是靠經驗法則。依照 ISO 32000-1 §7.5.6,一次增量更新會附加一個新的交叉參照區段,其 /Prev 指向前一個區段,所以 HotPDF 從檔案尾端讀取 startxref,剖析那裡的區段,沿著 /Prev 往回走,重複這個過程,最後依由舊到新的順序回傳這些區段。這個迴圈裡有兩道安全限制,在排查失敗檔案時都值得知道:一個 /Prev 若指向已經走訪過的偏移量,會以明確的循環診斷結束整個走訪,而不是原地打轉;一條長度超過一千個修訂版的鏈會被直接拒絕。兩者都會在 Analysis.Issue 裡呈現,函式回傳 False,而且都不該被輕輕帶過,因為一個循環的 /Prev 是一份格式錯誤或帶惡意的檔案,不是一份不尋常的檔案

真實文件中會出現四種歷史形態,全都有被處理:逐行剖析的傳統 xref 表、透過 /W/Index 欄位解壓與解碼的交叉參照串流、混合參照檔案(其傳統 trailer 帶有 /XRefStm 鍵,會被剖析並併入同一個修訂版,也就是 Office 產生器的情況,涵蓋於混合交叉參照串流一文),以及存放在 ObjStm 容器內的物件,這些之所以重要,是因為現代更新通常會把改變過的字典放進壓縮串流,而非直接寫出,如物件串流與增量更新一文所述。簽章是拆分的錨點:/ByteRange[2] + /ByteRange[3] 成為 SignedRevisionLength,任何位於此偏移量或之後的區段都屬於簽署之後。至於位元組範圍是否依然雜湊正確,是另一個問題,由 VerifyLoadedSignature 回答,涵蓋於驗證 PDF 數位簽章一文

每個變更過的物件如何被分類?

分類是逐一物件進行,然後沿著參照傳播。針對簽署後區段動到的每個物件編號,HotPDF 讀取新的內容,也讀取它在簽署快照裡原本的內容;內容相同就是 rmlNone,因為產生器確實會在不變更內容的情況下重寫物件。辨識器刻意設計得很窄。一個 /Type /DocTimeStamp 物件,或 /SubFilterETSI.RFC3161 的物件,屬於 rmlLongTermValidation,凡是能從目錄的 /DSS 樹走到的物件也是;一個 /Type /Sig 字典屬於 rmlFormFillAndSign。對容器而言,檢查的是哪些鍵動過,而不是這個物件是什麼:目錄只能新增或改動 /DSS/Extensions/AcroForm;AcroForm 字典只能改動 /Fields/SigFlags/NeedAppearances/DR/DA/Q;一頁只能改動 /Annots;一個欄位或小工具只能改動 /V/AP/AS/M。落在這些集合之外的一律降為 rmlOther,這正是附加的附錄頁被抓到的方式:新增一頁會以任何白名單都涵蓋不到的方式重排頁樹,而任何正當的填表行為都不會與它相似

接著等級會傳播,每個容器繼承它指向的、已變更子項目中的最大等級,反覆迭代直到指派結果穩定。這正是外觀串流能運作的原因。一個被填寫的文字欄位重寫 /V 並指向一個嶄新的 /AP 串流,而那個串流本身只是一團無法辨識的匿名內容運算子,沒有型別可供辨識;因為擁有它的欄位是 rmlFormFillAndSign,該串流就會繼承同一個等級,而不是落入 rmlOther。同樣的傳播機制,也會把 DSS 的脈絡帶到本來無法分類的憑證與撤銷串流上

為何一個無法讀取的物件也算違規?

因為另一種選擇,是讓驗證器被它看不懂的東西打敗。HotPDF 裡有三種情況會無條件落入 rmlOther:一個物件的內容無法從該修訂版讀出、一個物件被該修訂版標記為已釋放、以及一個不符合上述任何辨識器的物件。每一種都會在修訂版的 Issue 欄位裡記下具體的診斷訊息,讓操作人員能看出是哪個物件編號產生了這個判定

釋放(Freeing)是三者中最尖銳的一種。簽署後的修訂版若把一個先前已定義的物件標記為釋放,就等於從已簽署的文件裡刪除了內容,而 §12.8.2.2 底下沒有任何權限等級允許這麼做;相關物件編號會列進 FreedObjectNumbers,該修訂版被拉高到 rmlOther。無法讀取的物件,出於不同的理由,遵循同一套邏輯。一個驗證器如果無法剖析某個物件,就沒有依據說它是無害的,而誠實的回應不是保持沉默。把一個不常見但無害的構造回報成違規,代價是一次人工審查;相反的錯誤,則是出貨一份帶著未被察覺的編輯、已簽署的合約

在 Delphi 中讀出判定結果

這段呼叫很簡短。載入文件、選一個簽章索引、讀取記錄;不帶參數的多載重載會重新開啟該文件當初載入的檔案,TStream 多載則接受呼叫端提供的位元組,並在回傳前恢復串流位置。PolicyCompliant 是大多數呼叫端想要的單一布林值,結合了三項獨立判斷:權限字典的結構有效性、DocMDPCompliant,以及 FieldMDPCompliant。在你的 UI 中把這些組成部分維持可見,而不是把它們摺疊起來,並注意一份沒有任何 DocMDP 轉換的文件會讓 DocMDPCompliant 保持在 True,因為一般的核准簽章沒有宣告任何政策可以違反,聚合出來的 ModificationLevel 此時是描述性的,而非判定

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

做排查時,你通常想要的是逐修訂版的細目,而不是摘要,因為它能說出文件歷史中的哪個時間點出了問題。Analysis.Revisions 裡的每一筆記錄,都帶有它在鏈中的索引、寫入時的交叉參照偏移量、自己的變更等級,以及涉及的物件編號

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP 是分開判定的,而這是刻意的

一份文件可以滿足 DocMDP,卻依然不合法,這就是為何 FieldMDPCompliant 是一個獨立的布林值,而不是折進等級比對裡。ISO 32000-1 §12.8.2.4 定義了 FieldMDP 轉換,§12.7.5.5 定義了相關的 /SigFieldLock 項目,用來把具名的表單欄位凍結在簽署那一刻,即使文件整體依然允許填表。填寫一個欄位是等級 2 的動作;填寫一個被簽署者鎖定的欄位則是違規,無論等級為何。HotPDF 把鎖定範圍讀進 THPDFFieldLockAction,值為 flaAllflaIncludeflaExcludeflaNone 保留給沒有鎖定政策的結果;名稱則讀進 Permissions.FieldNamesflaAll 鎖定所有欄位,flaInclude 鎖定列出的名稱,flaExclude 鎖定除了列出名稱之外的所有欄位。讀取結果時有一個細節要注意,只有已存在於簽署快照裡的欄位才會出現在 ChangedFieldNames 中,因為一個完全在簽署後才建立的欄位沒有簽署狀態可供矛盾,而是由 DocMDP 路徑捕捉

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

這項分析不會告訴你的事

它不會驗證簽章。AnalyzeLoadedSignatureRevisions 推理的是結構與權限;至於已簽署的位元組範圍是否依然雜湊到 CMS 區塊裡的值,以及簽署者憑證是否鏈結到你信任的任何東西,這些由 VerifyLoadedSignatureVerifyLoadedSignatureWithTrust 回答。一份檔案可能完全符合政策、卻在密碼學上一文不值,所以這兩項檢查在任何真正的驗收關卡裡都該並列存在。它也不會讀出內容串流裡的意圖:一頁的內容串流若整個被替換,會被抓到是超出白名單的變更,但分析不會告訴你這次替換動了哪個付款金額。rmlOther 的判定代表該由人來看,不代表發生了詐欺;而符合規範的判定代表這次變更落在允許的類別裡,不代表這次變更是被期望的。當你只需要簽署者宣告了什麼、不需要走訪修訂版時,GetLoadedSignaturePermissions 會單獨回傳政策字典

這裡描述的一切都在 Delphi 與 C++Builder 中原生執行,過程中沒有任何外部簽署服務介入,這正是它能對每一份收件文件執行、而不只是對已經有人起疑的文件執行的原因。完整的簽章與修訂版 API,連同它搭配的權限與驗證方法,都是 Delphi 與 C++Builder 版 HotPDF Component 的一部分