技术文章

Delphi 里签名 AES-256 PDF 加载慢的原因与 HotPDF 修复

HotPDF 在 Delphi 里加载签名过的 AES-256 加密 PDF 比过去快得多,因为加载路径上三个各自独立的成本被拿掉了:对源流的无缓冲逐 token 解析、解密时把 ISO 32000-2 Algorithm 2.A 密码检查再跑一遍,以及 R6 加固散列内层一个缓慢的 AES-128-CBC 循环。在 Delphi 目标上,光是 R6 那一项修复就把一份文档的加载从约 66 ms 降到 16 ms

这些在大多数测试套件用的小文件上都不会现形。一两 KB 的未签名未加密样例,解析器再怎么写也在百分之几秒内打开。麻烦从客户真正发来的文档开始:一份带内嵌 CMS 签名的合同,35 KB 上下,用 revision 6 密码保护。这个组合把每条昂贵的代码路径同时点亮,而且每条都有自己的理由慢得没必要。本文按发现顺序把它们过一遍,顺带讲讲签名验证结果开始做记忆化之后冒出来的缓存键错误

34 KB 的签名 PDF 为什么要 170 ms 才加载完?

34 KB 的签名 PDF 加载花了 170 ms,因为解析器直接读裸的 THandleStream,每个 token 字节都变成一次独立的 ReadFile 系统调用。PDF tokenizer 找分隔符时一次前进一个字节,无缓冲的句柄流把一次廉价的内存扫描变成了成千上万次内核切换

细节解释了 bug 为什么能存活。THotPDF.LoadFromFile 早就把文件源包进了内部读缓冲,AutoDecrypt 探测也因为同样的原因修过一次——它曾花 1.4 s 反复查询一个 56 MB 文件的大小。但签名加密文档走的是另一条路。为了让懒式对象读取和签名检查始终看到同一份不可变修订版——即使在调用方释放自己的流之后——THotPDF.LoadFromStream 会给源拍快照并解析第二遍。第二遍解析连同用于已认证修订版的重用加载,读快照句柄时前面都没有缓冲。现在两者都走文件路径同一条内部带缓冲的读取器

HotPDF 在 Delphi 里签名 AES-256 加密 PDF 的加载管线:快照流、带缓冲的结构解析、Algorithm 2.A 预扫、复用已恢复密钥的单次解密,以及调用原生 AES-128-CBC 的 R6 加固散列,把加载从 66 ms 砍到 16 ms
三项修复叠在同一条路径上:缓冲消除成千上万次系统调用,预扫出的密钥在 scope 标志下复用,R6 内层循环接上原生 CBC 后端

那个缓冲是内部的,你永远不用自己创建。你能控制的是交给 LoadFromStream 的流,而 HotPDF 在第一遍结构解析期间按原样读它。数据已在内存里就传 TMemoryStream;在磁盘上就调 LoadFromFile,或者在 Delphi 10.1 Berlin 及以后给加载器一个 TBufferedFileStream 而不是裸的 TFileStream

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // 一次整块读,替代逐 token 读
    Mem.Position := 0;
    // LoadFromStream 返回页数,文档没加载成功时为 0
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

加载后立刻释放内存流,对签名加密输入是安全的,因为组件已经持有自己那份字节快照。但如果你打算之后验证签名,原文件或缓冲还是留着——验证散列的是文件的原始 /ByteRange 区段,不是解密后的对象模型

把加载与验证当两个阶段分开测

加载时间与签名验证时间应当分开测,因为两者的失败方式不同、修的地方也不同。加载阶段覆盖解析、密码检查和解密。验证阶段重新散列被签的字节区间并检查 CMS 签名。合成一个数字,任何一方的退化都会藏进另一方

RTL 的 System.Diagnostics 里的 TStopwatch 对这就够用了。下面的测试台先用 THotPDF.LoadFromFile 的密码重载加载,再经 VerifyLoadedSignature 的 TStream 重载验证第一个签名。加密文件该选流重载,因为密码加载路径不保留源文件名,无参重载没得重开

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

多跑几遍,忽略第一遍。第一次调用要付一次性设置成本——建 AES 表、跑自检——磁盘缓存也会让首次读取失真。你要的是对真实客户文件的稳态数字,对照同一份文档的未签名未加密副本来测

AES-256 密码为什么被推导了两遍?

HotPDF 把 AES-256 文件密钥推导了两遍,因为同一次加载的两个阶段各自跑了一遍完整的密码检查。结构解析之前,RecoverReloadKeysFromStreamRanges 预扫原始字节,让加密的对象流容器能在过滤器解码时被解密。那次预扫已经执行了 ISO 32000-2 §7.6.4.3.3 Algorithm 2.A 检查——对照 user 和 owner 盐验证密码、恢复文件加密密钥。DecryptLoadedDocument 随后无视那个结果,把整套推导又做了一遍

