技术文章

Delphi 中 PDF MAC 修订链验证(ISO 32004)

HotPDF 按修订而不是按文件验证 ISO/TS 32004 PDF MAC。THotPDF.ValidatePDFMACChain 从链锚点开始向前走遍每次增量更新,并对照一条止于该修订自身 startxref%%EOF 的只读前缀流验证每个 MAC。最新修订上一个有效 MAC 证明不了它下面那些修订的任何事情

驱动这一切的场景如下。你交付一个带 PDF MAC 的 AES-256 加密 PDF。有人用十六进制编辑器打开文件,翻转第一个受 MAC 保护修订里的一个字节,再追加一个带他自己完全有效 MAC 的全新修订。每个查看器都毫无怨言地打开文件,而拿当前字节范围对活动 trailer 里的 MAC 做哈希的天真检查器报告成功,因为那个 MAC 对它覆盖的字节而言确实是对的。损伤坐在往下两个修订处,在没有任何人复查过的区域里

为什么顶层 MAC 有效证明不了文件完好?

因为 PDF MAC 覆盖的是前缀,不是文档。增量更新是该格式的一等公民:每次保存追加一个新主体、一个新交叉引用节和一个新 trailer,旧字节原地不动。ISO/TS 32004 骑在这个模型上,于是每个修订携带自己的 /AuthCode 字典,认证文件彼刻的样子,只验最新一个等于放过所有更早修订。所以 HotPDF 把两个问题暴露成两个调用,它们之间的差别正是本文全部要点。ValidatePDFMAC 回答“当前修订是否可信”,填 THPDFPDFMACValidationInfo 记录;ValidatePDFMACChain 回答“此文件中每个受 MAC 保护的修订是否可信”,填带每修订数组加机器可读失败原因的 THPDFPDFMACChainValidationInfo。在上面那个先篡改再重新加 MAC 的文件上,第一个调用返回 True,第二个在修订索引 1 处返回 False

var
  Pdf: THotPDF;
  Chain: THPDFPDFMACChainValidationInfo;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    if not Pdf.ValidatePDFMACChain('incoming.pdf', 'user', Chain) then
    begin
      // 失败原因为 pmcfRevisionBoundary、pmcfNoPDFMAC、
      // pmcfRequiredRevisionMissing、pmcfRevisionInvalid、
      // pmcfKDFSaltChanged、pmcfDigestDowngrade、pmcfPermissionDowngrade 之一
      Writeln('chain rejected: ', Chain.Message);
      Writeln('revision ', Chain.FailureRevisionIndex,
              ' at xref offset ', Chain.FailureXRefOffset);
      Exit;
    end;
    for I := 0 to High(Chain.Revisions) do
      Writeln(I, ' len=', Chain.Revisions[I].RevisionLength,
              ' mac=', Chain.Revisions[I].HasPDFMAC,
              ' perms=', Chain.Revisions[I].PermissionsAuthenticated);
  finally
    Pdf.Free;
  end;
end;

每个 MAC 在自己的前缀流上验证,绝不用最终文件长度

这个领域最贵的缺陷是重哈希旧修订时拿最终文件大小当上界,它把尾部字节折进除最新修订外的每个摘要,在完好文件上报告篡改。HotPDF 的做法是为每个修订重建一条有界只读流,止于该修订自己的 startxref 值加随后的 %%EOF,只哈希这一段。定位边界比看上去琐碎:字面量 %%EOF 可以出现在内容流或字符串里,所以候选位置仅当紧挨其前的 startxref 解析出等于被验证节交叉引用偏移的数字、且两者之间只有空白时才被接受。随后修订恰好吸收标记后的一个行尾序列,即单个 CR、单个 LF、或一对 CRLF,绝不多吃。最后这条规则实践中会咬人,因为在两个修订之间多输出一个空行的写出端产出的是属于下一个修订的字节,把所有尾部空白吞进前一个修订会悄悄改掉两个摘要。节枚举遵循同一纪律:HotPDF 从最老到最新恰好走一遍交叉引用节,重放 free、direct 和对象流条目,让后到的节覆盖早先状态,这与活动 xref 解析器的先到先赢语义正好相反

HotPDF 对照止于该修订自身 startxref 与文件尾标记的前缀流验证每个 ISO 32004 PDF MAC,于是在修订 1 内翻转一个字节会让链失败,即便最新 MAC 仍干净通过
每个修订的 MAC 在自己的有界前缀上重哈希,于是篡改修订 1 再追加一个新加 MAC 的修订仍能满足 ValidatePDFMAC,而 ValidatePDFMACChain 落在修订 1

