跳转到内容

增量更新为什么重要

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 — 签名字典中的数组,指出签名摘要所涵盖的两段字节分段(除了签名值本身以外的所有内容)。
  • 已签署字节范围 — 用来计算签名摘要的那些确切字节。增量更新的存在,就是为了让这些字节永远不会被移动或覆盖写入。