PDF Library for Delphi 可以使用原始文件加密密钥打开加密 PDF,而不需要密码。DAOpenFileWithEncryptionKey 接受十六进制文本形式的密钥,按照加密字典中已有的验证器检查它,并返回只读 Direct Access 句柄;DAOpenFromStreamWithEncryptionKey 对调用方拥有的 TStream 执行同样的操作。两个入口都在 v3.496.0 中加入
这个场景很窄,但确实存在。取证任务从内存镜像中恢复出一个密钥,却没有密码。批量归档任务有一万份文档,其文件密钥存放在托管数据库中,因为原始 DRM 系统多年前就停止发放密码。迁移出已经退役的权限管理产品时,手里有密钥材料,却没有其他东西。在这些场景中,手上的凭据是密钥派生的输出,而不是输入,API 中任何密码参数都无法接收它
为什么文件加密密钥不是密码
在 PDF 标准安全处理器中(ISO 32000-1 第 7.6.3 节),密码和文件加密密钥位于密钥派生的两端。处理器把密码与 /O、/P、文件 ID 以及特定修订版本的哈希混合,生成文件密钥。把文件密钥塞进密码参数,会得到被哈希进另一个无意义值的无意义值,这正是它需要独立入口而不能只是给 DAOpenFile 增加一个标志的原因
原始密钥可以注入的位置由修订版本固定。修订版本 2 到 4 仍会从文件密钥、对象号、代号以及 AESV2 所需的 AES 盐值派生独立的对象密钥,因此即使持有文件密钥,也完全不能跳过对象级派生。修订版本 5 到 7 对 AES-256 直接使用 32 字节文件密钥,不再有对象级步骤。两者共有的唯一层就是文件密钥,因此这也是 PDF Library for Delphi 接受外部密钥的唯一位置。如果你仍然有密码,应该继续使用普通路径,让密码重试生命周期处理错误的第一次尝试,因为原始密钥入口会有意放弃密码路径保留的若干便利
DAOpenFileWithEncryptionKey 接受什么输入
只接受无前缀、无空白、长度为偶数的 ASCII 十六进制文本,解码后的字节数还必须与加密修订版本完全一致。对于修订版本 2 到 4,预期长度来自加密字典中的 /Length:它是 8 位的倍数,范围在 5 到 16 字节之间;缺少 /Length 时默认使用 40 位。对于修订版本 5 到 7,长度固定为 32 字节,不协商。0x 前缀、奇数位数、超过 64 个十六进制字符、Options 中的未知位,或根本没有加密的文档,都会产生同样的结果:句柄为 0,LastErrorCode 设置为 PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID,也就是 425。严格性正是设计目标。一个会裁剪空白并用零填充短输入的宽松解析器,很容易把截断的剪贴板内容变成凭据,然后在更难读懂的地方才失败
Var
Lib: TPDFlib;
FileHandle, PageRef: Integer;
Begin
Lib:= TPDFlib.Create;
Try
// KeyHex 对 AES-128 R4 是 32 个十六进制字符,对 AES-256 R6 是 64 个
FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
If FileHandle= 0 Then
Raise Exception.CreateFmt('raw key refused (error %d)', [Lib.LastErrorCode]);
Try
PageRef:= Lib.DAFindPage(FileHandle, 1);
WriteLn(Lib.DAExtractPageText(FileHandle, PageRef, 0));
Finally
Lib.DACloseFile(FileHandle);
End;
Finally
Lib.Free;
End;
End;
其他失败代码仍然彼此区分,因此批处理可以区分操作员错误和证据问题:文件不存在时为 411,无法以读取方式打开时为 401,交叉引用结构损坏时为 409。所有与密钥有关的情况都会有意合并为 425,因为一个会报告密钥具体哪一部分错误的原始密钥入口,本身就是一种 oracle
验证实际上证明了什么
PDF Library for Delphi 使用加密字典本身携带的验证器,证明所提供的密钥属于这个文档,检查方式取决于修订版本。修订版本 2 重新计算 32 字节标准填充字符串的 RC4 加密,并将全部 32 字节与 /U 比较。修订版本 3 和 4 将填充与文件 ID 一起哈希,执行 RC4 轮次以及 19 轮异或派生轮次,再比较 /U 的前 16 字节。修订版本 5 到 7 使用零 IV 解密 16 字节的 /Perms 字符串,并同时检查四件相互独立的事情:小端权限字与 /P 相符,位置 5 到 8 是四个 FF 字节,加密元数据标志是 T 或 F,位置 10 到 12 是 adb 标记
只要验证器存在但不匹配,就无条件拒绝打开。这一点值得直说,因为它正是整个功能所依赖的保证。还要注意验证不代表什么:它说明密钥可以解密这个文件,却不说明任何人授权你使用它。从 /Perms 恢复出的权限字是关于密钥的证据,而不是授权;如果你想知道文档实际上声称允许什么,那是加密与权限审计流程的独立工作。密码侧的规范化问题,例如非 ASCII AES-256 密码的 SASLprep 处理,在这里根本不会出现,因为没有字符串进入哈希
PDF_RAW_KEY_ALLOW_UNVERIFIED 什么时候生效
PDF_RAW_KEY_ALLOW_UNVERIFIED 只覆盖一种情况:文档没有可用验证器,因为 /Perms 缺失或不是 16 字节,或者 /U 太短而无法比较。它不能覆盖失败的证据。如果把 /Perms 中的一个十六进制数字改错,再用设置了该选项的正确密钥打开,PDF Library for Delphi 仍然返回 0 和 425。对完整验证器传入 32 字节全零密钥,即使设置了该选项,结果也一样。该选项放宽的是没有证据的情况,从不放宽与证据矛盾的情况。由于恢复打开和已验证打开代表不同的认知状态,库也会单独报告它们,而不是折叠进返回值;DAGetEncryptionKeyValidation 接受打开句柄,并返回三个常量之一:
PDF_RAW_KEY_VALIDATION_VERIFIED(1)表示验证器存在且匹配PDF_RAW_KEY_VALIDATION_UNVERIFIED(2)表示只有在无法评估验证器且调用方明确请求了该策略时,密钥才会被接受PDF_RAW_KEY_VALIDATION_NONE(0)是普通密码打开的句柄返回的值
FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex);
If (FileHandle= 0)And
(Lib.LastErrorCode= PDFLIB_ERROR_RAW_ENCRYPTION_KEY_INVALID) Then
// 文件中没有验证器?按明确的恢复策略重试
FileHandle:= Lib.DAOpenFileWithEncryptionKey(CaseFile, KeyHex,
PDF_RAW_KEY_ALLOW_UNVERIFIED);
If FileHandle<> 0 Then
Begin
Case Lib.DAGetEncryptionKeyValidation(FileHandle) Of
PDF_RAW_KEY_VALIDATION_VERIFIED:
Chain.Note('key verified against the encryption dictionary');
PDF_RAW_KEY_VALIDATION_UNVERIFIED:
Chain.Note('no verifier available: extraction is unattested');
End;
End;
从设计上只读,以及谁拥有流
原始密钥文件入口总是以 fmOpenRead or fmShareDenyWrite 打开源,并将整个 Direct Access 链标记为只读,因此 DAAppendFile 会拒绝就地写入并返回 2,而不是尝试增量更新。这不是可以通过对句柄说服其改变的策略;它在构造函数中、甚至文件解析之前就已经设置好了。对于证据工作,真正重要的是句柄关闭后源字节保持逐字节相同,回归套件正是在 AES-128 修订版本 4 和 AES-256 修订版本 6 的夹具上断言这一点。流入口的写入行为相同,并且多一条规则:DAOpenFromStreamWithEncryptionKey 从不取得所有权,因此 DACloseFile 会让你的 TStream 继续存活,需要由你自己释放
Source:= TMemoryStream.Create;
Try
Source.LoadFromFile(ArchivePath);
Source.Position:= 0;
FileHandle:= Lib.DAOpenFromStreamWithEncryptionKey(Source, KeyHex);
If FileHandle<> 0 Then
Try
// 2 = 只读句柄:导出到其他位置,绝不向证据追加内容
Assert(Lib.DAAppendFile(FileHandle)= 2);
Harvest(Lib, FileHandle);
Finally
Lib.DACloseFile(FileHandle);
End;
Finally
Source.Free; // 句柄从未拥有这个流
End;
密钥卫生,以及 DLL 导出哪些内容
关闭链时会覆盖文件密钥、密码缓存和派生出的对象密钥;无论打开成功还是失败,解码后的密钥都会在入口点自身的 Finally 块中清除。这里有个曾经耗费大量调试时间的细节:任何必须继续存在的副本都通过 SetLength 加 Move 显式克隆,而不是直接赋值。在 Delphi 中,把一个 AnsiString 赋给另一个时,两个名字会在写时复制机制下共享一个缓冲区,因此清除调用方一侧会把加密处理器仍在使用的密钥一起归零,文档就会解密成垃圾,而且栈跟踪不会解释原因。只有基于文件的入口以宽字符和 ANSI 形式跨过 DLL 边界,同时导出验证状态访问器;TStream 变体保持 Delphi 专用,因为它依赖 Delphi 对象生命周期和引用语义,而这些概念无法在扁平的 C ABI 中诚实表示。如果你的恢复工具是 DLL 客户端,就应当在同样的控制措施下暂存到临时文件并删除临时文件
把十六进制密钥当作凭据材料处理,采用对待文档密码的处理规则,并把验证状态写入保管链产生的日志,这样后来阅读记录的人才能区分已验证提取和未经证明的提取。如果你正在为取证、批量归档或 DRM 迁移评估 Delphi PDF 组件,原始密钥入口、只读保证和加密审计接口都属于同一个库,完整功能列表可见 PDF Library for Delphi 产品页