技術文章

FieldMDP:在 Delphi 中偵測鎖定表單欄位的竄改

PDFlibPas 透過 TPDFlib.GetSignatureModificationVerdict 回答 FieldMDP 的問題。給定一個已簽署的 PDF、其密碼與簽章欄位名稱,這個方法會走訪該簽章之後附加的每個增量更新,解析這些更新碰過的每個表單欄位的完整限定名,FieldMDP 鎖定的欄位被改動時回傳 3,只有未鎖定欄位被動過時回傳 2。接著 GetSignatureModificationDetail 會指出是哪個修訂版動了哪個欄位

需要這套機制的是多重簽核表單。申請人在請購單上填 Order.Customer 與 Order.Total,簽下 Approval.Requester,把這兩個欄位鎖住。主管加一個成本中心,簽 Approval.Manager。兩週後有人用桌面編輯器打開檔案,在第三個增量更新裡把 Order.Total 往上調了一點。每個簽章依然驗得過,因為每個簽章只涵蓋自己的位元組區間。稽核人員真正要問的是:哪個欄位在哪個簽章之後被改、這次改動是否被允許

為什麼光靠 DocMDP 抓不到被改動的鎖定欄位?

DocMDP 抓不到它,因為 DocMDP 分類的是改動的種類,不是一個個欄位。ISO 32000-1 §12.8.2.2 的認證權限等級只說後續更新可以填表單與簽署(P 2),還是可以再加註解(P 3),而這個權限對文件裡每個欄位一視同仁。改 Order.Total 屬於填表單,所以 P 2 認證恰恰被申請人本想禁止的那次編輯滿足了。PDFlibPas 的文件層級改動分類涵蓋了這一層,本文則再往下細一個層級

FieldMDP 是欄位粒度的那一層。簽章欄位可以帶一個 /Lock 字典(ISO 32000-1 §12.7.4.5),它容納的簽章也可以在簽章參照裡帶 FieldMDP 轉換(ISO 32000-1 §12.8.2.4)。兩者用的是同一套詞彙:/Action /All 鎖定所有欄位,/Action /Include 鎖定 /Fields 裡列出的名稱,/Action /Exclude 鎖定 /Fields 以外的所有名稱。在 v3.586.0 之前,GetSignatureModificationVerdict 只知道某個後續修訂版改了表單欄位,卻不知道是哪一個,所以任何帶欄位鎖、而後續更新又超出驗證材料的文件一律回傳 4(無法判定)。現在修訂版映射會為它分類的每個欄位相關物件記下一個解析出的欄位名,正是這一點把 4 變成真正的 2 或 3

每位簽核人簽署時寫入欄位鎖定

TPDFlib.SetSignProcessFieldLock 為簽署流程產生的下一個簽章設定鎖定。Action 1 是 All,2 是 Include,3 是 Exclude,0 則清除鎖定;Include 與 Exclude 接受以分號分隔的完整限定欄位名清單。被簽的欄位最終會帶上 /Lock 字典與配套的 FieldMDP 轉換,這正是嚴格驗證器期望的成對組合

PDFlibPas SetSignProcessFieldLock 在 Delphi FieldMDP 簽署中的 action 代碼:action 1 All 鎖定所有表單欄位(含後續的簽章欄位),action 2 Include 鎖定具名資料欄位,action 3 Exclude 鎖定清單以外的一切,action 0 清除鎖定
資料欄位交給各簽核人負責的部分用 Include,或用 Exclude 列出後續的簽章欄位;被拒絕的呼叫回傳 0 且先前的鎖定維持不變,所以務必檢查回傳值
var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open the form');
    try
      Pdf.SetSignProcessField(SignId, 'Approval.Requester');
      Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
      // 2 = Include:這個簽章只凍結下列欄位
      if Pdf.SetSignProcessFieldLock(SignId, 2,
        'Order.Customer;Order.Total') <> 1 then
        raise Exception.Create('field lock rejected');
      Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
      if Pdf.GetSignProcessResult(SignId) <> 1 then
        raise Exception.CreateFmt('signing failed, code %d',
          [Pdf.GetSignProcessResult(SignId)]);
    finally
      Pdf.ReleaseSignProcess(SignId);
    end;
  finally
    Pdf.Free;
  end;
end;

SetSignProcessFieldLock 在 Include 或 Exclude 呼叫沒帶名稱時回傳 0,清單裡有空項(例如 'Order.Customer;;Order.Total')時也是;被拒絕的呼叫會讓先前設好的鎖定保持原狀,所以請檢查回傳值,別想當然以為最後一次呼叫贏了。簽署設定的其餘部分,摘要演算法的選擇、/Contents 保留空間與結果代碼,都在PDFlibPas 的 PAdES 簽署與驗證指南裡。有一個設計要點是簽核鏈特有的:申請人簽章上的 /All 鎖定也會凍結 Approval.Manager,因為簽章欄位本身也是表單欄位。資料欄位交給各簽核人負責的部分用 Include,或用 Exclude 列出後續的簽章欄位

