3.114.8 之前,PDFiumPas 在非 Windows 目标上用运行库的 Random 函数生成 PDF 加密密钥材料,而因为没有任何地方调 Randomize,每个进程产出的都是同一串字节。于是 Linux 和 macOS 上的 Free Pascal 构建跑一次写一次完全相同的文件加密密钥、salt、CBC IV 和 AES-GCM nonce 前缀。3.114.8 改读 /dev/urandom,读不到就抛异常
缺陷本身就是个四行的循环。更有用的教训是:一个加密又解密了几百份文档、AESV3 和 AESV4 都测、带不带 PDF MAC 都测的测试套件,为什么全程都是绿的。每个进程内恒定的随机性,对任何一个在单进程里跑的测试都不可见——而加密测试恰恰都是这么写的
PDFiumPas 哪里需要随机字节?
PDFiumPas 加密栈里的每个随机字节都来自同一个过程——FPdfAes 单元的 AesGenerateRandomBytes,所以一个坏源头会污染所有地方。ISO 32000-2 §7.6.4 的标准安全处理器和 ISO/TS 32003 的 AESV4 扩展在这些地方消费这些字节:
- 32 字节的文件加密密钥,DeriveEncryptionKeys 为每份文档现生成,再在口令派生密钥下包进 /UE 和 /OE
- 两个 16 字节 salt,一个存在 /U 的后 16 字节、一个存在 /O 的后 16 字节,各自拆成 8 字节验证 salt 和 8 字节密钥 salt
- /Perms 背后明文的第 12 到 15 字节,ISO 32000-2 在这个块被文件密钥加密之前用随机数据填充
- 16 字节的 CBC IV,AESV3 文档里每条加密字符串和流前面都要垫一个
- AESV4 文档的 8 字节 nonce 前缀,后面跟一个从零开始的 4 字节逐对象计数器
- 置上 EnableIntegrityProtection 时的 32 字节 /KDFSalt 和 MAC 密钥
为什么每个进程产出的密钥都一样?
AesGenerateRandomBytes 只在 Windows 上用操作系统生成器;其他地方都从 RTL 伪随机生成器填缓冲区,而那个生成器从 RandSeed = 0 起步,除非程序调了 Randomize。循环上面那行注释说生成器用 GetTickCount64 播种了。没有任何一行代码做过这件事,于是注释成了种子唯一存在的地方:
// 3.114.8 之前 AesGenerateRandomBytes 的非 Windows 分支
//(它上面的注释许诺了一个从未生效的 GetTickCount64 种子)
P := PByte(Buffer);
for I := 0 to Count - 1 do
P[I] := Byte(Random(256));
这个序列每个进程重新起头、在进程内往前走,所以任何进程加密的第一份文档,与同一构建下所有其他进程的第一份文档共享文件密钥,第二份与第二份共享,以此类推。R5、R6 和 R7 的文件密钥根本不依赖口令,口令只是把它包起来,这意味着任何能复现这个序列的人不需要口令就拿到密钥。AESV4 又加了一层失败:同一密钥、同一 8 字节前缀、计数器从零重来,GCM nonce 就会重复,而 NIST SP 800-38D §8 对此是明令禁止的。同一密钥下重复的 GCM nonce 会泄露两条明文的异或、暴露认证子密钥,AESV4-GCM 加密与 PDF MAC token 依赖的那些 tag 就全失去了意义。机密性和完整性一起完蛋
波及范围比上面那段听起来要窄。Windows 构建从未受影响,因为 Windows 分支一直通过 advapi32 以 CRYPT_VERIFYCONTEXT 调 CryptGenRandom,失败就抛异常。暴露的是 3.114.8 之前非 Windows 构建的产出,实际就是 Linux 和 macOS 上的 Lazarus 与 Free Pascal 应用——PDFium 构建里 Delphi 与 FPC 的坑清单上又添一笔
为什么 Randomize 从来就不是正确的修法?
调一下 Randomize 只会遮住症状而不治源头,因为 RandSeed 是个 32 位值,Randomize 从时钟派生它。这把可能的密钥流数量封顶在 2^32,知道文件大致写入时间还能把搜索空间砍到远低于这个数——跟 256 位 AES 密钥比不值一提。密钥材料必须来自内核熵池,所以 3.114.8 的 AesGenerateRandomBytes 读 /dev/urandom,循环处理短读,池子交付不出全部请求字节就抛异常:
Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
Remaining := Count;
while Remaining > 0 do
begin
Got := FileRead(Handle, P^, Remaining);
if Got <= 0 then
Break; // 失败或流意外结束
Inc(P, Got);
Dec(Remaining, Got);
end;
if Remaining = 0 then
Exit;
finally
FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');
拒绝是刻意的,与 Windows 分支在 CryptGenRandom 不可用时的做法一脉相承。加密保存失败是当天就会注意到的事故;带着可预测密钥保存成功的事故,你得等别人告诉你。由此有两个实际后果。没有挂好 /dev 的极简容器或 chroot 现在会让加密失败而不是悄悄降级,所以把 /dev 挂上。另外,异常从 TPdf.SaveAsEncrypted 传出来时目标文件已经用 fmCreate 打开过,会留下一个空输出文件,等你的错误处理去删
为什么往返测试从来没抓住它?
往返测试看不见恒定的随机性,因为解密恢复的就是加密当时选的那个文件密钥。测试加密一份文档、用口令重新打开、从 /UE 解出密钥、解密每个对象;一个可预测的密钥解包解密与随机的同样顺利,GCM tag 也校验通过,因为它们本来就是用同一把密钥算的。就连加密两遍、断言两次输出不同的测试也会过,因为同一进程里的第二次调用取的是序列的下一段字节。真正要紧的性质——每个进程一个不同的密钥——只有跨进程比较输出才观察得到。每当同一段代码既生产又消费一个值,测试就会对一整类缺陷失明,随机性是最纯粹的例子
怎么跨进程测试密钥的随机性?
在目标平台上把一个小探针当独立进程跑两遍,比较输出。下面的探针调 DeriveEncryptionKeys,打印 /U 条目第 32 到 47 字节里存的 salt。这个值本来就明文写在每份加密文件里,打印进 CI 日志不泄露任何东西,而它和文件密钥出自同一个生成器:
program SaltProbe;
{$mode delphi}
uses
SysUtils, FPdfEncrypt;
var
Opts: TPdfEncryptOptions;
Keys: TPdfEncryptionKeys;
I: Integer;
Hex: string;
begin
Opts := TPdfEncryptOptions.Default;
Opts.UserPassword := 'probe';
Opts.Revision := erR6;
DeriveEncryptionKeys(Opts, Keys);
Hex := '';
for I := 32 to 47 do // /U = 32 字节哈希 + 16 字节 salt
Hex := Hex + IntToHex(Keys.UEntry[I], 2);
WriteLn(Hex); // 每次运行必须不同
end.
把探针接进每个非 Windows 目标的构建:跑两遍,两行相同就让作业失败。同样的比较也适用于已经发出去的文件。取同一应用两次运行写出的两份加密 PDF,从它们的 Encrypt 字典读出 /U 字符串,比较后 16 字节;salt 相同就说明构建受影响,这些文档应该用 3.114.8 或更高版本从明文重新加密一遍,让每份都拿到新的文件密钥。总的习惯是:非 Windows 代码路径要在那个平台本体上跑过,别只信 Windows 那次运行——面向非 Windows 构建的 libcurl 时间戳后端背后的道理是一样的
PDFiumPas 是构建在 PDFium 引擎上的 Delphi 和 Lazarus PDF 组件,AES-256、AES-GCM 和 PDF MAC token 都用 Pascal 原生实现,每个平台的密钥材料都取自操作系统生成器。详情与下载见 PDFium Delphi 组件页面