技术文章

在 Delphi 中审计 PDF 加密与权限

权限标志不是安全机制。那个写着「禁止复制」的比特位,和密码学参数一起住在同一个 /Encrypt 字典里,这让它平白带上了一层执行力的气息,而它并不具备;一旦你把两者当成一回事,审计就会开始给出错误答案。对一份 PDF 值得问的问题不是「它加密了吗」。真正的问题更具体也更难:用的是哪种算法,哪一版安全处理器,两个口令中设了哪一个,声明了哪些权限位,以及加密实际覆盖到文件的哪些部分。一份文件可以在形式上加了密而在实际上敞开着。它可以拒绝被读取,却把元数据留在明文里。它可以用一个任何查看器都能自由忽略的标志去锁定打印。审计一份 PDF,意味着把这些问题分别解开,而 losLab 面向 Delphi 与 C++Builder 的 PDF 引擎 PDF Library for Delphi,通过扁平的整数句柄 API 和带类型的类层这两条路径,把它们逐一暴露出来

/Encrypt 字典到底记录了什么

ISO 32000-1 §7.6 用为数不多的几个字典条目定义了文档安全,而 PDF Library for Delphi 在 TPDFEncryption 记录中把它们一一对应地映射了出来。过滤器版本 V 和修订号 R 决定算法族。Length 承载密钥长度。权限位落在 P 里,属主口令与用户口令的校验串分别落在 OU(AES-256 还额外加上 OEUE),旁边还挂着一个 EncryptMetadata 标志,另有三个字段分别指明施加于字符串、流和嵌入文件的加密过滤器

这条记录的价值在于它不替你做任何解释。它把原始字典交还给你,让你自己得出结论,而这正是审计所需要的。加密外壳里裹着明文的情形会显现在 StringFilterIdentityStreamFilterIdentity 上:其中任何一个为真,对应的数据就原封不动地走 Identity 过滤器通过,不管文档的加密状态报告的是什么。一个只看到「存在 /Encrypt 字典」就收手的扫描器,会把这种文件判为受保护,而它的字符串和流其实明明白白摊在那里。元数据也受同一套细节支配。当 EncryptMetadata 为假时,XMP 包对任何索引器都可读,而页面内容不可读;一旦你的路由规则要依据标题或作者字段来决策,这一点就立刻变得关键

PDF Library for Delphi 示意图:PDF /Encrypt 字典各字段映射到 TPDFEncryption 审计属性,含 Identity 加密过滤器陷阱
每个 /Encrypt 条目都对应一个 TPDFEncryption 字段,而 Identity 过滤器标志则揭示哪些字符串、流或元数据无论加密状态如何都保持可读

用扁平 API 做一次简短的安全探查

对多数流水线来说,四次扁平调用就能回答日常问题。LoadFromFile 成功时返回 1,文档一旦打开,加密检查器报告的就是它解密后的状态:

var
  PDF: TPDFlib;
begin
  PDF := TPDFlib.Create;
  try
    if PDF.LoadFromFile('contract.pdf', UserPassword) <> 1 then
      raise Exception.Create('Open failed: wrong password or damaged file');
    Writeln('status    : ', PDF.EncryptionStatus);     // 已解密 / 已加密 / 未知
    Writeln('algorithm : ', PDF.EncryptionAlgorithm);  // RC4 族还是 AES 族
    Writeln('strength  : ', PDF.EncryptionStrength);   // 密钥长度等级
    Writeln('owner pw? : ', PDF.CheckPassword(CandidatePassword));
  finally
    PDF.Free;
  end;
end;

CheckPassword 的分量比它那一行签名看上去要重。PDF 定义了两个权力不对等的口令。用户口令是打开文件本身所必需的。属主口令授予全部权利,并凌驾于每一个权限位之上。两种情况下磁盘上的字节完全相同,但以属主口令打开的会话能做用户口令会话做不到的事,所以一次不记录出示了哪种凭据的审计,只记下了一半真相。类层把这个区分变成可查询的。TPDFDocument.HasUserPasswordHasOwnerPassword 报告文件要求什么,而 IsUserPasswordIsOwnerPassword 报告当前会话实际是被哪一个口令打开的。请把这个事实记进日志。永远不要记录口令本身的取值