revision 4 及更老的版本,重复的成本不过是多一圈 MD5。revision 6 上,Algorithm 2.A 调 Algorithm 2.B 加固散列,重复让打开文件最贵的部分翻了倍。复用已恢复的密钥很简单,安全地复用才是难点——一个活得比自己密码检查更久的缓存密钥会变成认证绕过

修复用一个 scope 标志限制复用:该标志只在 LoadFromFile 和 LoadFromStream 密码重载内部、预扫与解密调用之间为真,并在 finally 块里清除。只有预扫真正验证过密码时标志才置位。普通加载之后直接调 DecryptLoadedDocument 总是跑完整推导,因为那份密码从未被预扫过。没有这条限制,早先成功打开留下的过期重载密钥会悄悄为后来给了错误密码的调用方解密文档——那可比加载慢严重多了

R6 加固散列的时间花在哪?

R6 加固散列几乎全部时间都花在 AES-128-CBC 加密上。ISO 32000-2 §7.6.4.3.4 Algorithm 2.B 把一轮至少重复 64 次。每轮构造一个缓冲——64 份密码、当前散列,owner 侧再加 /U 字符串——用散列前 16 字节作密钥、次 16 字节作 IV 以 AES-128-CBC 加密它,再把密文前 16 字节当数字模 3,选出下一轮的 SHA-256、SHA-384 或 SHA-512。散列本身很短。被加密的缓冲每轮好几个 KB

HotPDF 在 Delphi 里的 R6 Algorithm 2.B 加固散列循环图解:至少 64 轮的每一轮都构造一个含 64 份密码、散列和 /U 字符串的缓冲,以散列为密钥和 IV 用 AES-128-CBC 加密它,再从密文首字节选 SHA-256、SHA-384 或 SHA-512
R6 的成本在 AES-128-CBC 缓冲加密,不在摘要——这正是 PDF20HashDanceR6 内一个原生 CBC 例程能把整份文档加载从约 66 ms 降到 16 ms 的原因

HotPDF 对这个循环的实现 PDF20HashDanceR6,过去经一个面向字节的参考 AES 逐块加密缓冲。现在先试原生无填充 CBC 例程,在 Delphi 目标上它直达链接的 C AES 后端。这一改,整份文档加载从约 66 ms 降到 16 ms。原生例程不可用或返回长度不符时,代码回退到历史上的逐块循环,那个循环又能用一块表驱动 AES-128——其 T 表从既有 S 盒生成。各种情况下密码都相同,所以派生密钥逐字节一致,不管哪条路径算的

本文讲的是速度。把 Algorithm 2.B 的终止规则做对是另一个正确性问题,差一错误会让正确密码时灵时不灵地被拒。R5 与 R6 的配置侧见 HotPDF 的 AES-256 PDF 加密配置指南

快的 AES 块骗你的四种方式

表驱动的 AES 块可以产出错误密文还通过自己的自检。下面四个陷阱每一个都是在造快速路径时撞上的,其中两个在最初设计的自检面前溜了过去

  • 自检重入。就绪例程在跑自检之前就置了初始化标志,所以测试期间的重入调用走参考路径。测试的第一版因此拿参考实现和它自己比。修法是给自检一个无保护的裸快速块直接调用,让表路径成为真正被测的代码
  • 表布局对调。四张轮表里两张(Te1 与 Te3)字节序对调,产出重入自检无从察觉的错误密文。逮住它的是一个独立探针程序——它打印出密钥 000102…0f、明文 00112233…ff 对应的 FIPS-197 附录 C.1 密文 69c4e0d86a7b0430d8cdb78070b4c55a。自检现在先要求拿到那个向量、且与参考块逐字节相等,才信任快速路径
  • FPC 范围与溢出检查。Free Pascal 下开着 -Cr 和 -Co,Delphi 风格的显式 Byte() 转换和字节宽度移位算术会抛范围或溢出异常。表索引和 GF(2^8) 倍乘现在按原生整数宽度计算、用 and $FF 掩蔽
  • FPC 内联误编译。接收 PByte 参数的大端序 load/store 小辅助函数,在 FPC 下标记 inline 时被编译成访问违例。去掉 inline 就修好了,相比表查找这点调用成本可以忽略

最终接线背后也是同一段经历。让库里每个 AES-128 块都走快速表,在一次发布候选的载入时触发了确定性的 FPC 访问违例,于是那次改动被回退。发布的设计保守:原生 CBC 优先,R6 循环内一个显式的快速表分支,其余地方一律参考块。普遍教训是:必须在多个编译器下构建的新密码学代码,需要在每个目标编译器上跑独立的已知答案探针。DLL 层的测试失败分不清你是表错了还是崩了。Free Pascal 侧的更多内容见 HotPDF 在 Free Pascal 下的 Deflate 与 AES 后端笔记

