跳到內容

增量更新為何重要

Evidence: Standard-backed

當 PDF 在寫入之後發生變更時,妥善的儲存方式不是重寫整個檔案。取而代之,你會將變更的物件與一個新的交叉參照區段附加到檔案結尾,讓每一個原始位元組都留在原處。本頁說明其運作方式,以及為何這正是數位簽章在後續編輯後仍能存續的關鍵。

簽章保護的是一段位元組範圍。如果只是儲存一個字詞的變更就重寫了整個檔案,那麼每一個位元組偏移量都會改變。已簽署的範圍將不再描述相同的內容。即使已簽署的內容本身沒有被動過,簽章仍會失效。

增量更新的目的,就是避免這種情況發生。原始位元組,包括簽章所涵蓋的位元組,都會停留在原處。審查者可以拿一份先簽署後編輯的文件,對照原始修訂版本來驗證第一個簽章。審查者能精確看到當初簽署了什麼,也能另外看出後續有哪些變更。一旦處理錯誤,你不是讓原本有效的簽章失效,就是更糟:失去證明某個簽章實際上見證了什麼的能力。

  • 增量更新會附加:先放入新增與變更的物件,接著放入一個新的交叉參照區段,然後是一個新的尾段(trailer),全部都位於檔案結尾。
  • 原始檔案內容會保持完整無缺——不會就地編輯。
  • 新的尾段帶有一個 /Prev 項目:前一個交叉參照區段的位元組偏移量。這些區段構成一條向後的鏈。
  • 讀取器會以最新優先的方式走訪這條鏈,藉此建立索引。對任何物件編號而言,最新的項目勝出。
  • 由於沒有任何內容被覆寫,先前簽章所涵蓋的位元組範圍仍逐位元組保持一致——因此簽章仍可通過驗證,而且你能還原出當初簽署時一模一樣的文件。

NextPDF 會依照前一頁所描述的方式寫入基礎文件,接著提供增量更新所需的三項資訊。

build() 之後,寫入器(src/Writer/PdfWriter.php)會保留:

  • 輸出緩衝區,可透過 getBuffer() 取得,讓更新可以精準附加到既有位元組的末端;
  • 最後一個交叉參照區段的位元組偏移量,可透過 getLastXrefOffset() 取得,作為新區段的 /Prev 值;
  • 目錄字典項目,可透過 getCatalogEntries() 取得,如此一來,必須重新輸出目錄的更新(例如要附加簽章參照時)就不會遺失任何先前的鍵。

附加的修訂版本會針對同一個 ObjectRegistry 分配新的物件編號(或為它所取代的物件重複使用既有編號),因此物件編號在各修訂版本之間保持一致。新的交叉參照區段只會列出此修訂版本所動過的物件。新的尾段會重複前一個尾段的項目,並加上指回前一個區段的 /Prev。讀取器所追蹤的正是這條鏈。

簽署最能看出這一點。NextPDF 的 ByteRangeCalculatorsrc/Security/Signature/ByteRangeCalculator.php)會將 /ByteRange 陣列計算為兩個分段:簽章值之前的所有內容,以及其之後的所有內容——因此簽章涵蓋整個修訂版本,但不含它自身的位元組。由於後續編輯是附加上去,而非覆寫那些位元組,因此該範圍永遠不會位移。

  1. Write base revision Header, body, xref section, trailer — the original bytes.
  2. Sign A /ByteRange digest covers the whole revision except the signature value itself.
  3. Edit and save Changed objects + a new xref section are appended; originals are untouched.
  4. New trailer chains back The appended trailer carries /Prev = offset of the previous xref section.
  5. Verify The first signature still covers the same unchanged bytes; the chain shows what came after.
一份已簽署的 PDF 在後續被編輯後如何維持可驗證:每次儲存都是附加,因此最初簽署的位元組永遠不會被覆寫,而尾段鏈則記錄下這段歷史。

僅附加(append-only)規則具有規範性。 Spec: ISO 32000-2, §7.5.6 指出,PDF 的內容可以增量地更新,而無需重寫整個檔案;進行這種更新時,變更應當附加到檔案結尾,使原始內容保持完整無缺。 Evidence: Standard-backed

同一條款定義了運作機制。增量更新的交叉參照區段只包含被變更、被取代或被刪除之物件的項目。被刪除的物件仍留在檔案中,但會透過其交叉參照項目標記為已刪除。新增的尾段應當包含一個 /Prev 項目,指出前一個交叉參照區段的位置。更新中針對已變更物件的項目,會帶有副本的位元組偏移量,覆蓋舊的偏移量。讀取器建立交叉參照資訊時,會讓每個物件都存取到其最新副本。