链锚在哪里,什么会弄断它?

第一个携带有效 /AuthCode 的修订是锚点,FirstMACRevisionIndex 报告保护从哪里开始;它之前的一切按构造不受保护,这很正常。它之后的一切必须受 MAC 保护,于是给一个受 MAC 保护的文件追加一次普通增量更新会以 pmcfRequiredRevisionMissing 和肇事修订索引失败,容忍缺口等于让攻击者只要再保存一次就能剥掉保护。链上还有三条不变量,各有自己的失败码

  • pmcfKDFSaltChanged —— 从锚点起 /KDFSalt 必须保持稳定,因为会轮换的盐让伪造者能在自选参数下重推密钥
  • pmcfDigestDowngrade —— 摘要强度与最后一个已验证的 MAC 比较,而不是与紧挨的前一修订比较,于是一条以 Modern 档 SHA-384 起步的链不能悄悄用 SHA-256 续下去
  • pmcfPermissionDowngrade —— 一个修订不得清除更早修订已认证过的 PDF MAC 要求

值得内化的结论是:历史 MAC 即便不再是活动 trailer 也各自独立受验。这正是开头那个改旧修订再追加新 MAC 的攻击活不下来的原因:最新 MAC 自己过得去,ValidatePDFMAC 很开心,而链仍然带着 pmcfRevisionInvalid 落在修订 1

签名顺序:trailer 键先行,signatureDigest 最后

当 MAC 挂在 CMS 签名上而不是独立存在时,写出顺序不再是风格问题。HotPDF 要求 /AuthCode/KDFSalt、ISO 32004 开发者扩展和 /SigObjRef 在签名 /ByteRange 被计算之前写进同一修订;之后追加任何一个,那些字节就落在签名覆盖范围之外,产出一个签名能验而 MAC 绑定未签的文件。两个摘要随后按相反方向走,乍看像循环依赖,其实不是。PDF MAC 的 signatureDigest 绑定 CMS SignerInfo.signature OCTET STRING 的原始内容字节,不是整个 CMS DER,也不是已签属性,所以它在原始签名值存在之后构造,以 id-attr-pdfMacData 未签属性注入。由于 /Contents 被排除在签名 ByteRange 之外,且未签属性从不喂进签名计算,产出签名、构建 MAC、包成 CMS 这个序列干净闭合,没有密码学循环。两个推论随之而来:/ByteRange 哨兵和 /Contents 占位必须保持明文且不进对象流,即便在加密文件中,否则定宽修补器找不到它们;当 MAC 摘要同为 SHA-256 时直接复用签名摘要,否则两个摘要上下文在对输出流的单次遍历中一并更新

HotPDF 对挂在 CMS 签名上的 PDF MAC 的写出顺序:MAC 各键在丈量 ByteRange 之前进入修订,签名摘要随后由原始 SignerInfo 签名字节构建
在丈量 ByteRange 之前写入 AuthCode、KDFSalt、SigObjRef 和开发者扩展,正是 MAC 绑定留在签名覆盖范围内的原因
var
  Pdf: THotPDF;
  Options: THPDFPDFMACOptions;
  Info: THPDFPDFMACValidationInfo;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'unsigned.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aesgcm;
    Pdf.OwnerPassword := 'owner';
    Pdf.UserPassword := 'user';
    Pdf.ProtectOptions := [prPrint, prExtractContent];
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.AddSignedSignatureField('Approval',
      Rect(72, 120, 280, 160), 16384);
    Pdf.EndDoc;

    Options := THPDFPDFMACOptions.Modern;      // SHA-384 文档摘要
    if THotPDF.SignPDFWithPFXAndAttachedPDFMAC('unsigned.pdf',
         'signed.pdf', 'signer.pfx', 'pfx-secret', 'user', Options) then
      if Pdf.ValidatePDFMAC('signed.pdf', 'user', Options, Info) then
      begin
        // Location = pmlAttachedToSignature,两个摘要分别报告
        Writeln('signature object  : ', Info.SignatureObjectNumber);
        Writeln('signature digest  : ', Info.SignatureDigestMatched);
        Writeln('full file coverage: ', Info.FullFileCoverage);
        Writeln('perms authentic   : ', Info.PermissionsAuthenticated);
      end;
  finally
    Pdf.Free;
  end;
end;

验证从另一端重走同一条路:从当前活动的经典交叉引用 trailer 读直接 /AuthCode,跟随带生成号感知的间接 /SigObjRef,确认它绑定那个签名域的 /V,并把文档摘要失败与签名摘要失败分开报告。那是两种不同的诊断,折叠成单个布尔值等于丢掉唯一能说明动的是页面内容还是签名值的信息。如果你已在做 CMS 工作,这与 PAdES 签名一文在已加载文档中验证签名的指南并排

