技术文章

Delphi 中的 AES-256 PDF 加密:HotPDF 配置与陷阱

PDF 的权限标志不是一把锁。它是文件向打开它的东西提出的一项请求,而查看器完全可以置之不理。就这一个事实,决定了你该如何看待本文的其他每一个选择。真正的机密性只来自一个地方:以读者所没有的口令为密钥的 AES-256 加密。其余的一切,那些「禁止打印」和「禁止复制」的勾选框,都是遵从规范的软件同意去尊重、而怀有恶意的软件不会尊重的策略。把这两层搞混,你交付出去的东西在演示里显得安全,在现场却漏个不停

HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,它通过一小组属性暴露 ISO 32000 的保护模型。这些属性很好设。难的部分在于弄清哪一个买来的是密码学保护、哪一个买来的只是一句客气的建议,并把赋值顺序搞对,好让你要的那种加密真的就是你拿到的那种加密

两个口令各自承诺了什么

PDF 加密定义了两种职责不同的凭据,而把它们混为一谈,是受保护输出代码里最常见的设计错误。用户口令把守解密。没有它,也没有属主口令,遵从规范的阅读器就无法重建文件密钥,内容在密码学意义上仍然不可读。属主口令把守的则是权限设置:拿到属主口令的阅读器会获得完全访问权,无论限制标志怎么写

权限位站在更松软的地基上。打印、内容提取、表单填写:每一项都是查看器读取后自行决定是否尊重的标志(ISO 32000-2 §7.6.4)。加密保护的是字节。权限标志只是指示遵从规范的软件,而且是事后指示。任何用用户口令打开文档的人,内存里已经握着解密后的内容,所以「禁止复制」和「禁止打印」对一个守规矩的查看器有意义,对一个铁了心的查看器毫无意义。请围绕这条线来建立威胁模型。机密性住在用户口令里。权限塑造的是主流查看器提供哪些操作,而这就是它们的全部作用

HotPDF 示意图:加密 PDF 的凭据模型中,用户口令派生文件密钥并解锁解密,属主口令授予完全访问并凌驾于权限标志之上,警示条注明 ProtectOptions 权限位只是请求、仅由遵从规范的软件兑现
用户口令承载机密性,而属主口令只是解除限制;权限标志引导守规矩的查看器,对怀有恶意者毫无约束力

配置顺序:一切都要在 BeginDoc 之前

HotPDF 在 BeginDoc 运行的那一刻构建加密字典并派生文件密钥。那一瞬间各保护属性的取值就是文档拿到的取值,之后再改它们什么也改不了。这里最要紧的属性是 CryptKeyLength,它从 THPDFKeyType 的取值 k40k128aes128aes256 中挑出方案。在 BeginDoc 之后再赋值,你不会收到异常,也不会收到警告,只会得到一份悄悄沿用了原有设置的文件。这类无声的偏离是最糟糕的一种:它能通过每一次本地测试,几个月后作为一条合规检查项出现在客户的桌上

并排的 Delphi 流程对比:CryptKeyLength 设为 aes256 等保护属性在 BeginDoc 之前赋值可产出 AES-256 文件,而同样的赋值放在 BeginDoc 之后则原方案纹丝不动,既无异常也无警告
BeginDoc 会冻结加密字典,因此顺序正确的那条线产出你所请求的 AES-256 修订,而顺序错误的那条线悄悄交付了默认方案
var
  Pdf: THotPDF;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.FileName := 'statement.pdf';
    Pdf.ActivateProtection := True;
    Pdf.CryptKeyLength := aes256;        // 必须在 BeginDoc 之前设定
    Pdf.UserPassword := 'open-secret';
    Pdf.OwnerPassword := 'admin-secret';
    Pdf.UseAES256R6 := False;            // R=5:查看器兼容面最广
    Pdf.BeginDoc;
    Pdf.CurrentPage.SetFont('Arial', [], 11);
    Pdf.CurrentPage.TextOut(50, 720, 0, 'Account statement, June 2026');
    Pdf.EndDoc;
  finally
    Pdf.Free;
  end;
end;

口令是 UTF-8 编码且上限为 127 字节,这是 ISO 32000-2 为 AES-256 各方案设下的限制。如果你的口令策略给出更长的秘密,请自己动手截断,在你这一侧,由你精确掌控切在哪里。把它交给运气,库和某个未来的查看器就可能在切点上意见不合,产出一份你自己打得开、换个地方却拒绝同一个口令的文件

修订 5 还是修订 6:一个布尔值,两个生态

UseAES256R6 在两种 AES-256 握手之间做选择,而这个选择的分量比它的布尔类型所暗示的要重。把它留在 False,HotPDF 写出修订 5,也就是作为 PDF 1.7 扩展登场的那套 AES-256 方案,大约十五年来的查看器都打得开它。把它设成 True,你得到的是修订 6,即 ISO 32000-2 为 PDF 2.0 标准化的加固版密钥派生,它堵上了修订 5 在校验口令方式上的一个已知弱点

