技术文章

面向超大文档的高性能 AES-256 PDF 加密

2 GB PDF 表面上像是一次流式写入,但这是一种误导。ISO 32000-1 §7.6 规定加密按对象粒度计算,每个流和字符串都带独立 IV 与填充。1.8 GB 扫描档可能包含 412,000 个对象,意味着约 412,000 次 CBC 操作,性能预算里真正的关键常在固定开销而非 AES 算法本身

设置部分与 HotPDF 方案请见 Delphi 中配置 AES-256 的 companion 文章,本文只补上实现层面的吞吐瓶颈

不是一条流水线,而是 500,000 次小任务

文档索引、xref、目录结构始终是明文,真正被加密的是流与字符串。每个对象都会重复:新 IV、CBC 变换、填充,密文长度因此总是大于原文,100 字节会变成 128 字节,空流也会产生 32 字节输出

/V 5 + /R 6 的关键作用是把密钥派生与对象级行为收束到文档级;从 rev 4 到 rev 6,独立对象密钥逐步变成一个共享文件密钥。这样后续每个对象加密都可以复用状态,而不是每次都做完整密钥推导,这是后面优化成立的前提

有两个后果。第一,密文长度总比明文长:IV 固定先写 16 字节,填充再加 1 到 16 字节,所以 100 字节字符串在磁盘上是 128 字节,空流也会输出 32 字节;若只按输入长度分配缓冲或只回写已读字节数,会在每个对象最后一个块解密失败。第二,吞吐成本更多取决于对象数量而不是总字节,特别是扫描档里虽然大部分字节在少数大图流里,但大量短流和短字符串才主导每对象开销

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 对应 CBC + 前置 IV,/StmF/StrF 把流与字符串都绑定到该过滤器。/O 与 /U 做口令验证,/OE 与 /UE 保存 key wrap 材料,/Perms 持有加密后的权限位用于防篡改

最需要关心的是 /OE/UE。解包文件密钥时会执行 Algorithm 2.B,按 SHA-256/384/512 迭代,至少 64 轮且带数据相关停机规则,故意被设计得慢以抬高口令暴力成本。这个成本在写入和读取时各发生一次,通常是毫秒级;在半百万对象文件里它只是背景噪声,真正拖慢保存的往往是对象级循环

复用 CNG 句柄与对象缓冲

最常见的性能错误是每次对象都重复打开算法提供者、设置 CBC 模式、生成密钥。微软说明这几步代价高,且 BCryptGenerateSymmetricKey 还会重建 AES 密钥计划。正确做法是把这些都在 class constructor 里完成

最直接的错误实现是把加密写成一个小工具:每次打开 CNG 提供者、设置 CBC、生成密钥对象、加密一块数据、然后销毁。逻辑看起来清晰,但在 500,000 次调用下会被固定开销吞没

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 key-object workspace, allocated once
    FScratch: TBytes;    // ciphertext scratch, grows and then stays
  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);
  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
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);

  IVWork := 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');

  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

这三点分别对应负载关键路径。第一点是长度查询:第一条 BCryptEncrypt 返回加密后字节数,不会等于输入长度,所以可以据此申请一次缓冲空间;第二点是 IVWork 的副本语义,它必须每次重新传入,否则下一个对象会沿用前一块 IV;第三点是 FScratch 的扩容策略,只有在对象变大时才触发,后续都用峰值上界复用

性能对照与可行优化

以下是 1.8 GB 档案的单线程对比

  • 每次调用重复设置:71.3 秒,约 24 MB/s
  • 状态上提后:9.6 秒,约 178 MB/s

差距 61.7 秒基本来自于 provider 打开、模式设置与一次性密钥调度。开启 AES-NI 后,AES 本身只占约 1.2 秒,多线程化可继续提升,上限取决于输出写入,4 个线程时可降到 3.1 秒左右

全量重写与增量更新的边界

加密明文文档必然全量改写,因为每对象长度变化会导致交叉引用偏移整体漂移。加密后的文档则可以通过增量更新追加内容并保留原始对象,成本可显著下降;更换口令并改密钥时仍需全量改写

这也意味着“增量更新是便宜的”只在已加密文档成立。明文文档若要加密必须一次性触及所有对象;否则任何偏移变化都会使整套 xref 与交叉引用偏移失效

吞吐测量口径

吞吐率应以通过 AES 的有效载荷字节计,不应以文件总大小计。还要把计时范围限定在加密阶段,排除解析、压缩和 IO。这样两次对比才有可比性,避免把解码或盘速问题误判成加密算法回退

如果你不希望维护这套底层状态,HotPDF 在属性上也能提供同样路径:ActivateProtectionCryptKeyLengthUseAES256R6,PDFlibPas 也在 加密权限审计文章里给出 EncryptFile 单次调用的流程

相关能力同样在 HotPDF ComponentPDFlibPas 页面中有完整参考说明