技术文章

Delphi 中的 FieldMDP:检测被锁定表单字段的篡改

PDFlibPas 用 TPDFlib.GetSignatureModificationVerdict 回答 FieldMDP 的问题:给它一份已签名的 PDF、密码和一个签名字段名,这个方法会遍历该签名之后追加的每个增量更新,解析这些更新碰过的每个表单字段的完整限定名,FieldMDP 锁定的字段被改过就返回 3,只动了未锁定字段则返回 2。随后 GetSignatureModificationDetail 报出是哪个修订版、哪个字段

需要这套机制的场景是多审批人表单。申请人在采购申请上填好 Order.Customer 和 Order.Total,签 Approval.Requester,把这两个字段锁死。经理补一个成本中心,签 Approval.Manager。两周后有人用桌面编辑器打开文件,在第三个增量更新里把 Order.Total 往上调了一点。每个签名仍然都能验证通过,因为每个签名只覆盖自己的字节区间。审计人员真正要问的是:哪个字段在哪个签名之后被改了,以及这次改动是否被允许

为什么单靠 DocMDP 抓不到被改的锁定字段?

DocMDP 抓不到它,因为 DocMDP 分类的是改动类型,不是具体字段。ISO 32000-1 §12.8.2.2 的认证权限等级只说后续更新可不可以填表单和签名(P 2)、要不要连注释也禁掉(P 3),而且这条权限对文档里的每个字段一视同仁。改 Order.Total 属于填表单,P 2 认证恰恰被申请人力图禁止的那次改动满足。PDFlibPas 的文档级改动分类覆盖的就是那一层,本文再往细里走一步

FieldMDP 才是字段粒度的那一层。签名字段可以带一个 /Lock 字典(ISO 32000-1 §12.7.4.5),它承载的签名也可以在签名引用里带 FieldMDP 变换(ISO 32000-1 §12.8.2.4)。两者用的是同一套词汇:/Action /All 锁全部字段,/Action /Include 锁 /Fields 里列出的名字,/Action /Exclude 锁 /Fields 之外的一切。v3.586.0 之前,GetSignatureModificationVerdict 只知道后面的修订版动过某个表单字段、不知道是哪个,于是任何带着字段锁、后续更新又超出验证材料的文档都回来个 4——未定。修订版映射现在会为它分类的每个字段相关对象记下一个解析后的字段名,正是这一点把那个 4 变成真正的 2 或 3

每个审批人签名时怎么写字段锁

TPDFlib.SetSignProcessFieldLock 为签名进程产出的下一个签名设置锁。动作 1 是 All,2 是 Include,3 是 Exclude,0 清除锁;Include 和 Exclude 接收一个以分号分隔的完整字段名列表。被签字段最终会同时带上 /Lock 字典和配套的 FieldMDP 变换,严格的验证器要的就是这对组合

PDFlibPas SetSignProcessFieldLock 在 Delphi FieldMDP 签名里的动作码:动作 1 All 锁定所有表单字段、含后续的签名字段,动作 2 Include 锁定名单里的数据字段,动作 3 Exclude 锁定名单以外的一切,动作 0 清除锁
每个审批人自己的数据字段用 Include,或用 Exclude 把后续的签名字段列出来;被拒绝的调用返回 0 且此前的锁保持不变,所以一定要检查返回值
var
  Pdf: TPDFlib;
  SignId: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    SignId := Pdf.NewSignProcessFromFile('purchase-request.pdf', '');
    if SignId = 0 then
      raise Exception.Create('cannot open the form');
    try
      Pdf.SetSignProcessField(SignId, 'Approval.Requester');
      Pdf.SetSignProcessPFXFromFile(SignId, 'requester.pfx', PfxPassword);
      // 2 = Include:只有这些字段被这个签名冻结
      if Pdf.SetSignProcessFieldLock(SignId, 2,
        'Order.Customer;Order.Total') <> 1 then
        raise Exception.Create('field lock rejected');
      Pdf.EndSignProcessToFile(SignId, 'purchase-request-signed.pdf');
      if Pdf.GetSignProcessResult(SignId) <> 1 then
        raise Exception.CreateFmt('signing failed, code %d',
          [Pdf.GetSignProcessResult(SignId)]);
    finally
      Pdf.ReleaseSignProcess(SignId);
    end;
  finally
    Pdf.Free;
  end;
end;

SetSignProcessFieldLock 在 Include 或 Exclude 调用没给名字时返回 0,列表里含空项(比如 'Order.Customer;;Order.Total')时也一样;被拒绝的调用会保留之前配置的锁,所以别想当然认为最后一次调用赢了,去看返回值。签名设置的其余部分——摘要选择、/Contents 预留和结果码——见 PDFlibPas 的 PAdES 签名与验证指南。有一个设计点专属于审批链:请求者签名上的 /All 锁也会把 Approval.Manager 一起冻住,因为签名字段本身也是表单字段。每个审批人自己的数据字段用 Include,或者用 Exclude 把后续签名字段列出来

