支持工单写着:“对账单在我们的桌面上可以正常打开,但客户的记录管理系统把每个文件都标记为不可读。”这些 PDF 确实按合同要求使用 AES-256 加密。根因藏在一个布尔值里:文档使用加密 revision 6 写出,这是 ISO 32000-2 中的 PDF 2.0 变体,而客户的归档工具链只理解 revision 5。同样的算法、同样的密钥长度、同样的密码,却是不同的密钥派生握手;在运行新版 Acrobat 的任何开发机上都不会出现的问题,在客户环境里变成硬失败。
加密是少数几类“本地配置错误没有可见症状,远端却完全失败”的 PDF 功能之一。HotPDF 是面向 Delphi 和 C++Builder 的原生 VCL PDF 组件,用少量属性公开完整的 ISO 32000 保护模型;本文把每个属性映射到它实际控制的工程决策。
两个密码实际承诺了什么
PDF 加密定义了两个职责不同的凭据,把它们混为一谈是受保护输出代码里最常见的设计错误。用户密码负责解密门禁:没有它,或者没有所有者密码,符合规范的阅读器就无法重建文件密钥,内容在密码学意义上不可读。所有者密码负责权限设置:阅读器拿到它后,无论限制标志如何都会授予完整访问权。
权限本身,例如打印、内容提取、表单填写,是一种更弱的承诺。它们是查看器读取并同意遵守的标志(ISO 32000-2 §7.6.4)。加密保护的是字节;权限标志只是给符合规范的软件的指令。一个用用户密码合法打开文档的用户,内存里已经有解密后的内容,因此“禁止复制”和“禁止打印”是给守规查看器的策略信号,不是密码学保证。威胁模型应围绕这个分界建立:保密性来自用户密码,而权限标志只塑造主流查看器中的行为。
配置顺序:所有内容都要在 BeginDoc 之前
HotPDF 在 BeginDoc 运行时建立加密字典和文件密钥。因此每个保护属性都必须先赋值,尤其是 CryptKeyLength,它通过 THPDFKeyType 值 k40、k128、aes128 和 aes256 选择方案。在 BeginDoc 之后再赋值不会抛异常;文档只是继续使用开始时的参数,这正是那种几个月后才以合规问题形式浮现的静默分叉。
var
Pdf: THotPDF;
begin
Pdf := THotPDF.Create(nil);
try
Pdf.FileName := 'statement.pdf';
Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aes256; // must be set before BeginDoc
Pdf.UserPassword := 'open-secret';
Pdf.OwnerPassword := 'admin-secret';
Pdf.UseAES256R6 := False; // R=5: widest viewer support
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 方案的限制一致。如果你的密码策略生成更长的密钥,应在你自己的代码里明确截断,而不是让库和未来的查看器在截断位置上产生分歧。
Revision 5 还是 revision 6:一个布尔值,两个生态
UseAES256R6 在两种 AES-256 握手之间选择。为 False 时,HotPDF 写出 revision 5,这是作为 PDF 1.7 扩展引入的 AES-256 方案,已有大约十五年的查看器版本支持。为 True 时,它写出 revision 6,即 ISO 32000-2 为 PDF 2.0 标准化的加固密钥派生算法,这个变体修复了 revision 5 密码验证步骤中的已知弱点。
工程取舍是在兼容性和标准化之间选择。Revision 6 要求查看器支持 PDF 1.7 Extension Level 3 或 PDF 2.0;较旧的归档系统、嵌入式渲染器和无人维护的业务工具会完全打不开文件,和本文开头的工单一模一样。除非安全策略明确点名 ISO 32000-2 revision 6,否则应发布 revision 5 并把决策记录下来。它是更稳妥的默认值,等最慢升级的消费端完成升级后再重新评估。
同样的推理也适用于下一层。THPDFKeyType 仍然提供 k40、k128 和 aes128,用于兼容旧工具链,但三者都属于遗留系统维护,不属于新设计:40 位 RC4 可被普通硬件破解,128 位方案早于当前安全评审期望看到的 AES-256 修订。对 2026 年生成的任何文档来说,现实决策空间是 AES-256 revision 5 与 revision 6;旧密钥类型存在的意义,是让你能重现历史归档,而不是写出新的归档。
无打开密码的权限标志
一个常见需求是保密性的反面:任何人都可以阅读文档,但应限制打印或提取。配置方式是空用户密码加非空所有者密码,也就是开放密码模式,并在 ProtectOptions 中列出允许的操作。
Pdf.ActivateProtection := True;
Pdf.CryptKeyLength := aes256;
Pdf.UserPassword := ''; // anyone can open the file
Pdf.OwnerPassword := 'rotate-me-quarterly'; // guards the permission set
Pdf.ProtectOptions := [prPrint, prPrint12bit, prExtractContent];
Pdf.BeginDoc;
// ... page content ...
Pdf.EndDoc;
THPDFProtectOptions 集合覆盖 ISO 权限位:prPrint、用于高分辨率打印的 prPrint12bit、用于常规复制和提取的 prInformationCopy、用于辅助技术提取的 prExtractContent、prModifyStructure、prEditAnnotations、prFillAnnotations 和 prAssemble。其中两个值得特别说明。几乎每个配置都应保持 prExtractContent 启用,因为这是让屏幕阅读器和其他辅助技术访问内容的权限位,撤销它会把权利决策变成无障碍缺陷。另外,只有 prPrint 而没有 prPrint12bit 时,一些查看器会降级打印,最终用户通常会把它报成渲染 bug,而不是权限问题。
验证很快,也值得自动纳入发布检查:在 Acrobat 中打开每个配置的输出样本,读取 Document Properties 的 Security 页,它会列出算法(“AES 256-bit”)并逐项显示允许的操作。然后再用客户实际运行的最旧查看器打开一次。第二次检查花掉的五分钟,正是本文开头 revision 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; // drop encryption on save
Pdf.SaveLoadedDocument('plain.pdf');
end;
finally
Pdf.Free;
end;
end;
这条路径会解析完整文档,对普通文件没有问题。对于数百 MB 的输入,还有更便宜的路径:DecryptFile 会在文件级复制过程中解密,在输入允许时走直接 AES-256 重写路径,避免构建对象树。它属于 关于从 Delphi 处理大型 PDF 的配套文章中介绍的 Direct File API。
会与加密互相影响的约束
归档配置会直接冲突:ISO 19005 禁止 PDF/A 文件加密,所以一个既加密文档又声称符合 PDF/A 的工作流,在结构上就是无效的。当两个需求同时存在时,应交付两个制品:一份加密分发副本和一份未加密归档副本,而不是试图在一个文件里同时满足它们。
也不存在恢复路径。PDF 加密没有托管机制:R5 或 R6 文件丢失用户密码,结果就是暴力破解或无解。应把所有者和用户密钥视为生产凭据来处理:生成、入库、轮换,而不是作为 unit 里的常量,否则它们会进入版本控制和每个开发者的工作副本。
FAQ:从 Delphi 保护 PDF
禁用 prInformationCopy 能阻止别人复制文本吗?
它会阻止符合规范的查看器提供复制命令。任何能打开文档的人已经在内存中持有明文,因此应把这个标志视为工作流指引,而不是数据泄露防护。
新项目应该启用 UseAES256R6 吗?
只有在确认所有消费端都能处理 PDF 2.0 加密时才启用。Revision 5 提供同样的 AES-256 内容加密,却有更广泛的查看器覆盖,这也是它作为默认选择的原因。
我能修改不是自己创建的 PDF 的权限吗?
可以。通过 LoadFromFile 用密码加载它,调整 ProtectOptions 或密码,然后像上面的解密示例那样用 SaveLoadedDocument 写出结果。
本文展示的保护属性是面向 Delphi 和 C++Builder 的标准 HotPDF Component 的一部分;产品页提供完整加密参考,包括完整权限枚举。