Strength 阶梯,以及「AES-256」的两种含义

扁平的 EncryptEncryptFile 函数接受一个整数 Strength,它有五个有意义的取值:0 表示 40 位 RC4,1 表示 128 位 RC4,2 表示自 Acrobat 7 起可读的 128 位 AES,3 表示 Acrobat 9 引入的 256 位 AES,4 表示 Acrobat X 及更高版本所要求的 256 位 AES

有意思的地方在于,3 和 4 都被标称为 AES-256,却不是同一套方案。Strength 3 对应安全处理器修订 5,那是 Acrobat 9 发布过、而 ISO 从未采纳的一个过渡设计。Strength 4 对应修订 6,其密钥派生函数经过加固并在 ISO 32000-2 中标准化。对于今天新建的文档,没有任何理由舍 4 取 3。而对审计来说,这道缝隙是决定性的:一条写着「按 ISO 32000-2 的 AES-256」的策略,只有 R6 能满足,一份自称 AES-256 的 R5 文件会通不过这条策略,却能通过一次朴素的强度检查。类层用名字把两者分开,R5 是 esAES256Bit,R6 是 esAES256BitAcroX,而 EncryptionAcroX 属性用一个布尔值直接回答修订号的问题

面向 Delphi 审计的 PDF 加密 Strength 阶梯:从 40 位 RC4 到 AES-256 修订 5 与修订 6 的对比
Strength 3 与 4 都叫 AES-256,但只有修订 6 满足 ISO 32000-2 策略,因此审计必须记录处理器修订号而不只是那个标签

权限位,以及它们附带的密钥长度小字条款

EncodePermissions 把八个标志打包进 EncryptEncryptFile 所期望的那个整数。打印、复制、修改和添加注释构成基础集;填写字段、为无障碍而复制、组装文档和全质量打印构成扩展集。库自带的加密演示直白说出的那条小字条款是:扩展的这四项只在 128 位及以上强度下才生效。全质量打印标志同样适用这条规则:清除它以强制低分辨率打印,40 位文档会置之不理,因为这项降级同样要求 128 位或更强的加密。把一条「仅限低分辨率打印」的策略编码进一份 40 位文件,每个查看器照样会以全质量打印

更深一层的问题是这些位到底由谁来执行,答案是没有任何一方值得你信赖。权限是给遵从规范的阅读器的指示,不是密码学限制。无论允许还是拒绝复制,解密密钥都完全相同,所以一套锁死的权限集合只能让诚实的查看器保持诚实。一个选择忽略这些位的阅读器,面前根本没有任何密码学障碍。如果义务是阻止提取而不只是劝阻提取,那么文件需要一个用户口令,工作流需要围绕它的流程级控制;审计报告应当指明每份文件实际处在这两种机制中的哪一种,而不是把一个权限标志当成一把锁

施加策略,并证明它确实生效了

给既有文件加密并不需要把它们载入对象树。EncryptFile 一次调用就完成从输入到输出的处理,而审计环节会重新打开结果,确认落到磁盘上的到底是什么。随库发布的加密演示遵循的正是这种先写后回读的形态:

var
  PDF: TPDFlib;
  R: Integer;
begin
  PDF := TPDFlib.Create;
  try
    R := PDF.EncryptFile('in.pdf', 'out.pdf', 'owner-secret', 'user-secret', 4,
      PDF.EncodePermissions(1, 0, 0, 0,    // 允许打印;复制、修改、注释均拒绝
                            0, 0, 0, 1));  // 扩展集:仅开放全质量打印
    if (R = 1) and (PDF.LoadFromFile('out.pdf', 'user-secret') = 1) then
    begin
      Writeln('algorithm = ', PDF.EncryptionAlgorithm);
      Writeln('strength  = ', PDF.EncryptionStrength);
      Writeln('owner pw accepted: ', PDF.CheckPassword('owner-secret'));
    end;
  finally
    PDF.Free;
  end;
