技术文章

DocMDP 与 FieldMDP:Delphi 中审计 PDF 修订

一份签名后又发生了变化的 PDF,并不必然是被破坏了。ISO 32000-1 允许在签名之上做增量更新,其中只有一部分会违反签名者设定的策略。面向 Delphi 和 C++Builder 的 HotPDF Component 用 AnalyzeLoadedSignatureRevisions 来回答这个问题,它会对签名后的每一次修订分类,并依据 DocMDP 和 FieldMDP 打分。这个场景对任何做合同软件的人都不陌生:客户签署了一份采购协议,发出去后收到的返回件多贴了一页附件。阅读器显示一条黄色提示,说签名完好,但文档自签名后已被修改,而在场没人能说清楚这到底是正常的会签流程,还是有人悄悄编辑了一份已签署的合同

签名之后什么样的改动算合法

当一次改动的语义类别落在签证签名所声明的许可范围之内时,这次改动就是合法的。ISO 32000-1 §12.8.2.2 用 /P 值 1、2 或 3 定义了 DocMDP 转换:1 表示不允许任何改动,2 表示允许填表和签名,3 表示允许填表、签名和添加注释。HotPDF 把这些暴露为 THPDFDocMDPPermission 的取值 dmpNoChangesdmpFormFillAndSigndmpFormFillSignAndAnnotatedmpNone 则专门留给不带任何 DocMDP 转换的检查结果

这些类别是有序的,而这个顺序正是整个检查机制的引擎。THPDFRevisionModificationLevel 排出 rmlNonermlLongTermValidationrmlFormFillAndSignrmlAnnotationsrmlOther,刻意安排成序数越大限制性绝不会更弱。整份文档最终归约为签名之后所有修订中观察到的最高级别,DocMDP 比较也就变成了一次单纯的整数比较。有一点值得早点说清楚:在 dmpNoChanges 级别下,分析依然接受 rmlLongTermValidation。给一份经过签证的文件添加 DSS 和 VRI 验证材料,或者添加一个文档时间戳,属于对签名的维护,不是对文档的修改,如果把它当作违规,就会破坏现存的每一种长期归档流程

HotPDF 如何重建修订链

靠结构,不靠启发式猜测。按 ISO 32000-1 §7.5.6 的规定,一次增量更新会追加一个新的交叉引用节,其 /Prev 指向前一个节,因此 HotPDF 从文件末尾读取 startxref,解析那里的节,沿着 /Prev 往回走并重复这个过程,返回时按最旧到最新排列。这个循环里有两道安全限制,排查失败文件时都值得了解:如果 /Prev 指向一个已经访问过的偏移量,遍历会带着明确的循环诊断信息终止,而不是死循环;链长超过一千个修订会被直接拒绝。这两种情况都会体现在 Analysis.Issue 里,同时函数返回 False,而且两者都不该被粉饰过去,因为一个循环的 /Prev 是格式错误或恶意构造的文件,而不只是一个不寻常的文件

真实文档里会出现四种历史形态,四种都能处理:逐行解析的传统 xref 表;经过解压和解码、依据其 /W/Index 字段读取的交叉引用流;混合引用文件,其传统 trailer 带有一个 /XRefStm 键,被解析后合并进同一个修订(Office 生成器常见的情况,在混合交叉引用流那篇文章里有讲);以及存活在 ObjStm 容器里的对象,这一点很重要,因为现代的更新通常会把变更后的字典放进一个压缩流里,而不是直接写出,正如对象流与增量更新那篇文章所描述的那样。签名充当分割点的锚:/ByteRange[2] + /ByteRange[3] 变成 SignedRevisionLength,所有处在这个偏移量及之后的节都属于签名之后。字节范围是否依然能哈希校验通过是另一个问题,由 VerifyLoadedSignature 回答,在验证 PDF 数字签名那篇文章里有覆盖

每个变更对象是如何被分类的