在整个审批链上跑裁决

GetSignatureModificationVerdict 一次只判一个签名,所以审批链要按签名字段逐个调用。返回值是一个很小的封闭集合,对审核人来说每个值含义都不同:

  • 0:文件或指定签名字段读不出来
  • 1:签名之后没有追加任何东西
  • 2:追加的改动都在有效权限允许范围内,且没有碰任何锁定字段
  • 3:后面的修订版做了不被允许的改动,包括编辑了 FieldMDP 锁定的字段
  • 4:无法给出定论,绝不能当作通过
  • 5:签名没有落在修订版边界上,有字节嵌在它声称覆盖的范围之内
const
  Approvals: array[0..2] of WideString =
    ('Approval.Requester', 'Approval.Manager', 'Approval.Finance');
var
  Pdf: TPDFlib;
  I, Verdict: Integer;
begin
  Pdf := TPDFlib.Create;
  try
    for I := Low(Approvals) to High(Approvals) do
    begin
      Verdict := Pdf.GetSignatureModificationVerdict(
        'purchase-request-final.pdf', FormPassword, Approvals[I]);
      // 两个 getter 描述的都是刚做完的那次调用,读要在下一次调用之前
      Log.Add(Format('%s: verdict %d, MDP level %d, %s',
        [Approvals[I], Verdict, Pdf.GetSignatureEffectiveMDPLevel,
         Pdf.GetSignatureModificationDetail]));
      case Verdict of
        1, 2: ;                                 // 接受
        3: RejectForm(Approvals[I]);            // detail 给出字段和修订版
      else
        QueueForManualReview(Approvals[I]);     // 0, 4, 5
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

拿这份被篡改的采购申请来说:修订版 0 是空白表单,修订版 1 和 2 是两个签名,修订版 3 是那次编辑。因为锁定义是从每个已签名字段汇总来的,Approval.Manager 的裁决已经把请求者的锁算在内,而它之后的更新只有那次编辑本身。那次更新只重写了字段字典时,裁决是 3,detail 读作 Revision 3 changed locked form field "Order.Total", which FieldMDP does not allow. 这一行同时回答了两个审计问题。Approval.Requester 的裁决得先越过经理签的修订版,由于下文局限性一节里的原因它常常报 4,所以最近的签名通常给出最锋利的答案。GetSignatureEffectiveMDPLevel 报告各签名里最小的 DocMDP P 值,没有任何认证时报 3;两个 getter 都只在下一次裁决调用之前有效

被篡改的 Delphi PDF 表单经 PDFlibPas GetSignatureModificationVerdict 读出的增量更新时间线:修订版 1 带着 Order.Customer 和 Order.Total 的 Include 锁,修订版 2 签 Approval.Manager,修订版 3 重写被锁定的 Order.Total 字段,经理签名得到裁决 3,请求者签名得到裁决 4
锁从每个已签名字段汇总,所以 Approval.Manager 的裁决已经把请求者的锁算在内,detail 字符串报出违规字段和修订版,而最早的签名常常落在 4 上

PDFlibPas 怎么知道某个修订版改的是哪个字段?

PDFlibPas 从每个修订版的对象图重建字段名,因为被改的对象很少自带完整名字。带 /FT 或 /V 的字段字典、或者 widget 注解,只在 /T 里存着部分名。ISO 32000-1 §12.7.3.2 描述的完整限定名是从根到叶的部分名链条、以句点连接,所以修订版映射沿 /Parent 链向上走,把沿途找到的每个 /T 前置拼接。每个父节点都按被分类修订版当时或之前的最新定义读取,这样后面某次对父节点更名的操作就不会改写更早编辑的历史

PDFlibPas 在 Delphi 里做 FieldMDP 完整限定名解析:修订版映射从被改的 widget 沿 /Parent 链向上走,把每个 /T 部分名用句点连接前置拼成 Order.Total,再按精确相等或句点前缀与 /Fields 条目匹配,于是锁住 Order 就覆盖所有后代
被改的对象很少自带完整名字:父节点按被分类修订版当时或之前的最新定义读取,名字比较区分大小写,向上走八层即止

解析出的名字与锁的匹配刻意做得简单。名字等于某个 /Fields 条目、或以条目加句点开头即视为匹配,所以锁 Order 连 Order.Total 和其他所有后代一起锁住。All 锁所有名字,Include 锁匹配的名字,Exclude 锁不匹配的一切。锁定义来自文件里每个已签名的签名字段;字段同时带 /Lock 字典和 FieldMDP 变换时用 /Lock 的值。想确切看到每个签名锁了什么,PDFlibDigitalSign 单元里更底层的 TPDFlibSignDoc 类暴露了裁决所读的权限报告:

uses
  PDFlibDigitalSign;

var
  SignDoc: TPDFlibSignDoc;
  Report: TPDFlibSignaturePermissionReport;
  Names: TStringList;
  I, J: Integer;
  Line: string;
begin
  SignDoc := TPDFlibSignDoc.Create;
  Names := TStringList.Create;
  try
    if not SignDoc.Open('purchase-request-final.pdf', FormPassword, False) then
      Exit;
    try
      SignDoc.GetSignatureFieldNames(Names);
      for I := 0 to Names.Count - 1 do
      begin
        SignDoc.GetSignaturePermissionInfoByName(Names[I], Report);
        if not (Report.HasFieldLock or Report.HasFieldMDP) then
          Continue;
        Line := Names[I] + ': ' + string(Report.FieldLockAction);
        for J := 0 to High(Report.FieldNames) do
          Line := Line + ' ' + string(Report.FieldNames[J]);
        if Report.HasFieldLock and Report.HasFieldMDP and
          not Report.FieldMDPMatches then
          Line := Line + ' (lock and FieldMDP transform disagree)';
        Log.Add(Line);
      end;
    finally
      SignDoc.Close;
    end;
  finally
    Names.Free;
    SignDoc.Free;
  end;
end;

加密表单:不传密码就连字段名都读不出来

加密表单必须先解密才能解析任何字段名,所以 GetSignatureModificationVerdict 要 Password 参数不只是为了打开文件。/T 这类字符串按对象加密,写进压缩对象流的字段在所在流解密并解压之前根本读不出来。自 v3.586.0 起,内部修订版映射接收密码,在解析对象流和修订版字典时挂上解密上下文,并把它解码的每个对象都解密——包括用来做差分的更早版本和向上遍历的父节点

只有 Password 非空时,修订版映射里的按对象解密才会开启。因此用空密码对加密文件算出的裁决应视为未经验证,哪怕文档本身无需提示就打开了。把表单密码和工作流记录放在一起,每次调用都传进去

FieldMDP 裁决不覆盖哪些情况

字段检查是对一个很窄的问题的结构性回答,有几种情况落在它之外。预先知道这些,才不会让一个 4 升级成工单:

  • 字段比较只在文档级检查干净通过、且至少一个后续修订版改动了验证材料或时间戳之外的东西之后才运行;DocMDP 违规在任何字段被查看之前就报 3
  • 被分类为签名相关却不带字段名的对象——签名值字典、被重写的 AcroForm 字典、限于 /AcroForm 或 /Perms 的目录改动——会让裁决返回 4 而不是去猜,所以在活动锁之下追加后继审批签名的修订版常常落在 4 上
  • 只有字段字典和 widget 注解会拿到字段名;修订版映射没有为外观流设专门的类,所以连同值一起重新生成外观的编辑器,会在字段检查运行之前被文档级检查当作内容改动拦下,detail 字符串能区分这两种情况
  • 扫描遇到第一个锁定字段或无名条目就停,所以一个 3 只报一个违规字段,不是全部
  • 锁从每个已签名的签名字段汇总、不按时间排序,所以更晚的签名者写的锁也会套用在它之前的更新上;这只可能把结果推向 3,绝不会推向 2
  • 名字按精确且区分大小写的方式比较,/Parent 遍历八层即止,所以异常深的层级会解析成截断的名字
  • 结构校验不过、动作读不出来的 FieldMDP 条目会让裁决返回 4

裁决返回 4 时,GetSignatureRevisionIndex 给出签名结束处的修订版,GetDocumentRevisionChanges 则列出每个后续修订版新增、修改或释放的每个对象及其改动类别。这些足够一个人在一分钟内做出判断。这些修订版如何追加,见 PDFlibPas 的增量更新指南

FAQ

裁决为 2 等于签名有效吗?

不等于。GetSignatureModificationVerdict 只看签名之后追加了什么,从不检查 CMS 签名值、证书链或吊销状态。2 只说明后续更新没越出权限;密码学验证是另一个步骤,两者都要做

为什么第二个审批人签名得到的是 4 而不是 2?

签名会追加签名值字典,通常还会重写 AcroForm 字典之类的表单簿记对象。这些对象不属于任何具名字段,而字段锁活动时,裁决拒绝放行无法归因到字段上的改动。用 GetDocumentRevisionChanges 确认该修订版只包含预期的签名对象

一个名字能锁住一整组字段吗?

能。锁条目匹配具名字段和每个完整限定名以它加句点开头的后代,所以在 Include 锁里列出 Order 会冻住 Order.Customer、Order.Total 和该父节点下任何其他字段

FieldMDP 裁决、字段锁签名和修订版检查都随 PDFlibPas Delphi PDF Library 发布,上面审批流程依赖的 DocMDP 认证与 PAdES 长期验证功能也在其中