PAYATHON 2026

使用 Apache PDFBox 移除 PDF 数字签名时,如何维护版本历史和审计记录?

数据小陈

结论:由应用记录操作,由 PDF 比较确认变化

建议保留原始版本,并在外部记录审计事件。PDFBox 用于读取签名字段、比较文档状态;应用负责记录操作者、执行服务、时间和原因,并保留变更前后的文件。

比较 PDF 可以发现签名字段消失等结构变化,但仅凭这些变化,无法可靠判断是谁删除的、为什么删除。XMP、自定义 dictionary 或 annotation 可以携带审计信息,但不宜作为唯一的可信记录。

如果只剩最终 PDF,历史数据又已被重写或丢弃,就无法保证恢复完整的操作历史,也无法补出操作者。

先区分签名字段移除与签名失效

问题中的示例是创建文档、User A 签名,然后移除签名字段或签名失效。最后两种情况需要分别处理。

变化能说明什么
签名字段从表单字段树中消失当前表单结构已不再包含该字段
字段仍在,但 /V 签名值被清除字段保留,关联的签名值发生变化
仅移除可见 widget 或外观页面显示变化,不能据此断言数字签名被删除
签名验证失败需要进一步判断字节完整性、证书信任或文档修改权限等原因

规范的增量更新会保留先前字节。因此,即使当前版本不再显示签名,旧版本中的签名数据仍可能存在。最后一次修订删除了签名,并不意味着历史痕迹消失。Adobe 的签名文档介绍了通过增量更新保留和查看已签署版本的机制。Adobe:PDF 数字签名

还需要分别判断旧签名覆盖的字节是否完整,以及后续修改是否符合签名的修改权限。增量保存保留了旧字节,但后续修改能否被验证器接受,还取决于修改内容和相关权限限制。PDF Association:数字签名与增量更新

外部审计日志应记录哪些信息?

建议每次受控修改都生成独立事件,至少包括:

  • 文档 ID、变更前后版本 ID,以及目标签名的应用内部 ID。
  • 操作类型,例如移除字段、清除签名值或移除可见外观。
  • 发起者与实际执行者。
  • 服务端记录的时间、原因、请求 ID 或任务 ID。
  • 变更前后文件的存储位置与 SHA-256。
  • 执行结果,区分成功、失败与待完成。

版本文件应保留原始字节,避免覆盖已签署的原件。审计记录应限制修改和删除权限。如需更强的完整性保障,可以采用受控的追加写存储、独立签名或可信时间戳。单独存储文件哈希无法防止有权限的人同时替换文件和哈希。

一次业务操作不一定只产生一次 PDF incremental update。业务版本号应与 PDF 内部修订建立对应关系,不要默认两者数量相同。

实现时可以按以下顺序处理:

  1. 确认操作权限,并登记请求与发起者。
  2. 保留输入文件,生成新的输出版本。
  3. 重新打开输出文件,确认实际结构变化并计算文件哈希。
  4. 将版本引用与审计事件关联后,标记操作成功。

如果数据库和文件存储之间不能共享事务,需要安排失败恢复与对账,避免日志显示成功,文件却未保存。

“由 John 移除”和“由 System 移除”怎样生成?

这些文字应来自应用审计事件。PDF 中的签署者姓名、修改时间或自定义作者字段,不能直接证明后续删除操作由谁执行。

例如,John 发起请求,后台服务执行删除时,可以保存以下示意事件。字段名属于应用设计,并非 PDF 标准:

{
  "documentId": "doc-123",
  "fromRevision": 2,
  "toRevision": 3,
  "operation": "SIGNATURE_FIELD_REMOVED",
  "targetSignatureId": "sig-456",
  "requestedBy": {
    "type": "USER",
    "id": "user-john"
  },
  "executedBy": {
    "type": "SERVICE",
    "id": "document-worker"
  },
  "status": "SUCCEEDED"
}

界面可以显示:“Revision 3:John 请求移除签名,由 document-worker 执行。”

如果操作由自动规则触发,则记录服务身份、规则和任务 ID,显示“Revision 3:系统自动移除签名”。这比把所有后台操作都记为“System”更容易追查。

如果只能从上传文件的差异中发现字段缺失,应显示“检测到签名字段缺失,操作者未知”。上传者身份不能直接当作删除者身份。

XMP 或自定义元数据适合做什么?

可以在 PDF 中附带文档 ID、版本 ID 和审计事件 ID,方便文件离开系统后与外部记录关联。如果需要完整审计信息随文件流转,也可以提供经过独立保护的审计清单。

在文档中写入“John 删除了签名”,保存的只是一项声明。它的可信度取决于谁生成、谁能够修改,以及是否受到可信签名或外部记录保护。

写入元数据本身也是一次文档修改。对于已签署文档,应检查已有签名的修改限制,并验证最终文件,不能假定添加 XMP 或 annotation 总是被允许。

PDFBox 如何比较两个版本?

较容易维护的方式是保存两个完整版本,分别加载后比较签名字段及其 /V 状态。

以 PDFBox 3.0.4 源码为例,getSignatureFields() 遍历当前文档的表单字段树,getSignatureDictionaries() 从这些字段获取 /V。它们不会自动返回已经脱离当前字段树的所有历史签名,也不提供删除者信息。Apache PDFBox:PDDocument 源码

下面的方法用于生成字段缺失或签名值被清除的候选结果。调用方传入已经加载的前后两个版本:

import java.util.HashMap;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.interactive.form.PDSignatureField;

public final class SignatureDiff {
    private static Map<String, Boolean> snapshot(PDDocument document) {
        Map<String, Boolean> result = new HashMap<>();

        for (PDSignatureField field : document.getSignatureFields()) {
            String name = field.getFullyQualifiedName();
            if (name == null || result.containsKey(name)) {
                throw new IllegalArgumentException(
                    "Comparison requires unique, non-null field names");
            }
            result.put(name, field.getSignature() != null);
        }
        return result;
    }

    public static void compare(PDDocument previous, PDDocument current) {
        Map<String, Boolean> before = snapshot(previous);
        Map<String, Boolean> after = snapshot(current);

        for (Map.Entry<String, Boolean> entry : before.entrySet()) {
            String name = entry.getKey();
            if (!after.containsKey(name)) {
                System.out.println("Field missing: " + name);
            } else if (entry.getValue() && !after.get(name)) {
                System.out.println("Signature value cleared: " + name);
            }
        }
    }
}

这段比较的前提是字段名稳定且唯一。字段改名也会表现为旧名称消失,因此输出只是候选变化,不能直接作为可信的删除事件。生产环境应结合应用内部签名 ID、历史字段映射和操作记录判断;还要单独检查签名值替换、widget 变化以及密码学验证结果。

示例没有加载或关闭文档,资源由调用方管理。PDFBox 3.x 使用 Loader.loadPDF(...) 加载 PDF;2.x 的加载方式不同。PDFBox 3.0 迁移指南

只有一个最终 PDF,还能比较历史修订吗?

如果完整的增量历史仍然存在,可以解析 cross-reference、trailer 和 /Prev 链,识别历史修订边界,重建对应版本后再比较。这个过程需要处理 xref stream、混合结构及解析异常,不能只按字符串搜索 %%EOF 来切分文件。

签名的 /ByteRange 可以帮助分析某次签名覆盖的字节范围,但不能直接提供完整的业务版本列表,也不能告诉你删除者是谁。

外部需要保留可靠版本和业务操作事件,无须逐条记录每个 PDF 对象的变化。PDF 差异可用于验证操作结果、分析外部上传文件和补充历史线索;操作者归属仍需要可信的应用记录。

备注:内容仅供参考。