一份合約簽章完成之後又多出一個更新,審核腳本就可能把這個更新併進已簽章的修訂版,或把改寫過的頁面當成驗證材料放行。只要修訂版的邊界畫在 startxref 指名 /Prev 鏈走得到的那一節之處,且改寫過的物件保留它先前的類別——PDFlibPas 修訂版映射圖背後的這兩條規則——這兩件事就都不會發生
為什麼 %%EOF 不是切開檔案的正確位置
每個遞增更新都以自己的檔案結尾標記收尾(ISO 32000-1 §7.5.6),所以在每個 %%EOF 處切開檔案,看起來就是找修訂版最直覺的做法。但這個標記只有五個位元組,沒有任何東西阻止這些位元組出現在內容串流、內嵌檔案或字型程式裡。線性化檔案還添了第二個麻煩:附件 F(§F.3.4)允許首頁 trailer 以自己的 startxref 和 %%EOF 收尾,位置貼近檔案開頭,遠在主交叉參照表之前。在每個標記處切開,會憑空發明出沒有任何交叉參照節支持的修訂版。猜哪些標記是假的更糟——那個猜法正是偽造者可以塑形的東西。PDFlibPas 修訂版映射圖反著來。它沿著 /Prev 鏈走,記下鏈實際讀到的每個交叉參照節的位元組偏移量,然後才掃描 startxref N … %%EOF 序列,只有在 N 落在那些記錄過的偏移量之內才保留邊界。串流裡一個遊走的標記,除非它前面有個 startxref 指名了一個真實的節,否則什麼都算不上
這三個記號之間的位元組,比 §7.5.5 暗示的更不整齊。標準把 startxref、偏移量與標記畫在連續的三行上,但 §7.2.2(Table 1)的行尾標記與其他空白字元可以重複,且沒有規則設上限。漏掉一個帶填充的標記,兩個修訂版就融成一個,已簽章的那個看起來就結束在沒人簽過的位元組之後。PDFlibPas 修訂版映射圖不管填充多寡、不管落在檔案何處,一律從關鍵字到運算元再到標記沿著序列走,且每個關鍵字只計一次。trailer 關鍵字有個類似的鬆動接點:<< 是分隔符(§7.2.2,Table 2),而 §7.5.5 從不要求 trailer 之後換行,所以同一行上的 trailer<< 是合規語法。逐行讀下去,這一行看起來像交叉參照子節標頭——鏈就走一半停了
用 PDFlibPas 走一遍修訂版映射圖
這些修訂版存取器宣告在 TPDFlib 上,在 Small Business 版實作:從 PDFlibSmallBusiness 單元建立 TPDFlibSmallBusiness(Enterprise 繼承自它,帶同樣的呼叫)。在裸的 TPDFlib 上,它們回傳各自的失敗值(0、7 或 -1),並把 LastErrorCode 設為 PDFLIB_ERROR_SMALLBUSINESS_REQUIRED。修訂版從 0 開始編號,0 是原始文件。每個呼叫都收檔名與密碼,並從磁碟上的位元組建映射圖,所以答案描述的是檔案儲存時的樣子,而不是您剛好載入並編輯過的某份文件。GetDocumentRevisionKind 回報修訂版裡影響最深遠的那一項改動,一個既加了 OCSP 回應又改寫頁面的更新,會被回報成頁面改寫:
| 類別 | 意義 | 典型來源 |
|---|---|---|
| 0 | 純家務事:不改變讀者所見任何東西的物件 | 交叉參照串流、新的物件串流容器 |
| 1 | 驗證材料 | 文件安全儲存庫(DSS)、VRI、憑證、CRL、OCSP 回應 |
| 2 | 文件時間戳 | 一個 /DocTimeStamp 簽章與承載它的欄位 |
| 3 | 簽章 | 接收到值的既有簽章欄位 |
| 4 | 表單欄位 | 欄位值或其外觀 |
| 5 | 註解 | widget 以外的任何註解 |
| 6 | 文件內容 | 任何能改變文件呈現的東西 |
| 7 | 無法判定 | 修訂版裡有東西讀不出來;視為不安全 |
uses
System.SysUtils, PDFlibSmallBusiness;
procedure ListRevisions(const FileName, Password: WideString);
const
KindNames: array[0..7] of string = ('housekeeping', 'validation material',
'document time stamp', 'signature', 'form field', 'annotation',
'content', 'undetermined');
var
Pdf: TPDFlibSmallBusiness;
Count, I, J, Kind, Changes: Integer;
begin
Pdf := TPDFlibSmallBusiness.Create;
try
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
if Count = 0 then
raise Exception.Create('the cross-reference chain could not be followed');
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Writeln('WARNING: history incomplete, every figure below is a lower bound');
for I := 0 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind < 0) or (Kind > 7) then
Kind := 7;
Writeln(Format('revision %d: bytes %d..%d, %s', [I,
Pdf.GetDocumentRevisionStart(FileName, Password, I),
Pdf.GetDocumentRevisionEnd(FileName, Password, I),
KindNames[Kind]]));
// 每個物件一行 'objnum,change,class,detail'
Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
if Changes <> 0 then
try
for J := 1 to Pdf.GetStringListCount(Changes) do // 項目從 1 起算
Writeln(' ' + Pdf.GetStringListItem(Changes, J));
finally
Pdf.ReleaseStringList(Changes);
end;
end;
finally
Pdf.Free;
end;
end;
在相信其他任何呼叫之前,先讀的是 GetDocumentRevisionsComplete。只有當鏈一路走回原始節時它才回傳 1;斷掉的或成環的 /Prev,或深度超過 256 節的鏈,都會讓最舊的更新看不到,此後每個計數與索引都只是下限。256 這個深度替真實檔案的存檔與簽章歷史留足了餘裕,而指回自己的鏈也幾乎立刻就結束。留意迴圈裡的索引:GetStringListItem 從 1 起算,修訂版卻從 0 起算,內層迴圈從 0 開始的話,每個修訂版的第一個變更物件就會無聲無息地被丟掉
偽造更新如何把自己偽裝成驗證材料
PAdES 長期驗證在最低一個簽章之後合法地附加更新:文件安全儲存庫(/Type /DSS)、每個簽章一份的 /VRI 字典、憑證與撤銷串流、文件時間戳。認證等級放行它們,這使得驗證材料成為一個更新能掛上的最有價值標籤,它同時也是偽造起來最便宜的一個。/Type 寫什麼全看更新自己。畫頁面的檢視器從不看內容串流、資源字典或字型的 /Type,所以一個更新可以改寫第 3 頁的內容串流、在自己的字典裡加 /Type /DSS,每個算繪器照樣畫出新文字,而相信 /Type 的分類器看到的是家務事。/VRI、/Sig 與 /DocTimeStamp 同理
PDFlibPas 修訂版映射圖不理會更新在自己改寫的物件上寫的標籤。改寫過的物件只有在它的較早版本本來就是驗證材料、時間戳或簽章時,才以那個類別計數。憑證、CRL 與 OCSP 回應串流本身不帶型別,靠指向它們的文件安全儲存庫來辨認——而且只有寫在被判定的修訂版或更早修訂版裡的儲存庫才算。後來才加入的儲存庫伸不回頭,作保不了已簽章修訂版寫下的物件。只要改動會改變讀取器會解析出什麼,對文件結構的改動就算內容:
- 替換既有的物件串流,因為串流裡每個壓縮物件此刻都解析到替身(全新容器仍算家務事,因為每個成員有自己的條目,各自在原處受判定)
- 加入目錄或頁面
- trailer 的
/Root指名另一個目錄,即使更新沒有重定義任何簽章涵蓋的東西也算,因為換掉根就換掉了整份文件,不必碰任何一個被簽章的物件 - 改寫過的串流,其字典與舊的逐位元組相同:間接的
/Length或填充到相同尺寸的替換內容,能在圖形改變的同時保持字典不變,所以只有不帶串流內容的物件才憑這個證據視為未變
物件串流成員沒有可剝除的標頭
從 PDF 1.5 起,更新可以把改寫過的物件收進物件串流,而 §7.5.7 對這個格式說得很直白:只存物件值,不得使用 obj 與 endobj 關鍵字。於是那種「讀到物件所在位置、靠搜尋字串 obj 來剝掉標頭」的程式碼,在成員開頭什麼都找不到,會繼續往內容裡搜。給簽章之後才改寫的頁面字典塞一個看似無辜的字串,例如 (obj<</Type/DSS>>),搜尋就停在字串裡面。解碼器收到的是 <</Type/DSS>>——一個該修訂版從未寫過的儲存庫字典。PDFlibPas 修訂版映射圖從未壓縮物件的開頭解析 N G obj,核對數字指名的確實是它預期的物件,標頭缺失或不符時視為不可讀,而不繼續深挖
交叉參照串流自己也帶著欄位層級的歧義。按 §7.5.8.3(Table 18),只有 type 1 條目存位元組偏移量;type 2 條目的第二個欄位是容納它的物件串流的物件號,第三個是串流內的索引;而 type 0 條目的第二個欄位是下一個空閒物件號。把那個物件號當偏移量讀,壓縮條目就會落在涵蓋那些小位元組位置的任何修訂版裡——幾乎總是原始文件,改寫就此藏進已簽章的位元組中。修訂版映射圖把每個條目歸入宣告它的那個交叉參照節。同一條款還說,型別不是 0、1 或 2 的條目是對空物件的參照,所以這類條目既不加也不放任何東西,不上映射圖
簽章審核什麼時候該拒絕作答?
檔案藏起自己歷史的一部分時。修訂版映射圖用三種方式回報這件事:GetDocumentRevisionsComplete 回傳 0、GetDocumentRevisionKind 在簽章之後的某個修訂版回傳 7、或 GetSignatureRevisionIndex 回傳 -1。最後那個值涵蓋不在修訂版邊界上結尾的 ByteRange——簽章聲稱涵蓋的範圍內嵌著其他位元組——也涵蓋不存在的欄位、不帶簽章值的欄位,或落在讀不出來的檔案裡的欄位。三種情況都附上修訂版索引與 GetDocumentRevisionChanges 的輸出行送人工複核,讓打開檔案的人從物件號著手
// 以 DocMDP 式政策核對一個簽章之後的各個更新。
// 類別按嚴重度排序,所以每個政策等級是一個上限:
// P 1 容忍家務事、驗證材料與時間戳(0..2),
// P 2 加入簽署與表單填寫(..4),P 3 加入註解(..5)。
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
Ceiling: array[1..3] of Integer = (2, 4, 5);
var
Signed, Count, I, Kind: Integer;
begin
Result := False;
if (Level < 1) or (Level > 3) then
Exit;
if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
Exit; // 歷史有一部分從未被看到
Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
if Signed < 0 then
Exit; // 不在修訂版邊界上、沒有這個已簽章
// 欄位,或檔案讀不出來
Count := Pdf.GetDocumentRevisionCount(FileName, Password);
for I := Signed + 1 to Count - 1 do
begin
Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
if (Kind = 7) or (Kind > Ceiling[Level]) then
Exit; // 7 高於一切上限;寫出來是給讀者看的
end;
Result := True;
end;
這些上限依 ISO 32000-2 §12.8.2.2(Table 257):P 1 不允許任何改動,2 允許表單填寫與簽署,3 加入註解,而只添加 DSS 資料或文件時間戳的遞增更新在任何等級都不計為改動。ISO 32000-1 §12.8.2.2 定義同樣三個值,但沒有這條豁免。政策是您自己的而不是簽發者的時候,就自己寫迴圈,例如一條不管檔案掛什麼 P 都拒收帶註解合約的收件規則。政策該從檔案來時,同一個類別上的 GetSignatureModificationVerdict 會讀認證等級、套用偵測 FieldMDP 鎖定表單欄位的竄改所述的逐欄位鎖,並對越界情況回傳 5。兩者回答的問題都與對照您留存的文件副本分類改動不同:修訂版映射圖除了眼前的檔案什麼都不需要
附加走的是同一條鏈。遞增更新的 trailer 必須帶 /Prev 條目,給出前一個交叉參照節的位置(§7.5.6),而只有靠全盤掃描重建交叉參照表才開得了的文件,沒有這種位置可給。AppendToFile 對它回傳 0,目標檔案分毫未動。請完整存一次,在乾淨的副本上簽章——完整改寫不會把既有簽章帶過去
對已簽章合約的歸檔,在任何新東西被附加之前,對每個簽章欄位跑一次 UpdatesWithinPolicy。後來的修訂版讀出類別 7 的檔案,應該先送到人的手上,趕在任何會把讀不出的修訂版再埋進一個更新底下的 PDFlibPas 簽章或長期驗證步驟之前