想知道 PDF 在簽署之後改了什麼,Delphi 與 Lazarus 的 PDFium Component 提供 TPdf.AnalyzeSignatureRevisions,一個簽署後修訂變更分析器:從原始檔案位元組重建每個增量修訂、按該簽章的 DocMDP 與 FieldMDP 規則為後來的每個物件變更評級,並把影子物件定義獨立回報為風險。它針對的場景凡是經手合約的人都熟悉:一份經認證的表單寄出去,回來時多了兩次增量存檔,而每一個簽章都驗證通過。這是預期行為,因為簽章只覆蓋自己那個修訂的位元組。真正的問題是後來的存檔是否被允許,而簽章上的綠色勾勾回答不了這個問題
PDFium 簽章 API 為什麼看不到簽署後的變更?
PDFium 簽章 API 看不到簽署後的變更,因為它只讀簽章字典:/Contents、/ByteRange、/SubFilter 與 DocMDP 許可值。PDFium 沒有增量修訂圖,不解析 FieldMDP 轉換參數,也不提供修訂之間物件層級的 diff,所以 FPdfPades.pas 裡的分析器直接作用在原始位元組上。這有一個您應該納入設計的實際後果。TPdf.AnalyzeSignatureRevisions 讀的是文件載入時保留下來的位元組,絕不是 SaveAs 產出的副本,因為重寫過的檔案早已丟掉正在被分析的那個修訂結構。如果文件來自一個還沒下載完的漸進式來源,報告回傳 SourceStatus = pvssIncomplete 與 Status = prasIndeterminate,而不是去分析一個被截斷的檔案
從 startxref、xref 串流與 /Prev 重建修訂邊界
分析器沿著每個 startxref 往回走,穿過傳統 xref 表、交叉參照串流、混合參照的 /XRefStm 項目與 /Prev 鏈,重建修訂邊界,這是 ISO 32000-1 §7.5.6 與 §7.5.8 為增量更新定義的路徑。每個簽章的覆蓋長度是其第二個 ByteRange 區段的結尾,分析器把該長度映射到 xref 區段落在其中的那個修訂。沒有修訂相符時,簽章得到 prrCoveredRevisionNotFound 與 Indeterminate 狀態。接著把每個物件的狀態重播到被覆蓋的修訂為止,每個較晚的 xref 項目都與那個狀態相比。這比聽起來重要:有些寫入器在每次增量存檔時重述完整的 xref 表,而仍然指向同一個未變物件的項目會被跳過,而不是被回報成修改。少了這個比較,一次完全合法的表單填寫會被數百個假變更淹沒
影子定義是最值得警惕的情況。出現在較晚修訂位元組範圍內、卻沒有被該修訂的 xref 參照的物件本體,對一般檢視器是隱形的,而它恰恰是影子攻擊依賴的那種預置動作:隱藏內容在簽署前後被埋進去,之後靠翻動一個參照被啟用。AnalyzePadesSignatureRevisionsBytes 把這種物件記為 IsAuthoritative = False 的非權威變更,不管許可等級一律評為 prdSuspicious,並把 prrUnreferencedObjectDefinition 加進風險集。兩個相關風險涵蓋其他結構花招:prrDuplicateObjectDefinition 在一個 xref 區段把同一物件列了不只一次時觸發,prrSignatureObjectRedefined 在較晚修訂重定義既有簽章物件時觸發
uses
SysUtils, TypInfo, PDFium, FPdfPades;
const
ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
Pdf: TPdf;
Report: TPadesRevisionAnalysisReport;
i, j: Integer;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'contract-returned.pdf';
Pdf.Active := True;
Report := Pdf.AnalyzeSignatureRevisions;
Writeln('Revisions: ', Report.RevisionCount,
' Signatures: ', Report.SignatureCount,
' Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
Ord(Report.Status)));
for i := 0 to High(Report.Signatures) do
with Report.Signatures[i] do
begin
Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
[SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
DocMdpPermission,
GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
for j := 0 to High(Changes) do
Writeln(Format(' rev %d obj %d %s -> %s%s',
[Changes[j].RevisionIndex, Changes[j].ObjectNumber,
GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
ShadowTag[not Changes[j].IsAuthoritative]]));
end;
finally
Pdf.Free;
end;
end;
每個簽章的 DocMDP 與 FieldMDP 怎麼執行?
DocMDP 與 FieldMDP 對每個簽章分開執行,執行點是該簽章自己被覆蓋的修訂,所以同一份檔案裡的認證簽章與較晚的核准簽章,可以對同一個編輯得出不同裁決。每個較晚的物件先依其 /Type、/Subtype、/FT 項目,以及它在頁面、表單、註解與 DSS 圖裡扮演的角色,被分類成一個 TPadesRevisionChangeKind。凡帶著 /JavaScript、/JS、/Launch、/OpenAction、/AA、/RichMedia 或 /EmbeddedFile 的都成為 prckActiveContent。決定接著跟著 ISO 32000-1 §12.8.2.2 走:P=1 時除了交叉參照資料與驗證材料一律禁止;P=2 允許表單填寫與進一步簽署、但拒絕註解變更;P=3 連註解也允許。頁面內容、文件結構、中繼資料、主動內容與被刪除的物件在任何 DocMDP 等級下都禁止,而在簽章完全沒帶 DocMDP 時評為 prdSuspicious——核准簽章形式上什麼也沒禁,但讀者已經看不到當初簽的是什麼了
FieldMDP(ISO 32000-1 §12.8.2.4)把表單欄位的決定進一步收窄。pfmaAll 鎖所有欄位,pfmaInclude 只鎖列出的欄位,pfmaExclude 鎖除列出之外的一切。要套用 Include 或 Exclude,分析器透過 /Parent 鏈把每個被改的欄位解析成完整限定名稱,再與鎖定清單做精確比對,所以清單裡要寫終端欄位名稱,別指望一個父項名稱能罩住它的子項。名稱解析不了或轉換用了剖析器不認得的動作時,變更變成 prdIndeterminate 並舉起 prrFieldMdpUnresolved。逐變更的決定接著以最壞者優先彙總:Suspicious 高於 Disallowed、Disallowed 高於 Indeterminate、Indeterminate 高於 Allowed,所以一個影子物件就壓過任何數量的合法欄位更新
為什麼有些變更回報無法判定而不是安全?
只要分析器無法證明某個變更被允許,它就回報 Indeterminate,因為在簽章檢查裡,未知絕不能被回報成允許。有一個常見情況被精確處理:長期驗證會加入 /DSS 並重寫 catalog,這在 P=1 之下原本會算成結構變更。分析器把 /DSS 與 /Extensions 從新舊 catalog 字典裡剝掉、比較其餘部分;其餘都相同時,這次重寫被視為驗證材料更新而放行,所以 B-LT 與 B-LTA 增強不會弄壞認證簽章。其他缺口是故意留著的。交叉參照串流裡的 Type-2 項目指向壓縮的物件串流,分析器在這個安全邊界內不展開物件串流,所以那些變更以 prckCompressedObject 加 prrCompressedObjectUnresolved 浮現,P=1 下禁止、其他情況無法判定。1024 個修訂、1,000,000 個物件編號與 2,000,000 筆回報變更的硬預算會產生 prrResourceLimitExceeded,斷掉的 xref 鏈產生 prrMalformedRevisionChain;兩者都以 Indeterminate 收場,絕不以通過收場
const
StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
prrSignatureObjectRedefined, prrResourceLimitExceeded];
function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
// 有些風險記錄時不改變 Status,所以先測它們
if R.Risks * StructuralRisks <> [] then
Exit('review: structural risk in the revision chain');
case R.Status of
prasNoLaterChanges: Result := 'accept: nothing was added after signing';
prasAllowed: Result := 'accept: every later change is permitted';
prasDisallowed: Result := 'reject: a change violates DocMDP or FieldMDP';
prasSuspicious: Result := 'reject: shadow or unconstrained content change';
prasIndeterminate: Result := 'review: the analyzer could not decide';
else
Result := 'not checked: no signatures or no original bytes';
end;
end;
那道關卡裡的順序是刻意的。prrDuplicateObjectDefinition 被加進風險集時,光靠它自己不會調降 Status,而一個解析不了的 FieldMDP 轉換也只在表單欄位真的變更時才影響狀態,所以只看 Status 的關卡會漏掉報告裡已經存在的證據。也請記住這份報告不宣稱什麼。TPadesRevisionAnalysisReport 對 CMS 簽章密碼學上是否有效、簽署者憑證是否鏈到您信任的根,一個字都沒說。修訂分析回答的是簽署後發生了什麼,它該與結構及信任驗證並肩作戰,而不是取而代之
簽署時寫入種子值與 MDP 鎖定
同樣的規則可以在簽署時透過 TPadesSignatureFieldOptions 寫下,它是 TPadesSignOptions 與 TPadesRemoteSignOptions 共同的 FieldOptions 成員。PDFium 能建小工具,卻寫不了 /SV、/Lock、FieldMDP 或 DocMDP 轉換,也寫不了 catalog 的 /Perms 字典,所以元件自己的增量 PAdES 寫入器把這些物件放進與簽章同一個 xref 更新裡。FieldName 設根欄位名稱,RequiredSeedValues 變成 ISO 32000-1 §12.7.4.5 所述種子值字典的 /Ff 位元,Reasons、LegalAttestations 與 AcceptableCertificates 約束後來的簽署者能選什麼,LockAction 配 LockFields 寫出間接的 /SigFieldLock,而 1 到 3 的 CertificationPermission 把簽章變成認證簽章。DocMDP 與 FieldMDP 轉換都進到簽章值上同一個 /Reference 陣列,各自以 /Data 指向 catalog
var
Options: TPadesSignOptions;
begin
Options := TPadesSignOptions.Default;
Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
Options.Reason := 'Approved for release';
Options.FieldOptions.FieldName := 'Certification';
Options.FieldOptions.CertificationPermission := 2; // 只允許表單填寫與簽署
Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
Options.FieldOptions.LockAction := pfmaInclude; // 只鎖這些欄位
SetLength(Options.FieldOptions.LockFields, 2);
Options.FieldOptions.LockFields[0] := 'Total';
Options.FieldOptions.LockFields[1] := 'IBAN';
if not Pdf.SignPades('contract-certified.pdf', Options) then
Writeln('Signing failed');
end;
如果您手工拼這些,有幾個細節容易出錯。catalog 的 /Perms /DocMDP 必須參照簽章值字典,不是小工具註解,寫入器為此把簽章值保留成自己的間接物件。既有的 /Perms 字典可能已經裝著 /UR3 使用權限,所以寫入器複製它並插入 /DocMDP、而不是整個換掉,遵循 ISO 32000-1 §12.8.4 的許可字典。已經帶著 /DocMDP 的文件會以 EPadesCrypto 拒絕第二個認證簽章,不一致的選項也一樣:Include 或 Exclude 鎖卻沒有欄位名稱、All 鎖卻帶欄位清單、非認證簽章上放了法律聲明、或根欄位名稱裡出現句點。遠端簽署多一條規則,因為 PreparePadesRemoteSignature 執行時還不知道簽署憑證:在那裡設 CertificateRequired 就要求明確的 AcceptableCertificates 清單,本機簽署則可以退回解析出的簽署者憑證
修訂分析補齊了簽章工具箱,而不是取代其中任何一塊。從用 PDFium Component 檢視 PDF 簽章與 PAdES 等級入門讀字典與基準等級,看驗證器為什麼拒收 PAdES 簽章了解在任何修訂問題之前就出現的結構性失敗,再把裁決併入更廣的PDF 安全風險稽核,與 JavaScript 及嵌入式檔案檢查並列。TPdf.AnalyzeSignatureRevisions、TPadesSignatureFieldOptions 與這裡展示的增量 PAdES 寫入器都隨 PDFium Component 出貨,支援 Delphi、C++Builder 與 Lazarus