技术文章

面向巨型文档的高速 AES-256 PDF 加密

加密一个 2 GB 的 PDF 听起来像一个流式问题:打开文件,把两个吉字节推过 AES-256,写出结果。那个心智模型以一种决定整个性能预算的方式错了。ISO 32000-1 §7.6 把 PDF 加密的粒度设在单个对象上——每个流和每个字符串都被单独加密,各自带自己的初始化向量和自己的填充。一个带 50 万个对象的 2 GB 扫描存档是 50 万次小块 CBC 运算,而不是一次长流,而在那个规模上每次运算周围的固定成本比它内部的 AES 算术更重要

本文是关于那个固定成本的:Delphi 代码把 AES-256 应用到非常大的文档时时间花在哪里,以及如何把它找回来。关于设置侧——密码、权限标志、修订版 5 对 6 的兼容性取舍——参见配套文章在 HotPDF 中配置 AES-256 加密;这里一概不重复

50 万次 CBC 运算,而非一次长流

文件的骨架保持明文。交叉引用表、对象号、字典键、页面树:这些没有一个被加密,这正是阅读器能在校验密码之前定位对象的方式。标准加密的是内容——页面描述、图片、字体和附件等流数据,外加元数据值和注解文本等字符串。在 AES-256 加密过滤器下,每一个都被各自处理:一个新鲜的随机 16 字节 IV、对字节的 CBC、到 16 字节边界的块填充,以及 IV 以明文写在密文之前

PDF 示意图:AES-256 PDF 加密粒度——一个共享的 256 位文件密钥分别密封五十万个内容流和字符串,而结构骨架保持明文
骨架保持可读,每条流和每个字符串各自封存 — 每项携带全新 16 字节 IV、CBC 链接与块填充,共用同一把 256 位文件密钥

由此得出两个后果。第一,密文总是比明文长:IV 加 16 字节,填充再加 1 到 16 字节,所以一个 100 字节的字符串在磁盘上占 128 字节,而一个空流仍产出 32 字节。把输出缓冲区按输入长度设定、或只写回与读取同样多字节的代码,产出的文件会在每个对象的最后一块解密失败。第二,成本跟踪的是对象计数,而不仅仅是字节计数。一个扫描存档把字节集中在少数几个大型图片流中,但携带着数十万个短流和小字符串,那里付账的是每次运算的开销,而不是 AES

AES-256 设计中唯一的仁慈之处是密钥处理。直到修订版 4 的安全处理器通过把文件密钥与对象号和生成号一起哈希来为每个对象派生一个不同的密钥,迫使每次都做一次新的密钥扩展。/V 5 方案放弃了按对象派生:一个随机的 256 位文件密钥加密文档中的每个对象。那个事实授权了下面的每一项优化——昂贵的密码学状态可以每个文件构建一次,而不是每个对象构建一次

R6 的 /Encrypt 字典:一次缓慢的打开,廉价的对象

一个修订版 6 的文档在 trailer 的 /Encrypt 字典中声明其方案,而要紧的条目在几行之内:

/Filter /Standard
/V 5  /R 6  /Length 256
/CF << /StdCF << /CFM /AESV3  /Length 32  /AuthEvent /DocOpen >> >>
/StmF /StdCF    /StrF /StdCF
/O ...48 bytes...   /U ...48 bytes...
/OE ...32 bytes...  /UE ...32 bytes...
/Perms ...16 bytes...  /P -3904  /EncryptMetadata true

/V 5 选择 256 位密钥架构,/R 6 选择加固的 ISO 32000-2 握手。/CF 定义命名的加密过滤器——/AESV3 意味着带前置 IV 的 CBC 模式 AES-256——而 /StmF 和 /StrF 分别把该过滤器分配给流和字符串。/O、/U、/OE 和 /UE 持有密码验证和密钥包装材料,而 /Perms 携带权限位的一份 AES 加密副本,这样敌意编辑器就无法悄悄翻转 /P

成本结构藏在 /OE 和 /UE 中。从它们解包文件密钥运行的是算法 2.B,一个把 SHA-256、SHA-384 和 SHA-512 轮次链在一起的迭代密钥派生函数——至少 64 轮,带一个数据依赖的停止规则——刻意构建得慢,以使密码猜测保持昂贵。那笔价格在写入者产出文件时付一次、在阅读器打开它时付一次,各自个位数毫秒。在一个 50 万对象的文件上 KDF 是噪声,而如果一次保存很慢,算法 2.B 不是嫌疑;按对象的循环才是