這對簽章的影響,明確見於 Spec: ISO 32000-2, §12.8.1 :位元組範圍摘要是針對檔案的一段範圍計算而來——通常是整個檔案,但不含 簽章值(即 /Contents 項目)。 標準接著指出若一份已簽署的文件透過增量更新被修改並儲存,對應於原始簽章位元組範圍的資料會被保留,因此若簽章有效,便能將文件在簽署時的狀態還原出來。僅附加並不是可有可無的細節。它正是簽章模型所仰賴的特性。

從結構面看,一份先簽署後編輯的 PDF 會像這樣。原始修訂版本在它自己的 %%EOF 處結束。第二個修訂版本則附加在它後面。

%PDF-2.0
... original objects, including the signature dictionary ...
xref
0 8
... entries for the original revision ...
trailer
<< /Size 8 /Root 1 0 R >>
startxref
920
%%EOF
<-- end of revision 1: the signed bytes stop here
9 0 obj <-- revision 2, appended
<< /Type /Annot /Subtype /Text /Contents (added after signing) >>
endobj
xref
0 1
9 0 obj-entry...
8 9
0000001740 00000 n
trailer
<< /Size 10 /Root 1 0 R /Prev 920 >>
startxref
1980
%%EOF

驗證器讀取最後一個尾段,看到 /Prev 920,於是取得整條鏈。它可以對照第一個 %%EOF 之前那些未變更的位元組來驗證簽章。接著,它能另外回報修訂版本 2 新增了一個註解。這段歷史就在檔案之中。沒有任何東西因覆寫而被隱藏。

陷阱在於「增量更新意味著變更很小,所以無害」這種想法。附加的重點在於位元組的保留,而非大小。一次增量更新可以新增大量內容。使它成為增量更新的關鍵在於:它並未動到本來就已存在的位元組。另一個常見誤判也由此而來:某個工具若藉由從頭重寫來「最佳化」或「線性化」一份已簽署的 PDF,會產生一個更小、更乾淨的檔案,以及一個失效的簽章,因為已簽署的位元組範圍不復存在。儲存一份已簽署的 PDF,和把它重新儲存,並不是同一件事。

僅附加保護的是位元組。它本身並不會告訴你這些附加的變更是否經過授權。第二個修訂版本可以正當地加上第二個簽章,也可以加入第一位簽署者從未預期的內容。判斷究竟是哪一種,是簽章驗證與修改偵測原則(DocMDP)的職責。附加是讓那項分析得以成立的基礎,而非分析本身。

本頁也不涵蓋簽章的兩段位元組範圍如何被計算與拼接,亦不涵蓋一次完整驗證會檢查哪些項目。那些屬於另外的主題。此外,這裡的保證針對的是由相容寫入器所寫入並更新的檔案:一個其較早修訂版本本身就格式錯誤的檔案,並不會因為被附加內容而變得格式正確。

我怎麼知道一份 PDF 有幾個修訂版本? 計算 %%EOF 標記的數量,並從最後一個尾段沿著 /Prev 鏈追蹤下去。每抵達一個交叉參照區段,就代表一個已儲存的修訂版本。

刪除一個物件會把它從檔案中移除嗎? 不會。增量更新會在物件的交叉參照項目中將它標記為已刪除,但該物件的位元組仍留存於較早的修訂版本中。「已刪除」的意思是「不被目前的修訂版本所參照」,而不是「抹除」。

增量更新可以變更 PDF 版本嗎? 可以,方法是在附加的修訂版本中於目錄裡設定 /Version 項目。標頭則維持原本寫入時的樣子。當目錄的 /Version 指出較新的版本時,便以它為準。

  • 增量更新 — 透過將變更的物件、一個新的交叉參照區段以及一個新的尾段附加到檔案結尾來儲存變更,而不更動既有的位元組。
  • /Prev — 尾段(或交叉參照串流)中保存前一個交叉參照區段位元組偏移量的項目。它將各修訂版本串連成一條向後的鏈。
  • 修訂版本 — 由一個交叉參照區段及其尾段所捕捉的檔案狀態。一個有 N 個交叉參照區段的檔案,就有 N 個修訂版本。
  • /ByteRange — 簽章字典中的陣列,指出簽章摘要所涵蓋的兩段位元組分段(除了簽章值本身以外的所有內容)。
  • 已簽署位元組範圍 — 用來計算簽章摘要的那些確切位元組。增量更新的存在,就是為了讓這些位元組永遠不會被移動或覆寫。