技术文章

Delphi PDF 加密过滤器:StmF、StrF 与 EFF 策略

HotPDF Delphi PDF 组件将 ISO 32000-1 第 7.6.5 节的加密过滤器模型实现为三个相互独立的策略,而不是一个总开关:ConfigureCryptFilterDefaults 分别设置字符串过滤器 /StrF、流过滤器 /StmF 和嵌入文件过滤器 /EFFSetStreamCryptFilter 覆盖单个流,GetLoadedCryptFilterInfo 则报告输入文件声明的内容。大多数加密 PDF 互操作问题都藏在这三者之间的缝隙里

下面这种失败会把人带到这一层。团队交付一个文档,页面内容必须保持可读以供下游工具使用,但附件不能保持可读,于是设置 /EFF /StdCF 并保留 /StmF /Identity。Acrobat 可以正常打开。一个符合规范的第三方阅读器却返回密文垃圾,因为 /EFF 是生产者一侧关于嵌入文件使用哪个过滤器的策略,而通用阅读器仍会通过 /StmF 解析没有显式标记的流。修复不是换一个 /EFF 值,而是在嵌入文件流自身添加显式 /Crypt 过滤器

加密过滤器层实际控制什么

加密过滤器位于加密算法和对象图之间,决定算法会接触哪些对象,而不是决定算法如何工作。加密字典中的 /CF 字典把名称映射到过滤器定义,每个定义带有 /CFM 方法、可选的 /Length/AuthEvent。顶层的 /StrF/StmF/EFF 再选择这些命名过滤器中哪个应用于字符串、没有显式过滤器的流以及嵌入文件。HotPDF 有意限制内置处理器会写入的内容。ConfigureCryptFilterDefaults 只接受当前处理器的保留名称:标准安全处理器输出 /StdCF/Identity,公钥处理器输出 /DefaultCryptFilter/Identity,其他名称会在调用点抛出 EArgumentException。外部生产者以其他名称写入的过滤器,在加载、检查和兼容性重写路径上仍会被保留,因此 HotPDF 作为写入器是保守的,作为读取器则是宽容的。还有两个保护条件:文档序列化开始后调用会抛出 EInvalidOpException,文档处于增量更新时也会抛出,因为同一文件的不同修订版本之间不能改变加密策略

var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.AutoLaunch := False;
    Pdf.FileName := 'wrapper.pdf';
    Pdf.OwnerPassword := 'owner-secret';
    Pdf.UserPassword := 'open-secret';
    Pdf.CryptKeyLength := aes128;
    // 字符串加密,页面流明文,附件加密
    Pdf.ConfigureCryptFilterDefaults('StdCF', 'Identity', 'StdCF');
    Pdf.ActivateProtection := True;
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 12);
    Pdf.CurrentPage.TextOut(50, 50, 0, 'Visible stream operators');
    Pdf.AddDocumentAttachment('payload.bin', 'Encrypted payload');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

有一条约束需要提前说明,因为它会在较晚阶段检查并让人措手不及。HotPDF 中的命名加密过滤器要求文档加密方式为 aes128aes256aesgcm。在 RC4 k40k128 之上配置过滤器策略时,加密启用时运行的验证阶段会直接抛出错误,而不是悄悄提升密钥类型。这与Delphi 中的 AES-256 PDF 加密路径采取同样的设计立场:拒绝含糊配置,而不是猜测调用方的意图

/Length 条目为什么有两种含义

因为规范根据安全处理器定义了两种不同单位,HotPDF 必须同时遵守。在 /CFM/V2 的加密过滤器字典中,标准安全处理器使用字节表示 /Length,公钥处理器则使用表示。与 /V 同级的加密字典 /Length(ISO 32000-1 第 7.6.2 节)始终使用位。读取带有 /Length 16 的过滤器字典时,在标准处理器文件中它代表 128 位密钥,在公钥文件中则会被拒绝。HotPDF 会在捕获加载配置时完成规范化:只有当文件不是公钥加密时,才将 /V2 过滤器的 /Length 乘以八;过滤器缺少自身值时回退到文档级 /Length,并把结果存入 THPDFCryptFilterInfo.KeyLengthBitsAESV2 固定为 128 位,AESV3AESV4 固定为 256 位,因为这些方法没有可协商的密钥大小。严格性体现在下一步:只接受 40 位和 128 位的 /V2。如果过滤器解析出的长度是任何其他值,系统会报告不可用并使操作失败,而不是假定大多数生产者其实想要 128 位后直接取整。静默规范化密钥长度,正是让文件在你的机器上能解密、在其他地方却不能解密的方式

