当一份 AES-256 revision 6 的 PDF 拒绝你确信正确的密码时,最常见的罪魁是 R6 加固哈希(ISO 32000-2 Algorithm 2.B)里的轮计数器。The PDFium Component for Delphi 在 v2.57.2 之前恰好有这个 bug:TPdf.SaveAsEncrypted 从错误的基数起计轮,产出的文件约有 30% 被 FPDF_LoadDocument 以 FPDF_ERR_PASSWORD 拒收
讨厌的不是算术,而是这个缺陷对每一个“加密后再用同一段代码校验”的测试都隐形,而一次成功的往返什么也证明不了。本文讲清 Algorithm 2.B 到底要求什么、差一轮的失误藏在哪里、为什么它只是偶尔失败,以及如何构建能在客户之前抓住这类 bug 的回归测试
R6 哈希的终止规则到底怎么说
Algorithm 2.B 只有在至少跑满 64 轮之后才会停,而且停的条件是最近一次 AES 输出 E 的最后一个字节小于等于轮数减 32——轮数指已执行轮数按 1 起数的计数值。第 N 轮之后就和 N 比较。不是 N − 1,不是下一轮的索引。这一定义就是全部 bug 表面
算法其余部分是机械的。先把 K 置为 password、salt 加(owner key 场景下)48 字节 /U 值的 SHA-256。每轮把 K1 构造为 password ‖ K ‖ UK 重复 64 次,用 K 的前 16 字节作密钥、次 16 字节作 IV,以 AES-128-CBC 无填充加密,取 E 的前 16 字节按大端 128 位整数对 3 取模,据结果选择 SHA-256、SHA-384 或 SHA-512 产出下一个 K。最终哈希取 K 的前 32 字节。ISO 32000-2 §7.6.4.3 定义了它;依赖数据的循环长度是刻意的,这让每次密码猜测的工作量对 GPU 破解器不可预测
下面是循环的正确形态,作为示意而非拿来即用的实现。BuildK1、AesCbcNoPad128、FirstBlockMod3 和各哈希助手代指你代码库里已有的原语
// 示意:Algorithm 2.B 循环骨架(ISO 32000-2,revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0; // 目前已执行的轮数
repeat
Inc(RoundNumber); // 这是第 1、2、3 轮……
K1 := BuildK1(Password, K, UK); // (Password || K || UK) 重复 64 次
E := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
case FirstBlockMod3(E) of // E 的前 16 字节,大端,mod 3
0: K := Sha256(E);
1: K := Sha384(E);
2: K := Sha512(E);
end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);
带着计数器去读终止行。自增在循环体顶部,所以 until 测试运行时,RoundNumber 恰好持有已完成轮数——这正是标准所说的轮数
差一错误藏在 Delphi 移植的哪里
The PDFium Component 的 bug 是计数器从 −1 起步、自增放在循环体顶部,于是 until 子句里测到的值永远是已执行轮数减一。两件事同时出错:64 轮下限变成 65,字节阈值也比标准紧了一格。在真正要紧的情形里,两者都把退出点整整挪了一轮
同样的失误有好几种同样合理的伪装。从 0 起步、自增放在循环体底部,而测试早已按自增前的值写好;用 0 起的 for 循环索引并在比较里复用它;翻译一个循环变量在 ++ 之前检查的 C 参考实现。每一种在代码评审里读起来都很自然,每一种产出的哈希都看着完全随机——因为它确实只是另一个同样像模像样的 SHA 输出,没有别的读取器算得出。FPdfEncrypt.pas 里修复后的 CalculateR6Hash 现在带一条注释钉死 1 起语义,恰恰因为错误的版本看起来不像错的
为什么 AES-256 R6 的 PDF 只偶尔失败
失败是概率性的,因为多出来的那一轮只在终止字节恰好落在正确阈值与偏移阈值之间那一轮窗口里时才改变结果。其余所有情形下两个版本都在同一轮退出、产出同一个 K,文件照常打开。在 PDFium Component 的测量里,60 次加密有 18 次(约 30%)产出的 /U、/O、/UE 和 /OE 值与标准实现不一致;另外 70% 逐字节正确
每次保存都重新抽取随机 salt,所以同一份文档、同一个密码这次成功下次失败。在支持台看来,这像存储不稳、上传损坏或用户密码敲错。十份文件七份能开时,没人会想到去怪一个循环计数器。如果你的产品在自己代码加密的 PDF 上收到间歇性的“密码错误”报告,而且只在 revision 6 上——这就是第一个该查的地方
Revision 5 免疫,因为它只跑一遍 SHA-256、没有迭代。ISO/TS 32003 的 AESV4 路径不是:TPdfEncryptionRevision 的 erR7 对密码哈希复用 Algorithm 2.B,所以同一修复也覆盖了ISO/TS 32003 的 AES-256-GCM 加密与 PDF MAC 完整性一文讲的 GCM 文件
自洽陷阱:往返测试为什么都能过
用你自己的加密加你自己的校验走往返永远成功,因为两边跑的是同一个错误的循环、认同同一个错误的哈希。缺陷只相对别人的实现存在,所以裁判必须是独立的:pdfium.dll 里的 PDFium 引擎、Acrobat、qpdf,或任何不是你写的读取器
这是写给所有写密码学沾边文档代码的人的、不太舒服的通用教训。算一个哈希、再和被测函数自己的输出比较的单元测试,证明的是确定性,不是正确性。来自标准或参考工具的已知答案向量更好,跨实现的往返是最接近 ground truth 的东西。The PDFium Component 在这点上位置很幸运:加密由 Pascal 的 EncryptDocumentBytes 完成,加载时的解密走 PDFium 自己的 C++ 安全处理器,所以用 TPdf 重新打开保存的文件是一次真正的独立校验,不是照镜子
uses
SysUtils, PDFium, FPdfEncrypt;
procedure SaveR6(const Source, Target: string);
var
Pdf: TPdf;
Opts: TPdfEncryptOptions;
begin
Pdf := TPdf.Create(nil);
try
Pdf.FileName := Source;
Pdf.Active := True;
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'userpw';
Opts.OwnerPassword := 'ownerpw';
Opts.Revision := erR6; // AESV3,Algorithm 2.B 密码哈希
if not Pdf.SaveAsEncrypted(Target, Opts) then
raise Exception.Create('SaveAsEncrypted failed');
finally
Pdf.Free;
end;
end;
TPdfEncryptOptions.Default 从 revision 5 起步、权限全开、元数据加密,所以想要 R6 请显式设 Revision。输出去 TStream 而不是文件时,TPdf.SaveAsEncryptedToStream 接受同样的选项记录。注意选项记录里的密码是 AnsiString,传非 ASCII 凭据时这点很重要;在它们进入记录之前先规范化和编码
如何对概率性加密 bug 做回归测试
把跨实现往返重复到 30% 失败率溜不过去的次数。单次运行有约 70% 的概率漏掉 PDFium Component 的这个 bug。二十次运行、每次新 salt,把漏检率压到约 0.08%(0.7 的 20 次方),这就是随包回归测试循环 20 次而不是 1 次的原因
procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
Pdf: TPdf;
I: Integer;
Target: string;
begin
Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
for I := 1 to 20 do
begin
SaveR6(SamplePdf, Target); // 每次保存都是新 salt
Pdf := TPdf.Create(nil);
try
Pdf.Password := 'userpw';
Pdf.FileName := Target;
Pdf.Active := True; // PDFium 独立校验 /U
AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
finally
Pdf.Free;
end;
end;
end;
再配上反例——一个必须失败的错误密码——免得坏掉的加载器让正向测试空转通过。用 Lazarus 构建的话,动过 Lib\*.pas 之后先重编库包再跑测试工程;测试构建链接的是包里预编译的 .ppu 单元,对着陈旧目标代码跑,你会在一个根本没修的 bug 上收获令人安心的绿。FPC Random 产出恒定加密密钥是同一轮排查里值得一并复查的相关陷阱:输出看着随机,实际悄悄错了
FAQ
R6 轮计数器 bug 影响 revision 5 加密的文件吗
不影响。Revision 5 用单遍 SHA-256 哈希密码,没有迭代次数,也就没有轮计数器可错。受影响的只有同样使用 Algorithm 2.B 的 revision 6 和 ISO/TS 32003 的 AESV4 revision
有 bug 版本写出的文件还能打开吗
约 70% 能正常打开,因为它们的哈希碰巧一致。其余的带着任何标准读取器都复现不出的 /U 和 /O 值,密码校验处处失败;实际办法是用 v2.57.2 或更新版本从明文源重新加密
为什么对着 PDFium 测试而不是自己的解密代码
你自己的解密器与加密器共享同一个 Algorithm 2.B 实现,错误的轮数会互相抵消,每次往返都通过。像 PDFium 安全处理器这样的独立实现,是唯一能和你意见相左的检查
加密、PDF MAC 校验以及其余保存管线随 PDFium Component for Delphi and C++Builder 提供,附 Delphi 和 Lazarus 双份源码与回归测试