复用密钥句柄,复用暂存缓冲区

朴素的实现是一个整洁的工具函数:一个 EncryptAes256Cbc 助手,打开 Windows CNG 提供程序、选择 CBC、生成密钥对象、加密一个缓冲区、然后拆除一切。正确、可单元测试,但在一个 50 万次迭代的循环里是灾难性的。微软的文档标注 BCryptOpenAlgorithmProvider 昂贵并建议缓存句柄,而 BCryptGenerateSymmetricKey 运行完整的 AES 密钥扩展并分配提供程序状态——在密钥从不跨文档改变时纯属浪费

Delphi RTL 不附带 bcrypt 导入单元,所以直接声明入口点。下面的类一次性构建所有密码学状态,然后加密任意数量的对象而稳态下不分配:

PDF 对比:朴素的 AES-256 辅助例程为每个 PDF 对象重建 Windows CNG 提供程序、链接模式与密钥调度,对比提升到循环外的 TPdfObjectEncryptor 构造——其循环内不做任何分配
重开 CNG 提供者并重建密钥编排每个对象约花 150 微秒;提升后的构造函数只付一次,热循环不再有任何分配
uses
  Winapi.Windows, System.SysUtils, System.Classes;

const
  BCRYPT_AES_ALGORITHM  = 'AES';
  BCRYPT_CHAINING_MODE  = 'ChainingMode';
  BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
  BCRYPT_OBJECT_LENGTH  = 'ObjectLength';
  BCRYPT_BLOCK_PADDING            = $00000001;
  BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;

type
  NTSTATUS = Integer;
  BCRYPT_HANDLE = Pointer;

function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
  Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
  Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
  cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
  cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
  out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
  cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
  external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
  Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
  out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
  cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';

procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
  if Status <> 0 then
    raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG 密钥对象工作区,仅分配一次
    FScratch: TBytes;    // 密文临时缓冲区,增长后保持不变
  public
    constructor Create(const FileKey: TBytes);
    destructor Destroy; override;
    procedure EncryptObject(const Plain: TBytes; Dest: TStream);
  end;

constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
  Mode: string;
  ObjLen, Got: ULONG;
begin
  inherited Create;
  if Length(FileKey) <> 32 then
    raise Exception.Create('AES-256 file key must be 32 bytes');
  CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
    'BCryptOpenAlgorithmProvider');
  Mode := BCRYPT_CHAIN_MODE_CBC;
  CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
    PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
    'BCryptSetProperty');
  CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
    SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
  SetLength(FKeyObject, ObjLen);
  // AES 密钥扩展在此构建一次,并为每个对象复用
  CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
    PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;

destructor TPdfObjectEncryptor.Destroy;
begin
  if FKey <> nil then
    BCryptDestroyKey(FKey);
  if FAlg <> nil then
    BCryptCloseAlgorithmProvider(FAlg, 0);
  inherited;
end;

procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
  IV, IVWork: array[0..15] of Byte;
  Need, Written: ULONG;
  Src: PByte;
begin
  // 每个对象使用一份全新的随机 IV;它以明文形式位于数据之前
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // 输入为空时 nil 也是合法的:仅填充的块

  // 尺寸查询:CBC 填充总是增加 1..16 字节,因此 Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt 在链式加密时会推进 IV 缓冲区
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');

  if ULONG(Length(FScratch)) < Need then
    SetLength(FScratch, Need);  // 增长若干次后便保持不变

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // AESV3 布局:先是 16 字节的 IV,然后是填充后的密文
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

有三处细节是承重的。尺寸查询——第一次 BCryptEncrypt 调用,带一个 nil 输出缓冲区——返回填充后的密文长度,永远不等于输入长度;填充是确定性的,所以你可以自己计算 ((Len div 16) + 1) * 16 并把调用次数减半,但查询是文档化的契约。第二,BCryptEncrypt 在链式过程中就地推进 IV 缓冲区,所以每次调用放进一个工作副本,而原始 IV 落到输出里。第三,FScratch 只增长,直到文件中最大的对象为止,之后循环不分配任何东西

