技术文章

对 PDF 签名后的变化分类并执行 DocMDP 策略

对 PDF 的签名并不禁止后续修改。它固定的是一个字节范围,而增量更新会在其后追加新字节,于是签名在数学上依然有效,文档却获得了新内容。这些内容是否可接受是一个策略问题,DocMDP 就是作者声明策略的地方:完全不许修改、只许填写表单和签名,或者再加批注。执行它意味着对实际发生的变化进行分类,这正是 AnalyzeModifications 所做的事。把它指向较早的修订版,然后读取 GetModificationLevel 获得总体判定,并通过各 finding 访问器读取每处差异的级别、对象号与描述

有了这些,DocMDP 执行就坍缩成一次比较:计算出的级别是否不高于策略允许的级别

PDFlibPas 修改级别阶梯从 mlNone 到 mlUnclassified 的图示,展示在 Delphi 中把 DocMDP 策略执行化为一次比较
TPLModificationLevel 阶梯从 mlNone 到 mlUnclassified,DocMDP 执行归结为把计算级别与策略比较

为什么已签名的 PDF 本来就会变化

三种正当情况,覆盖你将见到的大多数情形。第二位签署者添加自己的签名。收件人填写作者开放的表单字段。以及长期验证材料的追加:OCSP 响应与 CRL 写入文档安全存储,使签名在响应服务器消失后仍可验证。最后一种不只是被允许——它正是管理良好的归档对已签文档的刻意操作

所以"签名后文件变大了"不携带任何信息。问题永远是添加了什么,而答案必须来自文档状态的比较,而不是盯着字节。追加机制本身在增量更新一文中介绍

按对象的形状分类,而不是按产生它的路径

分类器看的是变更后对象是什么,而不是哪个库调用创建了它。这是刻意的,因为分析要面对其他软件产出的文件,那里没有调用路径可查

识别四种形状。文档安全存储与验证相关信息字典、交叉引用流对象、catalog 的 metadata 条目,以及携带字节范围的签名字典,属于长期归档材料。同时携带字段类型与字段值的对象是表单填写。类型为 annotation、或 subtype 属于 ISO 32000-2 表 168 所列之一的对象,是批注变更。其余一切都是未分类

PDFlibPas 应用于每个变更 PDF 对象的判定树,把更新归入归档、表单填写、批注或未分类级别
每个变更对象按其身份分类——安全存储、xref 流、字段、批注——绝不按产生它的调用

删除比新增更严格。被移除的对象只有当旧侧对象本身就是归档材料时才进白名单,这覆盖了安全存储被更新的取代的正常情形。其余一切删除都是未分类,因为从已签文档中删除内容不是任何权限级别会授权的行为。文档级差异更严格:页数变化直接判为未分类,不检查单个对象,因为没有哪个 DocMDP 级别允许增删页面

白名单宁可错拒

这是支配每个边界情形决策的设计规则。被错判为允许的变更,是一个能在作者从未授权的内容上通过验证的签名。被错判为未分类的变更,是一份被标记出来交由人工审查的文档。两种错误不对称,所以白名单保持狭窄,识别不了的形状落入未分类,而不是靠猜测

这有一个值得预见的实际后果:来自少见产出方的文件有时会报告未分类变更,检查后其实无害。正确的应对是查看 finding 详情与对象号,而不是放宽白名单,因为一个为了消音个别报告而不断膨胀的白名单,就不再是安全控制了

uses
  PDFlibrary, PDFlibCompare;

var
  Pdf: TPDFlib;
  I, Level: Integer;
begin
  Pdf := TPDFlib.Create(nil);
  try
    Pdf.LoadFromFile('contract-countersigned.pdf', '');
    if Pdf.AnalyzeModifications('contract-as-signed.pdf', '') < 0 then
      raise Exception.Create('the earlier revision could not be loaded');

    // TPLModificationLevel 依次为 mlNone、mlLTAUpdates、mlFormFilling、
    // mlAnnotations、mlUnclassified;getter 返回其序数
    Level := Pdf.GetModificationLevel;
    // DocMDP 执行此时只剩一次与策略的比较
    if Level > Ord(mlFormFilling) then
      for I := 0 to Pdf.GetModificationFindingCount - 1 do
        Report.Add(Format('object %d, level %d: %s',
          [Pdf.GetModificationFindingObjNum(I),
           Pdf.GetModificationFindingLevel(I),
           Pdf.GetModificationFindingDetail(I)]));
  finally
    Pdf.Free;
  end;
end;

总体级别是所有 finding 的最大值,这是唯一站得住的聚合方式:一份含九十九笔归档新增和一处未分类变更的文档,就是一份有未分类变更的文档

底层是指纹,不是加密哈希

CompareWith 暴露的比较引擎——修改分析正是构建在其上——用非加密的 64 位哈希而非 SHA-256,以对象规范化主体的指纹来标识对象。这是深思熟虑的选择。结构比较需要的是确定性:同一次运行内,同一对象主体必须始终产生同一指纹。它不需要抗碰撞,因为能同时控制比较两侧的攻击者早就通过其他手段得手了;而对百万对象文档中的每个对象都支付完整加密哈希的成本,毫无收益

有两条规范化规则比哈希选择更重要。间接引用折叠为占位符标记,而不是展开成被引用内容:展开会把共享对象的主体复制进每个引用者,于是对共享字体描述符的一次小改动就会让所有可达它的对象的指纹失效,报告将不可读。对象号本身也被排除在指纹之外,因为重写可以在不改变任何语义的情况下重新编号对象

匹配随后分两遍进行:先按指纹对齐,再按对象号配对剩余项,从而识别为变更而不是一增一删。全程廉价检查先行:页数差异在任何对象遍历开始之前就报告

PDFlibPas 的两遍 PDF 修订版 diff:先查页数,64 位指纹,先按指纹对齐再按对象号配对
比较引擎对规范化对象主体取指纹,先报告页数差异,再按指纹与对象号匹配

一个陷阱:自比较不保证完全一致

对一个 diff 引擎最自然的第一个测试,是把文件与自身比较并断言结果完全一致。这个断言在这里不成立,原因很有启发。公开加载路径与更底层的文档加载路径配置解码的方式不同,所以同一文件经两条路径加载,某些对象可能产生不同的指纹。引擎没有错;两次加载确实产生了不同的内存状态

与其强行把两条路径捏在一起,不如把比较语义限定得窄一些:分析比较的是当前文档状态与较早修订版,仅当两个指纹集合完全重合才报告一致。这才是用户真正问的问题,而且它不要求两个加载器可互换。设计比较功能时,定义"相同"意味着什么,比计算相同更费工夫

在哪里使用它

两个地方。一是在验证报告里,与签名检查并列,让审查者不仅看到签名在密码学上是否完好,还看到之后文档发生了什么;签名侧见PAdES 签名与验证。二是在入口闸门上,把外部到来的文档与你发出的副本比对,这样带回的合同带一笔新增批注与被编辑过页面将被区别对待

关于范围要提醒一句。这个分析告诉你同一文档谱系的两个修订版之间变了什么。它不告诉你可见内容是否有误导性、表单字段的外观流与其值是否匹配,或者覆盖层下隐藏的文本是否还留在内容流里。这些需要单独处理,内容移除方面见真正删改一文。分析与比较入口的文档列在 losLab PDF Developer Library 产品页