end;

在文档层工作的团队可以用带类型的集合替代位打包来完成同一操作,代码评审时要眯的眼睛少得多:

if not Doc.Encrypt('owner-secret', 'user-secret', esAES256BitAcroX,
  [ppCanPrint], [ppCanPrintFull]) then
  raise Exception.Create('Encryption failed');

无论走哪条路,回读这一步都不是可有可无的仪式。它能抓住那些否则要等几个月后才在客户机器上浮现的部署失误:一个悄悄把请求强度降级的旧版库、一条因为目录只读而根本没被写入的输出路径、一个参数顺序搞反了的权限整数。这三种都能通过本地冒烟测试而在现场失败,而重新打开输出文件,会把它们各自变成你在生成该文件那次运行中就能看到的异常。GetEncryptionFingerprint 返回一个紧凑的值,可以随作业记录一起保存,这样日后比对时无需重新打开任何一份文件,就能判断两份输出是否共享同一套加密配置

值得专门写代码去规避的审计误报

有几种模式会稳定地把安全扫描器推向错误结论,而每一种都源于把一个多分量的问题坍缩成一个是或否的答案。Identity 加密过滤器是最干净的例子。/Encrypt 字典存在,文件报告为已加密,可字符串和流却原样穿过 Identity 过滤器,于是真正的内容是明文。修法就是在宣布任何东西受保护之前,先读 StringFilterIdentityStreamFilterIdentity

元数据的分裂更微妙。EncryptMetadata 可以在两个方向上与文档其余部分背离,留下一份带可读 XMP 包的加密文件,或者反过来,虽然后者少见。「这份文件加密了」这句话对它的元数据是否也加密只字未提,而一旦索引器或路由规则伸手去取标题,这就要紧了。嵌入文件添加了第三个维度:PDF 允许为附件单独指定一个加密过滤器,因此附件可以是一份原本敞开的文档中唯一加密的部分,也可以是一份加密文档中唯一明文的部分。把字符串、流和嵌入文件这三处过滤器指派作为独立字段分别记录下来,上述陷阱就一个都困不住你。只存一个布尔值,判断出错就只是时间问题

PDF Library for Delphi:审计流程在宣布 PDF 受保护之前先检查 StringFilterIdentity、StreamFilterIdentity、EncryptMetadata 与嵌入文件加密过滤器
四条互相独立的判据共同决定一份看起来已加密的文件是否真的封死,把它们坍缩成一个布尔值迟早会把某份文件归错类

解除加密,以及为新文件选择加密方案

一次审计常常以决定剥除保护而收尾,而在那里机制并不是障碍。DecryptFile(InputFileName, OutputFileName, Password) 无需完整加载就能写出一份解密副本,而针对已加载文档的 Decrypt 则在文件已经打开之后在内存中做同一件事。两者都需要有效口令;都不会绕过密码学。真正的关卡是策略而非代码,所以请让接入规则明确写清何时允许解除,并记录是哪一类口令授权了这次操作,因为技术步骤本身不留下任何痕迹

为新输出所做的选择,比五个 Strength 取值暗示的要窄。请使用 Strength 4,即 AES-256 修订 6,除非你必须在早于 Acrobat X 的查看器中打开文件。Strength 2 即 AES-128,是面向无法升级的老化查看器群体时务实的下限。取值 0 和 1 的 RC4 选项存在,是为了让你能读取和审计历史归档,而不是让你用它们生产任何新东西;在 2026 年的设计里伸手去拿它们,说明上游某条需求已经过时

加密状态会直接喂进签名决策,因为一个校验并签署文档的工作台,需要的正是本次审计所依赖的那套回读纪律。这块地在合规与签名工作台一文中有覆盖。当一个批处理对成千上万份大文档施加 EncryptFile 时,大型 PDF 的直接访问指南展示了如何在运行期间把内存占用压平。完整的加密 API 参考位于 PDF Library for Delphi 产品页