技术文章

用 PDFium 在 Delphi 里分析签名后的 PDF 变更

要弄清一份 PDF 在签名之后改了什么,面向 Delphi 和 Lazarus 的 PDFium Component 提供 TPdf.AnalyzeSignatureRevisions——一个签名后修订变更分析器,它从原始文件字节重建每个增量修订,把之后的每个对象变更对照该签名的 DocMDP 和 FieldMDP 规则逐项裁定,并把 shadow 对象定义作为独立风险报告。它针对的场景凡是经手合同的人都熟:一份认证表单发出去,回来时多了两次增量保存,每个签名仍然验证通过。这是预期行为,因为签名只覆盖自己那个修订的字节。真正的问题是那些后续保存是否被允许,而签名上的绿色对勾回答不了这个问题

为什么 PDFium 签名 API 看不到签名后改了什么?

PDFium 签名 API 看不到签名后的变更,因为它只读签名字典:/Contents、/ByteRange、/SubFilter 和 DocMDP 权限值。PDFium 没有增量修订图,不解析 FieldMDP transform 参数,也不提供修订之间的对象级 diff,所以 FPdfPades.pas 里的分析器直接在原始字节上干活。这带来一个你应该在设计时就绕开的实际后果。TPdf.AnalyzeSignatureRevisions 读的是文档加载时保留的字节,绝不是 SaveAs 产出的副本,因为重写过的文件已经丢掉了正在被分析的修订结构本身。如果文档来自一个还没下载完的渐进源,报告返回 SourceStatus = pvssIncomplete 和 Status = prasIndeterminate,而不是去分析一份截断的文件

从 startxref、xref 流与 /Prev 重建修订边界

分析器按 ISO 32000-1 §7.5.6 和 §7.5.8 对增量更新的定义,沿着每个 startxref 往回走,穿过经典 xref 表、交叉引用流、混合引用的 /XRefStm 条目和 /Prev 链,重建修订边界。每个签名的覆盖长度是其第二个 ByteRange 区间的终点,分析器把这个长度映射到它落在其 xref 节里的那个修订。没有修订匹配时,签名得到 prrCoveredRevisionNotFound 和 Indeterminate 状态。然后每个对象的状态被重放到覆盖修订为止,每个更晚的 xref 条目都与该状态比较。这件事比听起来重要:有些写入器每次增量保存都重述完整的 xref 表,而仍指向同一个未变对象的条目会被跳过,而不是报成修改。没有这道比较,一次完全合法的表单填写就会被几百条假变更淹没

AnalyzeSignatureRevisions 在 Delphi 里如何从原始 PDF 字节重建增量修订示意图:签名 ByteRange 终点落在覆盖修订之内,xref Prev 链往回穿过每次后续保存,对象状态重放到覆盖修订,未变化的被重述条目被跳过而不是报成变更
签名只覆盖自己那个修订的字节,所以分析器把第二个 ByteRange 区间映射到一个修订,并把每个更晚的 xref 条目对照重放出的对象状态来裁定

Shadow 定义是最值得盯住的情况。一个出现在更晚修订字节范围内、却不被那个修订的 xref 引用的对象体,对普通查看器是不可见的,而这恰恰是 shadow 攻击依赖的那种埋伏:隐藏内容在签名前或签名后埋进去,之后靠翻转一个引用来激活。AnalyzePadesSignatureRevisionsBytes 把这样的对象记录为非权威变更(IsAuthoritative = False),无论权限级别一律裁定 prdSuspicious,并把 prrUnreferencedObjectDefinition 加进风险集。两个相关风险覆盖其他结构花招:prrDuplicateObjectDefinition 在一个 xref 节里同一对象被列出多次时触发,prrSignatureObjectRedefined 在更晚的修订重定义已有签名对象时触发

更晚 PDF 修订字节范围内的 shadow 对象定义示意图:对象体存在但没有任何 xref 条目引用它,查看器永远不会显示,PDFium Component 的 AnalyzeSignatureRevisions 把它记录为非权威、裁定 prdSuspicious,并在重复与签名重定义风险之外提出 prrUnreferencedObjectDefinition
隐藏内容在签名前或签名后埋入、之后靠翻转引用激活,所以未被引用的对象体无论 DocMDP 权限级别如何都裁定为可疑
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

每个签名的 DocMDP 和 FieldMDP 怎么执行?

DocMDP 和 FieldMDP 按签名分开执行,落点在该签名自己的覆盖修订上,所以同一文件里认证签名与更晚的批准签名可以对同一处编辑给出不同裁定。每个更晚的对象先按它的 /Type、/Subtype、/FT 条目,以及它在页面、表单、注释和 DSS 图里扮演的角色,归类到一个 TPadesRevisionChangeKind。凡带着 /JavaScript、/JS、/Launch、/OpenAction、/AA、/RichMedia 或 /EmbeddedFile 的都成为 prckActiveContent。裁定随后遵循 ISO 32000-1 §12.8.2.2:P=1 时除交叉引用数据和验证材料外一切不许;P=2 允许填表和继续签名,但拒绝注释变更;P=3 连注释也允许。页面内容、文档结构、元数据、活动内容和被删对象在任何 DocMDP 级别下都不许,而当签名完全没有 DocMDP 时裁定 prdSuspicious——批准签名在形式上什么都没禁,但读者已经看不到当初签的是什么了