怎么缓存签名验证结果而不缓存错误答案?

签名验证缓存只有在其键覆盖每一个可能改变裁决的输入时才是安全的——包括住在被签字节区间之外的输入。HotPDF 的 headless 文档层对 CMS 验证结果做记忆化,散列的输入有:CMS 字节、/Contents 空档里签名字典的字节、被覆盖区间的内容、策略指纹、验证预算、源总大小和一个一分钟时间桶。其中三个输入是 bug 暴露缺失之后才加上的

HotPDF 在 Delphi 里的签名验证缓存键输入:CMS 字节、/Contents 空档里的签名字典、被覆盖区间内容、策略指纹、预算、源总大小和一分钟时间桶,先一起散列,CMS 裁决才会被记忆化
只有每个可能改变裁决的输入都进了键,裁决才能复用——源大小之所以要紧,是因为基准修订版与它的增量更新可能共享完全相同的被覆盖区间

受信任证书文件按内容做键,不按路径。操作层为每条命令把同一份 PEM 材料写进全新的临时文件名,按路径做键永远打不中,缓存从未命中。源总大小必须进键,因为上报的 UnsignedTrailingBytes 依赖它:基准修订版和后来的增量更新可以共享完全相同的被覆盖内容、却有不同的尾部长度,只按区间做键的缓存会把基准修订版的裁决交给更新后的文件。这些尾部字节的含义,HotPDF 在 Delphi 里验证 PDF 数字签名指南里有背景

第三个 bug 是 Pascal 语言陷阱,不是设计错误。散列例程收无类型 const 缓冲,而把动态数组变量传给无类型参数传的是数组引用的地址,不是数组数据

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // 错误:读到的是数组引用和栈上跟在后面的东西。
  // 从那里读 16 KB 会冲出栈页并抛访问
  // 违例,Delphi 与 FPC 在不同点位失败。
  Writeln(Checksum(CMS, Length(CMS)));

  // 正确:传首元素,对空数组设防
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

缓存锁用 SyncObjs.TCriticalSection 而不是 Windows 的 InitializeCriticalSection API,因为同一单元还要在 Linux 上的 FPC 下构建,那里没有这个 API

哪些慢是故意的

R6 密钥推导本身从不在加载之间缓存,这是刻意的。外部字节和显式提供的密码永远走真正的认证路径,所以每次带密码加载 R6 文档都要付至少一次 Algorithm 2.B 散列,尝试 owner 密码时再加 user、owner 各一次。上面说的 scoped 复用只消掉单次加载内部的重复,此外没有别的

  • 66 ms 到 16 ms 的数字适用于链接了原生 AES 后端的 Delphi 目标。FPC 构建走表驱动块,同样断言下 Windows 上 FPC 代码生成比 Delphi 慢 2 到 4 倍
  • 验证缓存绑定一分钟时间桶,短时间内重复检查同一批字节很便宜,而缓存裁决的寿命绝不超过它被算出的那一分钟
  • 合并修订版权限缓存实现过、调试过、然后删掉了。它没缩小 FPC 差距,还让 Delphi 安全回调可测量地变慢——留缓存之前先测量,这是个好提醒
  • 签名验证需要原始加密文件字节,所以源要留着。解密再快也免不了这条要求

FAQ

复用预扫密钥会削弱密码检查吗?

不会。HotPDF 只在验证过密码的同一个密码重载调用内部复用已恢复的文件密钥——用的是完整的 Algorithm 2.A 检查——scope 标志在 finally 块里清除。单独调 DecryptLoadedDocument 总是做完整推导,所以早先打开留下的密钥不可能被错误密码借走

为什么不跨加载缓存 R6 散列结果?

跨加载缓存散列意味着不对照被打开的文件检查就接受密码。HotPDF 把每个外部字节源、每个显式密码都当作必须自行认证的不可信输入,所以可测量的加速来自把 Algorithm 2.B 变快,而不是跳过它

该给 LoadFromStream 传 TFileStream 吗?

磁盘上的文件优先 LoadFromFile,它内部已经有缓冲。需要流重载时,传 TMemoryStream 或 TBufferedFileStream——HotPDF 在第一遍结构解析期间直接读你给的流,裸句柄流每个小读取都要付一次系统调用

密码保护加载、AES-256 R6 加密、CMS 签名验证和已加载文档 API 的其余部分一起随一个面向 Delphi 与 C++Builder 的原生 VCL 库发布。功能细节与支持的 IDE 版本见 HotPDF Component 产品页