沿簽核鏈逐一執行判定

GetSignatureModificationVerdict 一次只判一個簽章,所以簽核鏈要對每個簽章欄位各呼叫一次。回傳值是一個很小、封閉的集合,每個值對審閱者的意義都不一樣:

  • 0:檔案或指定的簽章欄位讀不出來
  • 1:簽章之後沒有附加任何東西
  • 2:附加的只有生效權限允許的改動,且沒有鎖定欄位被動過
  • 3:某個後續修訂版做了不被允許的改動,包括對 FieldMDP 鎖定欄位的編輯
  • 4:無法判定,絕不可當成通過
  • 5:簽章沒有落在修訂版邊界上,有位元組落在它宣稱涵蓋的範圍內
const
  Approvals: array[0..2] of WideString =
    ('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
  Pdf: TPDFlib;
  I, Verdict: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    for I := Low(Approvals) to High(Approvals) do
    begin
      Verdict := Pdf.GetSignatureModificationVerdict(
        'purchase-request-final.pdf', FormPassword, Approvals[I]);
      // 兩個 getter 都描述剛完成的那次呼叫,務必在下一次呼叫前讀取
      Log.Add(Format('%s: verdict %d, MDP level %d, %s',
        [Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
         Pdf.GetSignatureModificationDetail]));
      case Verdict of
        1, 2: ;                                 // 接受
        3: RejectForm(Approvals[I]);            // detail 會指出欄位與修訂版
      else
        QueueForManualReview(Approvals[I]);     // 0, 4, 5
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

拿被竄改的請購單來看:修訂版 0 是空白表單,修訂版 1 和 2 是兩個簽章,修訂版 3 是那次編輯。因為鎖定定義是從每個已簽欄位匯集而來,Approval.Manager 的判定已經強制執行了申請人的鎖定,而它唯一的後續更新就是那次編輯本身。當那次更新只改寫了欄位字典,判定是 3,detail 讀作 Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. 這一行同時回答了兩個稽核問題。Approval.Requester 的判定得先越過主管簽署的修訂版,基於下方限制小節說明的原因,它經常回報 4,所以最新的簽章通常給出最銳利的答案。GetSignatureEffectiveMDPLevel 回報在各簽章中找到的最低 DocMDP P 值,沒有任何簽章認證文件時為 3,兩個 getter 都只在下一次判定呼叫前有效

PDFlibPas GetSignatureModificationVerdict 讀取被竄改 Delphi PDF 表單的增量更新時間線:修訂版 1 帶對 Order.Customer 與 Order.Total 的 Include 鎖定,修訂版 2 簽署 Approval.Manager,修訂版 3 改寫被鎖定的 Order.Total 欄位,主管簽章判定為 3、申請人簽章判定為 4
鎖定從每個已簽欄位匯集,Approval.Manager 的判定因此已強制執行申請人的鎖定,detail 字串會指出肇事欄位與修訂版,而最早的簽章往往落在 4

PDFlibPas 怎麼知道某個修訂版改了哪個欄位?

PDFlibPas 從每個修訂版的物件圖重建欄位名,因為被改動的物件身上很少帶著完整名稱。帶 /FT 或 /V 的欄位字典,或是介面元件註記,在 /T 裡只放著部分名稱。ISO 32000-1 §12.7.3.2 描述的完整限定名是從根節點出發、以句點相連的部分名稱鏈,所以修訂版映射沿 /Parent 連結向上走,把找到的每個 /T 前綴上去。每個父節點都從被分類修訂版當時或之前最新的定義讀取,這樣父節點後來的改名就不會改寫更早編輯的歷史

PDFlibPas 在 Delphi 中解析 FieldMDP 完整限定名:修訂版映射從被改動的介面元件沿 /Parent 連結向上走,把每個 /T 部分名稱以句點相連前綴成 Order.Total,再按完全相等或句點前綴比對 /Fields 項目,鎖定 Order 即涵蓋所有後代
被改動的物件很少帶著完整名稱:父節點從被分類修訂版當時或之前最新的定義讀取,名稱比對區分大小寫,向上走訪八層後停止

把解析出的名稱對到鎖定上,規則刻意簡單。名稱等於某個 /Fields 項目、或以該項目加句點開頭時即為相符,所以鎖定 Order 也鎖定了 Order.Total 與其他所有後代。All 鎖定所有名稱,Include 鎖定相符的名稱,Exclude 鎖定所有不相符的名稱。鎖定定義來自檔案裡每個已簽的簽章欄位;欄位同時帶 /Lock 字典與 FieldMDP 轉換時,採用 /Lock 的值。想確切知道每個簽章鎖了什麼,PDFlibDigitalSign 單元裡較底層的 TPDFlibSignDoc 類別公開了判定所讀取的權限報告:

uses
  PDFlibDigitalSign;

var
  SignDoc: TPDFlibSignDoc;
  Report: TPDFlibSignaturePermissionReport;
  Names: TStringList;
  I, J: Integer;
  Line: string;
begin
  SignDoc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
      Exit;
    try
      SignDoc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
      begin
        SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
        if not (Report.HasFieldLock or Report.HasFieldMDP) then
          Continue;
        Line := Names[I] + ': ' + string(Report.FieldLockAction);
        for J := 0 to High(Report.FieldNames) do
          Line := Line + ' ' + string(Report.FieldNames[J]);
        if Report.HasFieldLock and Report.HasFieldMDP and
          not Report.FieldMDPMatches then
          Line := Line + ' (lock and FieldMDP transform disagree)';
        Log.Add(Line);
      end;
    finally
      SignDoc.Close;
    end;
  finally
    Names.Free;
    SignDoc.Free;
  end;
end;

加密表單:必須傳入密碼,否則名稱讀不出來

加密表單得先解密才解析得出任何欄位名稱,所以 GetSignatureModificationVerdict 需要 Password 參數,用途遠不止打開檔案。/T 這類字串是按物件加密的,而寫進壓縮物件流的欄位在所屬流被解密並解壓之前根本讀不出來。從 v3.586.0 起,內部修訂版映射接受密碼,在解析物件流與修訂版字典時掛上解密環境,並對它解碼的每個物件解密,包括它做差異比對的早期版本與它向上走訪的父節點

修訂版映射的按物件解密只在 Password 非空時開啟。因此,對加密檔案用空密碼算出的判定應視為未經驗證,就算文件本身不必提示密碼就打開了也一樣。請把表單密碼與流程記錄存放在一起,每次呼叫都帶上

FieldMDP 判定不涵蓋的情況

欄位檢查是對一個很窄的問題給出的結構性答案,有好幾種情況落在它之外。事先知道這些,4 才不會演變成一張支援工單:

  • 欄位比對只在文件層級檢查乾淨通過、且至少一個後續修訂版改動了驗證材料或時間戳以外的內容之後才執行;DocMDP 違規在任何欄位被檢視之前就回報為 3
  • 被分類為簽章相關卻不帶欄位名稱的物件,例如簽章值字典、被改寫的 AcroForm 字典,或限於 /AcroForm 與 /Perms 的目錄變更,會讓判定直接回傳 4 而不猜測,所以在作用中的鎖定下加入後續簽核簽章的修訂版常常落在 4
  • 只有欄位字典與介面元件註記能拿到欄位名稱;修訂版映射沒有外觀流專屬的分類,所以編輯器若連同值一起重建外觀,可能在欄位檢查執行之前就被文件層級檢查當成內容變更擋下,兩種情況靠 detail 字串區分
  • 掃描停在遇到的第一個鎖定欄位或未具名條目,所以 3 只指出一個肇事欄位,不是全部
  • 鎖定從每個已簽簽章欄位匯集,且不按時間排序,所以較晚的簽署者寫下的鎖定也會套用到它之前的更新;這只會把結果推向 3,絕不會推向 2
  • 名稱比對完全一致且區分大小寫,/Parent 走訪八層後停止,所以異常深的層級會解析成截斷的名稱
  • FieldMDP 條目結構驗不過、action 也讀不出來時,判定回傳 4

當判定回傳 4 時,GetSignatureRevisionIndex 給出簽章結束的修訂版,GetDocumentRevisionChanges 則列出每個後續修訂版新增、修改或釋放的每個物件及其變更類別。這些足夠一個人在一分鐘內做出判斷。這些修訂版如何被附加,機制在PDFlibPas 的增量更新指南裡

FAQ

判定為 2 就代表簽章有效嗎?

不代表。GetSignatureModificationVerdict 檢視的是簽章之後附加了什麼,從不檢查 CMS 簽章值、憑證鏈或撤銷狀態。2 表示後續更新留在權限範圍內;密碼學驗證是獨立的一步,兩者都需要

第二位簽核人簽署為什麼得到 4 而不是 2?

簽署會附加一個簽章值字典,通常還會改寫 AcroForm 字典這類表單簿記。這些物件不屬於任何具名欄位,而欄位鎖定作用中時,判定拒絕放行無法歸屬到某個欄位的變更。請用 GetDocumentRevisionChanges 確認修訂版裡只有預期的簽章物件

一個名稱能鎖住整組欄位嗎?

可以。鎖定條目比對具名欄位與所有完整限定名以它加句點開頭的後代,所以在 Include 鎖定中列出 Order,就凍結了 Order.Customer、Order.Total 以及該父節點下的任何其他欄位

FieldMDP 判定、欄位鎖定簽署與修訂版檢視都隨PDFlibPas Delphi PDF Library 提供,上述簽核流程依賴的 DocMDP 認證與 PAdES 長期驗證功能也一併在內