分类按对象逐个进行,然后沿引用关系传播。对于签名后某个节触及到的每一个对象号,HotPDF 会读取新的正文,以及该对象在签名快照里当时的正文;正文完全一致的归为 rmlNone,因为生成器确实会在不改变内容的情况下重写对象。识别器刻意做得很窄。一个 /Type /DocTimeStamp 对象,或者 /SubFilterETSI.RFC3161 的对象,归为 rmlLongTermValidation,从目录 /DSS 树可达的任何对象也是如此;一个 /Type /Sig 字典归为 rmlFormFillAndSign。对于容器,判断依据是哪些键发生了移动,而不是这个对象本身是什么:目录只允许新增或改动 /DSS/Extensions/AcroForm;AcroForm 字典只允许 /Fields/SigFlags/NeedAppearances/DR/DA/Q;一个页面只允许 /Annots;一个字段或部件只允许 /V/AP/AS/M。落在这些集合之外的一切都会降级为 rmlOther,而这正是那页附加进来的附件页会被逮到的原因:新增一页会以任何白名单都覆盖不到的方式重新排列页面树,任何正当的填表操作都不会像这样

接下来这些级别会向上传播,每个容器继承它所指向的已变更子项中的最高级别,反复迭代直到分配结果稳定下来。这正是外观流能够正常工作的原因。一个被填写的文本字段会重写 /V,并指向一个全新的 /AP 流,而这个流单独看只是一段没有类型可供识别的匿名内容操作符;由于拥有它的那个字段是 rmlFormFillAndSign,这个流会继承同样的级别,而不是落到 rmlOther 里去。同样的传播机制,也把 DSS 上下文带到了原本无法分类的证书和吊销状态流上

为什么一个读不懂的对象也算违规

因为另一种做法,是让校验器被它看不懂的东西给绕过去。三种情形在 HotPDF 里没有申诉余地,一律归入 rmlOther:某个对象的正文在该修订里读不出来、某个对象被该修订标记为已释放、以及不匹配上述任何识别器的对象。每一种都会在修订的 Issue 字段里记录一条具体诊断信息,方便操作人员看清是哪个对象号导致了这个判定

释放是三者中最尖锐的一种。一个签名之后的修订,如果把一个之前已定义的对象标记为已释放,就等于从一份已签署文档里删除了内容,而 §12.8.2.4 之下没有任何许可级别允许这样做;这些对象号会落进 FreedObjectNumbers,该修订被提升为 rmlOther。读不出来的对象出于不同的理由遵循同样的逻辑。一个连对象都解析不了的校验器,没有资格断言它是无害的,对此诚实的回应不是沉默。把一个不寻常但无害的构造报告为违规,代价是需要一次人工复核;反过来的错误,则是让一份被悄悄改动过的已签合同就这么通过了

在 Delphi 中读取判定结果

调用本身很短。加载文档、选定一个签名索引、读取记录即可;无参数的重载会重新打开当初加载文档时所用的那个文件,TStream 重载则接收调用方提供的字节,并在返回前恢复流的位置。PolicyCompliant 是大多数调用方最想要的那个单一布尔值,它综合了三个独立判断:许可字典的结构有效性、DocMDPCompliant,以及 FieldMDPCompliant。请在界面里把这几个分量都单独展示出来,不要把它们折叠成一个值;还要注意,一份没有 DocMDP 转换的文档会让 DocMDPCompliant 保持 True,因为一枚普通的核准签名根本没有声明可以违反的策略,此时汇总出来的 ModificationLevel 只是描述性的,而不是一个判定

var
  Pdf: THotPDF;
  Analysis: THPDFSignatureRevisionAnalysis;