FieldMDP(ISO 32000-1 §12.8.2.4)把表单字段的裁定进一步收窄。pfmaAll 锁所有字段,pfmaInclude 只锁列出的字段,pfmaExclude 锁除列出之外的一切。要套用 Include 或 Exclude,分析器通过 /Parent 链把每个被改字段解析成全限定名,再与锁清单做精确匹配,所以清单里要写末端字段名,别指望父字段名能罩住子字段。名字解析不了、或 transform 用了解析器不认识的动作时,该变更成为 prdIndeterminate 并提出 prrFieldMdpUnresolved。逐变更的裁定随后按最坏优先汇总:Suspicious 排在 Disallowed 之上,Disallowed 之上是 Indeterminate,Indeterminate 之上是 Allowed,所以一个 shadow 对象压得过任意多个合法的字段更新

AnalyzeSignatureRevisions 在 Delphi 里对每个签名后变更套用的裁定流水线示意图:由 Type 和 Subtype 条目得出 TPadesRevisionChangeKind,在覆盖修订上做 P=1 到 P=3 的 DocMDP 裁定,对全限定字段名做 FieldMDP 锁检查,再从 prdSuspicious 到 prdAllowed 做最坏优先汇总
一个 shadow 对象压得过任意多个合法字段更新,因为 Suspicious 排在 Disallowed、Indeterminate 和 Allowed 之上,而某些风险只记录在状态旁边、不拉低状态

为什么有些变更回来的是 Indeterminate 而不是安全?

只要分析器证明不了某个变更是被允许的,它就回来一个 Indeterminate,因为在签名检查里,未知绝不能报成允许。有一个常见情况反而被精确处理:长期验证会加 /DSS 并重写 catalog,在 P=1 下这本该算结构变更。分析器把新旧 catalog 字典里的 /DSS 和 /Extensions 剥掉再比较其余部分;当没有其他差异时,这次重写被当作验证材料更新而放行,于是 B-LT 和 B-LTA 增强不会弄坏认证签名。其余的口子是故意留着的。交叉引用流里的 Type-2 条目指向压缩对象流内部,而分析器在这个安全边界内不展开对象流,所以这些变更以 prckCompressedObject 配 prrCompressedObjectUnresolved 浮出,P=1 下不许、其他情况下 Indeterminate。1024 个修订、100 万个对象号、200 万条报告变更的硬预算会触发 prrResourceLimitExceeded,断掉的 xref 链触发 prrMalformedRevisionChain;两者都以 Indeterminate 收场,绝不以通过收场

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // 某些风险被记录时并不改变 Status,所以要先测它们
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

那个闸门里的顺序是故意的。prrDuplicateObjectDefinition 被加进风险集时本身不拉低 Status,而一个解析不了的 FieldMDP transform 也只在表单字段真的变了时才影响状态,所以只看 Status 的闸门会漏掉报告里已经装着的证据。同时记住这份报告不主张什么。TPadesRevisionAnalysisReport 对 CMS 签名在密码学上是否有效、签名者证书是否链到你信任的根,一概不说。修订分析回答的是签名之后发生了什么,它应该与结构验证和信任验证并肩而立,而不是取而代之

签名时写入 seed value 与 MDP 锁

同样的规则可以在签名时通过 TPadesSignatureFieldOptions 写下——它是 TPadesSignOptions 和 TPadesRemoteSignOptions 共有的 FieldOptions 成员。PDFium 能创建 widget,但写不了 /SV、/Lock、FieldMDP 或 DocMDP transform,也写不了 catalog 的 /Perms 字典,所以组件自己的增量 PAdES 写入器在与签名同一个 xref 更新里产出这些对象。FieldName 设根字段名,RequiredSeedValues 变成 ISO 32000-1 §12.7.4.5 描述的 seed value 字典的 /Ff 位,Reasons、LegalAttestations 和 AcceptableCertificates 约束后续签名者能选什么,LockAction 配 LockFields 写一个间接 /SigFieldLock,而 1 到 3 的 CertificationPermission 把签名变成认证签名。DocMDP 和 FieldMDP 两个 transform 都进签名值上的同一个 /Reference 数组,各自带指向 catalog 的 /Data

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // 只许填表和签名
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // 只锁这些字段
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

手搓这些的话有几处细节容易搞错。Catalog 的 /Perms /DocMDP 必须引用签名值字典,不是 widget 注解,写入器因此把签名值保持为独立间接对象。已有的 /Perms 字典可能已经装着 /UR3 使用权,所以写入器复制它再插入 /DocMDP,而不是整个换掉,遵循 ISO 32000-1 §12.8.4 的权限字典。已带 /DocMDP 的文档会用 EPadesCrypto 拒绝第二个认证签名,不一致的选项同样会被拒:Include 或 Exclude 锁没有字段名、All 锁却带了字段清单、非认证签名上写了法律声明、或根字段名里带句点。远程签名还多一条规则,因为 PreparePadesRemoteSignature 运行时签名证书还未知:在那里设 CertificateRequired 就要求显式给出 AcceptableCertificates 清单,本地签名则可以退回解析出的签名者证书

修订分析是给签名工具箱补上最后一块,而不是替换其中任何一块。先看用 PDFium Component 检查 PDF 签名与 PAdES 等级读懂字典和基线等级,再看验证器为什么拒 PAdES 签名了解排在任何修订问题之前的结构失败,然后把裁定并入更广的PDF 安全风险审计,与 JavaScript 和嵌入文件检查放在一起。TPdf.AnalyzeSignatureRevisions、TPadesSignatureFieldOptions 和这里展示的增量 PAdES 写入器都随面向 Delphi、C++Builder 和 Lazarus 的 PDFium Component 发布