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 在属性上也能提供同样路径:ActivateProtection、CryptKeyLength、UseAES256R6,PDFlibPas 也在 加密权限审计文章里给出 EncryptFile 单次调用的流程
相关能力同样在 HotPDF Component 与 PDFlibPas 页面中有完整参考说明