begin
  Pdf := THotPDF.Create(nil);
  try
    if Pdf.LoadFromFile('contract-countersigned.pdf') > 0 then
    begin
      if Pdf.AnalyzeLoadedSignatureRevisions(0, Analysis) then
      begin
        if Analysis.PolicyCompliant then
          Writeln('Post-signature changes stay inside the signing policy')
        else
          Writeln('Policy violation: ', string(Analysis.Issue));
      end
      else
        Writeln('Analysis could not run: ', string(Analysis.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

做排查时,你通常想要的是按修订拆分的明细,而不是汇总结果,因为它能告诉你问题是在文档历史的哪个节点出现的。Analysis.Revisions 里的每个条目都带有它在链条中的索引、写入时所在的交叉引用偏移量、自身的修改级别,以及涉及的对象号

const
  LevelNames: array[THPDFRevisionModificationLevel] of string =
    ('none', 'long-term validation', 'form fill and sign',
     'annotations', 'other');
var
  I: Integer;
begin
  Writeln(Format('%d revisions in chain, signature sits at index %d',
    [Analysis.TotalRevisionCount, Analysis.SignedRevisionIndex]));
  for I := 0 to High(Analysis.Revisions) do
    Writeln(Format('  rev %d at offset %d: %s (%d changed, %d freed) %s',
      [Analysis.Revisions[I].RevisionIndex,
       Analysis.Revisions[I].XRefOffset,
       LevelNames[Analysis.Revisions[I].ModificationLevel],
       Length(Analysis.Revisions[I].ChangedObjectNumbers),
       Length(Analysis.Revisions[I].FreedObjectNumbers),
       string(Analysis.Revisions[I].Issue)]));
end;

FieldMDP 是单独判定的,这是刻意为之的设计

一份文档可以满足 DocMDP,却依然不合规,这正是 FieldMDPCompliant 被设计成一个独立布尔值,而不是并入级别比较里的原因。ISO 32000-1 §12.8.2.4 定义了 FieldMDP 转换,§12.7.5.5 定义了与之相关的 /SigFieldLock 条目,目的是在签名那一刻冻结指定的表单字段,哪怕文档整体依然允许填表。填写一个字段是一个 2 级操作;填写一个被签名者锁定的字段,不管处于哪个级别都是违规。HotPDF 把作用范围读入 THPDFFieldLockAction,取值为 flaAllflaIncludeflaExclude,不带锁定策略的结果用 flaNone;字段名则读入 Permissions.FieldNamesflaAll 锁定所有字段,flaInclude 锁定列出的字段名,flaExclude 锁定除列出字段名之外的所有字段。读取结果时有一个细节要注意,ChangedFieldNames 里只会报告在签名快照里就已经存在的字段,因为一个完全在签名之后才创建的字段没有可供对照的签名状态,它会被 DocMDP 那条路径捕获

var
  Source: TFileStream;
  Analysis: THPDFSignatureRevisionAnalysis;
  I: Integer;
begin
  Source := TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    if Pdf.AnalyzeLoadedSignatureRevisions(0, Source, Analysis) then
      if Analysis.Permissions.HasFieldMDP and (not Analysis.FieldMDPCompliant) then
        for I := 0 to High(Analysis.ChangedFieldNames) do
          Writeln('modified after locking: ',
            string(Analysis.ChangedFieldNames[I]));
  finally
    Source.Free;  // stream position was restored before the call returned
  end;
end;

这项分析不会告诉你什么

它不会验证签名本身。AnalyzeLoadedSignatureRevisions 推理的是结构和许可;已签名的字节范围是否依然能哈希出 CMS 数据块里的那个值,以及签名者证书是否能链接到你信任的东西,这些问题由 VerifyLoadedSignatureVerifyLoadedSignatureWithTrust 来回答。一份文件完全可以在策略上合规,却在密码学上一文不值,所以这两项检查在任何真实的验收环节里都应该并排出现。它也不会解读内容流里的意图:一个内容流被整体替换的页面,会被判定为超出白名单的改动,但分析不会告诉你这次替换换掉的是一个付款金额。rmlOther 的判定意味着该有人来看一看,而不是意味着确实发生了欺诈;合规的判定意味着这次改动符合某个允许的类别,而不是意味着这次改动是被期望的。如果你只需要签名者当初声明了什么、不需要走一遍修订链,GetLoadedSignaturePermissions 会单独返回那些许可字典

这里描述的一切都在 Delphi 和 C++Builder 中原生运行,不需要接入任何外部签名服务,这也是它能被实际用在每一份收到的文档上、而不只是用在已经有人起疑的那几份文档上的原因。完整的签名与修订 API,连同与之配套的许可与验证方法,都是 HotPDF Component(面向 Delphi 和 C++Builder)的一部分