所以从密码学上讲修订 6 是更好的故事。它也是会弄坏东西的那一个。一份修订 6 文件需要为 PDF 1.7 扩展级别 3 或 PDF 2.0 构建的查看器,而大量已部署的软件两者都不是:档案管理归档系统、其他产品里内嵌的渲染器、多年没人碰过的业务线工具。它们会干脆拒收这份文件,而且会在客户的机器上拒收,永远不在你的机器上。因此务实的默认值是修订 5。只有当某条安全策略按修订号点名 ISO 32000-2、并且你确实确认过每一个消费方都读得了它时,才去动修订 6。无论选哪个,都把你选了哪个以及为什么写下来,因为下一个碰这段代码的人一定会疑惑

更老的那些密钥类型值得用一句话交代,好让你知道跳过它们。THPDFKeyType 里仍然列着 k40k128aes128,但它们的存在是为了复现历史归档,不是为了保护新文件。40 位 RC4 在消费级硬件面前就会倒下,而那些 128 位方案早于任何当代安全评审都会期待的 AES-256 修订。对一份你在 2026 年创建的文档,真正的问题只是修订 5 还是修订 6;如果你发现自己在一个新设计里伸手去拿遗留类型,那上游一定有什么出了问题

不设开启口令的权限标志

需求常常与保密恰恰相反。谁都应该能读这份文档,但打印或提取要受限。你用一个空的用户口令加一个非空的属主口令来表达这一点,PDF 称之为开启口令模式,然后在 ProtectOptions 中列出你想允许的操作

Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aes256;
Pdf.UserPassword := '';                      // 任何人都能打开这份文件
Pdf.OwnerPassword := 'rotate-me-quarterly';  // 把守着权限集合
Pdf.ProtectOptions := [prPrint, prPrint12bit, prExtractContent];
Pdf.BeginDoc;
// ... 页面内容 ...
Pdf.EndDoc;

THPDFProtectOptions 集合映射到 ISO 的权限位上:prPrintprPrint12bit 对应高分辨率打印,prInformationCopy 对应一般的复制与提取,prExtractContent 对应辅助技术的提取,另外还有 prModifyStructureprEditAnnotationsprFillAnnotationsprAssemble。其中两项值得警示。在你构建的几乎每一套配置里都请保持 prExtractContent 打开。屏幕阅读器正是靠这一位才够得着文本,而清掉它会悄悄把一项权利决定变成一个无障碍缺陷,由某位残障用户撞上,而你永远看不见。另一个陷阱是单独打开 prPrint 却不带 prPrint12bit:好几款查看器的反应是降低打印质量,而你的用户会把它当成渲染缺陷来报,而不是当成它实际所是的权限设置

验证只要五分钟,应当写进你的发布检查表。把每套配置的样本文件在 Acrobat 里打开,打开文档属性,读「安全性」选项卡,那里会写明算法("AES 256-bit")并逐条列出允许的操作。然后把同一份文件在你的客户实际还在用的最老那款查看器里打开,而不是在你机器上最新的那款里。第二次打开,是防止一份修订 6 文件在开发阶段一路顺风、却在某个从未升级的客户那里猝死的廉价保险

解除既有文件上的保护

解密把同一套属性模型倒着跑。用一个有效凭据加载文档,把保护关掉,再把结果不带保护地保存出来

var
  Pdf: THotPDF;
  PageCount: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    PageCount := Pdf.LoadFromFile('encrypted.pdf', 'open-secret');
    if PageCount > 0 then
    begin
      Pdf.ActivateProtection := False;   // 保存时去掉加密
      Pdf.SaveLoadedDocument('plain.pdf');
    end;
  finally
    Pdf.Free;
  end;
end;

这条路线会把整份文档解析进内存,对普通文件没问题,对巨大文件则是浪费。当输入达到数百 MB 时,DecryptFile 是更省的选项:它在文件级复制过程中完成解密,只要输入条件允许,就走一条跳过构建完整对象树的直接 AES-256 重写路径。它属于 Direct File API,见从 Delphi 处理大型 PDF 的配套文章

与加密相互作用的约束

有两条限制值得在你围绕加密做设计之前、而不是之后就了解。第一条是归档合规。ISO 19005 禁止 PDF/A 中出现加密,因此任何既加密文档又声称符合 PDF/A的工作流,在构造上就是自相矛盾的;HotPDF 不会让你在一个文件里同时拥有两者。当你确实两样都需要时,答案是两份产物:一份用于分发的加密副本,和一份单独的、不加密的归档副本

第二条限制更严峻。PDF 加密没有托管,也没有恢复。丢了一份 R5 或 R6 文件的用户口令,你的选择只有暴力破解或者放弃。所以请像对待任何生产凭据那样对待属主与用户秘密。生成它们,存进保险库,按计划轮换。唯一绝不能做的,是把它们硬编码成某个单元里的常量,那样它们会直接搭车进入版本控制,并永远躺在每位开发者的工作副本里

最后还有一个值得养成的反射。修改一份不是你创建的文件的保护设置,用的是与解密同一套机制,而不是另一项功能:用它的口令经 LoadFromFile 加载它,就地修改 ProtectOptions 或那些口令,再用 SaveLoadedDocument 写回去。只要你能解密一份文件,你就能给它重设权限,而代码和上面那个例子几乎一模一样

本文展示的保护属性是面向 Delphi 与 C++Builder 的标准版 HotPDF Delphi Component 的一部分;产品页上有完整的加密参考,包括完整的权限枚举