使用 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 内部修订建立对应关系,不要默认两者数量相同。
实现时可以按以下顺序处理:
- 确认操作权限,并登记请求与发起者。
- 保留输入文件,生成新的输出版本。
- 重新打开输出文件,确认实际结构变化并计算文件哈希。
- 将版本引用与审计事件关联后,标记操作成功。
如果数据库和文件存储之间不能共享事务,需要安排失败恢复与对账,避免日志显示成功,文件却未保存。
“由 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 差异可用于验证操作结果、分析外部上传文件和补充历史线索;操作者归属仍需要可信的应用记录。
备注:内容仅供参考。