var
  Reader: THotPDF;
  Info: THPDFCryptFilterInfo;
  I: Integer;
begin
  Reader := THotPDF.Create(nil);
  try
    Reader.AutoLaunch := False;
    if Reader.LoadFromFile('incoming.pdf', 'open-secret') <> 1 then
      Exit;
    // /StrF 和 /StmF 默认为 Identity;/EFF 默认为 /StmF
    WriteLn(Reader.LoadedStringCryptFilterName);        // StdCF
    WriteLn(Reader.LoadedStreamCryptFilterName);        // Identity
    WriteLn(Reader.LoadedEmbeddedFileCryptFilterName);  // StdCF
    for I := 0 to Reader.GetLoadedCryptFilterCount - 1 do
      if Reader.GetLoadedCryptFilterInfo(I, Info) then
        if (Info.Method = hcfmV2) and
           not (Info.KeyLengthBits in [40, 128]) then
          raise Exception.CreateFmt(
            'crypt filter /%s: unsupported V2 key length %d',
            [String(Info.Name), Info.KeyLengthBits]);
  finally
    Reader.Free;
  end;
end;

/CFM /None 保证什么,/Identity 又有何不同

它们通过不同路径得到相同结果,把二者混为一谈会破坏查找。命名过滤器的 /CFM/None,以及完全省略 /CFM 的命名过滤器,都表示该过滤器不进行加密或解密——HotPDF 会在解析前把缺失条目映射到 None,所以二者最终都落到 hcfmNone,记录的密钥长度为零。/Identity 在性质上不同:它是绕过 /CF 查找的保留名称,因此文档可以引用 /Identity,而不必在 /CF 中定义它。PDF 名称区分大小写,这使得另一个实现细节不可妥协:任何加密过滤器查找都不能不区分大小写。HotPDF 对 /CF 子字典名称、过滤器 /Length 条目以及流的 /Type 检查,都使用区分大小写的字典查找。文件定义了 /stdcf,而 /StmF 指向 /StdCF 时,文件就是格式错误;把这两个键当成相同键,会把可检测的编写错误变成静默地对文档中每个流应用错误密钥

让 /EFF 在嵌入文件流上真正生效

/EFF/StmF 不同,嵌入文件流的 /Filter 中需要一个显式置于开头的 /Crypt 条目,并且 /DecodeParms 字典数组中同一位置要有携带 /Name 的匹配字典。HotPDF 在保存时按流计算:它检测 /Type /EmbeddedFile,继承配置的嵌入文件过滤器,只有当继承名称不同于有效流默认值时才发出显式 /Crypt 标记。当 /EFF/StmF 一致时不会写标记,因为阅读器无论如何都会解析出同一个过滤器。数组位置与名称同等重要。HotPDF 读回流时,会扫描 /Filter 中的 /Crypt 条目并记录其索引,再到 /DecodeParms 数组的同一索引查找 /Name。索引 0 的 /Crypt 与索引 1 的参数配对时,解析结果是 /Identity,而不是你的过滤器。这也是为什么当流此前有 /Filter 却没有 /DecodeParms 时,写入器会用 null 填充参数数组:位置必须保持对齐

下面还有一个更尖锐的陷阱。如果现有的 /Filter/DecodeParms 是间接对象——在多个流共享一个过滤器数组的生成器文件中很常见——直接插入 /Crypt 会修改共享的过滤器图,破坏所有指向它的其他流。HotPDF 会解析间接对象,先将其克隆为流私有的直接对象,清除对象号和代号,因此原来的间接根不会被嵌入新数组。对于之前使用 ASCIIHexDecode 的流,序列化结果是 /Filter [ /Crypt /ASCIIHexDecode ],并带有 /DecodeParms [ << /Type /CryptFilterDecodeParms /Name /StdCF >> ... ]。同样的位置纪律也适用于其他所有过滤器链,包括你在通过解码过滤器从已加载 PDF 提取图像时遍历的那些过滤器链