绝不信任 /P:先解密 16 字节 /Perms

ISO/TS 32004 通过权限位 13 发出“本文档要求 PDF MAC”的信号,而显然的读法是错的,因为加密字典里的 /P 整数是明文且未认证,任何人都能在文本编辑器里翻转那一位来降级要求。ISO 32000-2 §7.6 在 /Perms 条目里给了答案,HotPDF 就用它:用文件加密密钥在 AES-256 CBC、零 IV、无填充下解密 16 字节 /Perms 字符串,然后先检查明文的每个字段再相信任何东西。字节 1 到 4 以小端序装权限值,必须与 /P 整数精确相等;字节 5 到 8 是 0xFF;字节 9 是 TF 元数据加密标志;字节 10 到 12 是字面标记 adb。只有全部成立,PermissionsAuthenticated 才变 True,位 13 才被读取,还要注意它的极性,因为 MAC 要求是在 0x1000清零时断言的。/P 与解密权限不一致不是记条日志就放过的警告;它是伪造的权限集,正确响应是失败关闭

HotPDF 通过用文件加密密钥解密十六字节 Perms 字符串来认证 PDF 权限,并在读取位 13 之前检查小端权限值、FF 填充字节、元数据标志和 adb 标记
明文 /P 整数未经认证,所以只有解密后 /Perms 的每个字段都通过检查,才会读取 PDF MAC 要求

算法敏捷性止于摘要

ISO/TS 32004 让你选文档摘要,也只能选文档摘要。HotPDF 在下层固定保留 HMAC-SHA-256 做认证、按 RFC 5869 的 HKDF-SHA-256 做密钥派生、按 RFC 3394 的 AES-256 密钥包装,上面才是跨 pmdaSHA256pmdaSHA3_512 的可变 THPDFPDFMACDigestAlgorithm,因为自然的错法是把“SHA3-512 档”当成连 HMAC 一起换的许可证,产出的文件在任何可互操作意义上都不再是 PDF MAC。一个实现细节值得你自己写验证器时照抄:在哈希字节范围之前从 CMS AuthenticatedData 读出摘要 OID,因为硬编码 SHA-256 再事后调和会把敏捷性变成一张标签,还会让敌意文件骗你先流完整个文档才发现算法从不被支持。CMSAlgorithmProtectionAuthenticatedData 摘要算法、完整性信息 messageDigest 和字节范围摘要必须同指一个算法,任何不一致都失败关闭

var
  Options: THPDFPDFMACOptions;
begin
  Options := THPDFPDFMACOptions.Compatibility;  // SHA-256,接受全部六种
  Options := THPDFPDFMACOptions.Modern;         // SHA-384,拒绝 256 位
  Options := THPDFPDFMACOptions.HighAssurance;  // 仅 SHA3-512,AES-GCM

  // 自定义配置合法,但它用来生成的算法
  // 也必须出现在验证白名单里,否则配置
  // 在写出任何字节之前就被拒绝
  Options.Profile := pmppCustom;
  Options.DigestAlgorithm := pmdaSHA512;
  Options.AllowedDigestAlgorithms := [pmdaSHA512, pmdaSHA3_512];
  Options.RequireAESGCM := True;
end;

PDF MAC 证明了什么,没证明什么

一条验证通过的 PDF MAC 链证明:每个受保护修订与持有文件加密密钥者写下的字节逐字节一致,没有受保护修订被移除或重排,锚点之后没有被追加未保护修订。这正是普通 AES-256 加密敞开着的那一类攻击,因为机密性对完整性只字不提,一个被拼接过修订的加密 PDF 解密起来和完好的那个一样欢快。它没证明的是作者身份。MAC 密钥从文件加密密钥派生,于是任何能打开文档的人,包括每个合法收件人,都能为修改版产出有效 MAC;它是对称原语,对称原语无法归因。如果你需要知道改了什么,你需要背后有证书的数字签名,PDF MAC 则补上签名单独覆盖不到的增量结构保护。把它们当作分层,让两个裁决独立报告,而不是塌缩成一个状态图标

本文描述的 PDF MAC 入口点,即 AddStandalonePDFMACSignPDFWithPFXAndAttachedPDFMACValidatePDFMACValidatePDFMACChain,随 Delphi 和 C++Builder 标准版 HotPDF Delphi 组件交付,产品页载有选项记录、状态枚举和每修订验证数组的完整参考