句柄复用值多少,有度量

逼迫出这次练习的文件是一个 1.8 GB 的扫描贷款存档:412,000 个加密对象,在减去明文结构后携带 1,710 MB 的载荷。同一台机器、同一份文件、NVMe 存储、单线程:

  • 每次调用设置(提供程序在助手内部打开并生成密钥):加密阶段 71.3 秒——1,710 MB ÷ 71.3 s ≈ 24 MB/s
  • 状态上提(上面的类):9.6 秒——1,710 MB ÷ 9.6 s ≈ 178 MB/s

差异是横跨 412,000 次调用的 61.7 秒,即每次调用大约 150 µs 花在打开提供程序、设置链式模式和为一个从不改变的密钥重建密钥扩展上。这些没有一项是密码学。在 AES-NI 下,大缓冲区的 CBC 加密在一个核心上跑到接近 1.4 GB/s,所以 AES 算术本身在 9.6 秒中占大约 1.2 秒;其余大部分是每个对象两次用户态 BCryptEncrypt 转换加上按对象的 IV 生成。批量处理 IV——一次 BCryptGenRandom 调用填满 4,096 个——把运行裁剪到 8.9 秒。过了那一点你就到了 API 的按对象地板,而剩下的杠杆是并行:/V 5 对象在共享的文件密钥下是独立的,所以四个工作线程各带一个密钥对象把阶段降到了 3.1 秒,之后输出写入器成为串行化点

PDF:1.8 GB 扫描版 PDF 的 AES-256 加密耗时条形图:从 71.3 秒降到提升 CNG 状态后的 9.6 秒、批量 IV 后的 8.9 秒,再用四个工作线程后为 3.1 秒
在含 412,000 个对象的 1.8 GB 扫描件上实测:提升 CNG 状态回收约 61.7 秒纯 API 开销,成批 IV 再进一步削减,四个工作线程在写入器串行化之前达到 3.1 秒

完整重写与增量保存

粒度也决定了一次保存的成本。给一个已有的明文文档添加加密,按定义重写每个对象:每个流和字符串的内容和长度都改变,每个交叉引用偏移都移动,不存在增量路径。把它当作一次完整的顺序重写来预算,并写入一个之后重命名覆盖目标的临时文件,因为加密中途崩溃否则会留下一个没有任何密码能打开的半加密文件

反方向是廉价的那一个。一旦文件被加密,一次增量更新追加用同一文件密钥加密的新对象,而每个原始字节都原封不动。给一个 2 GB 加密存档盖戳一条审批注解,成本是千字节量级的追加输出,而非一次 2 GB 的重写。流水线推论:把加密作为任务的最后一步做一次,让后续触碰搭增量保存的便车。一次还轮换文件密钥的密码轮换又是一次完整重写——像那样安排它

不愚弄自己的吞吐度量

加密吞吐声明往往在分子、分母或两者上都错。分子应是载荷字节:实际推过 AES 的流和字符串长度之和,在压缩之后,写入器可以在行进中累加它。文件大小高估了它——上面的存档在磁盘上是 1.8 GB,但其中只有 1,710 MB 真正触碰加密器。分母应仅是加密阶段,用 System.Diagnostics 的 TStopwatch 括起来,把解析、deflate 和磁盘 I/O 放在括号之外。把那些折进来,同样的加密代码在一份只是压缩得更差的文件上会度量出慢几倍。上面的数字之所以可比,恰恰是因为除法的两边都是仅加密的

这些都不必是你拥有的代码。HotPDF 把同样的工程包裹在组件属性——ActivateProtectionCryptKeyLengthUseAES256R6——后面,处于交互式 VCL 应用程序合适的高度,赋值顺序的陷阱在HotPDF 的 AES-256 文章中涵盖。对于无人值守的流水线,PDF Library for Delphi 在一次 EncryptFile 调用中按 Strength 4 把 AES-256 修订版 6 应用到已有文件,并在事后校验磁盘上落下了什么——这是一条在PDF Library for Delphi 加密审计文章中走通的流程

此处描述的加密路径随面向 Delphi 和 C++Builder 的 HotPDF Delphi Component以及 PDF Library for Delphi 库发布;两个产品页都带有完整的加密参考