// Editor 已经持有一个加载的文档,ContentStream 是一个 THPDFStreamObject,
// 其 /Filter 是间接的 /ASCIIHexDecode 名称
Editor.OwnerPassword := 'owner-secret';
Editor.UserPassword := 'open-secret';
Editor.CryptKeyLength := aes128;
Editor.ConfigureCryptFilterDefaults('StdCF', 'Identity');
Editor.SetStreamCryptFilter(ContentStream, 'StdCF');
Editor.ActivateProtection := True;
Editor.SaveLoadedDocument('out.pdf');

// 空名称会清除覆盖,并在下一次保存时移除过期的 /Crypt
// 条目及其解码参数
Editor.SetStreamCryptFilter(ContentStream, '');
Editor.SaveLoadedDocument('cleared.pdf');

对象流会继承文档的 /Encrypt 策略吗

不会,假设它们会继承,是生成垃圾的可靠方式。对象流必须遵循实际的 /StmF 策略,或遵循自身显式的 /Crypt 标记;仅仅存在 /Encrypt 字典,并不会让每个 /ObjStm 容器都变成密文。/StmF /Identity 的文档拥有明文对象流,即使其中的字符串已完全加密;错误地再次解密这些对象流,就会把从未经过 deflate 的输入送进解压阶段

成员对象的后果值得再读一遍。按照 ISO 32000-1 第 7.5.7 节,已加密对象流内的字符串在容器本身解密后已经是明文,再次解密会变成双重解密。HotPDF 通过查询每个类型 2 对象的容器是否被加密来防止这种情况;如果是已加密容器,就跳过该对象,并将跳过次数累计到 XRefProbeDecryptObjStmSkips,作为保护逻辑确实触发的直接证据。如果容器是明文,成员字符串从未被任何内容覆盖,HotPDF 就会实例化这些成员,并分别应用 /StrF——按照实现的真实方式,密钥使用的是成员对象号和代号,而不是包含它的 /ObjStm 对象号。对混合策略文件反过来处理后,每个压缩对象中的每个字符串都会解码成噪声。相关容器级规则还在PDF 对象流与增量更新的说明中进一步讨论

HotPDF 拒绝猜测的边界

低于 /V 4 的文件不存在加密过滤器语义,因此 HotPDF 会以显式错误拒绝任何逐流覆盖,而不是写入任何符合规范的阅读器都不会理会的 /Crypt 标记。读取侧也一样:/V 低于 4 的加密字典会清除三个已加载过滤器名称,因为那里没有可报告的内容。系统还会有意执行三个边界限制:

  • 公钥加密文档上的非 Identity 逐流过滤器会被拒绝,因为公钥处理器下的流级策略需要 HotPDF 尚未生成的流级接收者封装
  • 当公钥加密嵌入文件的 /EFF 与有效 /StmF 不同时,也会因同样的原因被拒绝,而不是写出一个谁也无法解密的形态
  • AES-256 直接文件快速路径只在字符串、流和嵌入文件都解析到同一种加密过滤器方法,且文件中没有对象带有显式 /Crypt 时适用;混合策略或明文元数据会强制回退到完整对象图路径

这些都不是性能决策,而是标记错误猜测会生成什么样的文件:它可能在一个查看器中打开,在另一个查看器中失败,直到客户报告问题前都不给开发者任何信号。在 ConfigureCryptFilterDefaults 或保存时抛出一个异常,只需要付出一次异常处理成本;静默地为嵌入文件使用错误密钥,则会付出一个支持周期。如果你构建 Delphi 或 C++Builder 软件来生成或消费加密 PDF——选择性保持页面内容明文并加密附件、使用 PDF 2.0 加密载荷封装,或与加密策略并非由你选择的文件互操作——这里介绍的加密过滤器 API 已包含在当前的 HotPDF Delphi PDF 组件中,并与它所依赖的加密、对象流和增量更新路径一起提供