Encrypting a 2 GB PDF sounds like a streaming problem: open the file, push two gigabytes through AES-256, write the result. That mental model is wrong in a way that decides the entire performance budget. ISO 32000-1 §7.6 sets the granularity of PDF encryption at the individual object — every stream and every string is encrypted separately, each with its own initialisation vector and its own padding. A 2 GB scanned archive with 500,000 objects is 500,000 small CBC operations, not one long pass, and at that scale the fixed cost around each operation matters more than the AES arithmetic inside it
This article is about that fixed cost: where the time goes when Delphi code applies AES-256 to very large documents, and how to get it back. For the setup side — passwords, permission flags, the revision 5 versus 6 compatibility call — see the companion piece on configuring AES-256 encryption in HotPDF; none of it is repeated here
Half a million CBC operations, not one pass
The file's skeleton stays in plaintext. Cross-reference tables, object numbers, dictionary keys, the page tree: none of it is encrypted, which is how a reader can locate objects before it has validated a password. What the standard encrypts is content — stream data such as page descriptions, images, fonts, and attachments, plus strings such as metadata values and annotation text. Under the AES-256 crypt filter each one is processed on its own: a fresh random 16-byte IV, CBC over the bytes, block padding to a 16-byte boundary, and the IV written in the clear ahead of the ciphertext
Two consequences follow. First, ciphertext is always longer than plaintext: the IV adds 16 bytes and the padding adds 1 to 16 more, so a 100-byte string occupies 128 bytes on disk and an empty stream still produces 32. Code that sizes the output buffer to the input length, or writes back only as many bytes as it read, produces files that fail to decrypt at the last block of every object. Second, cost tracks the object count, not just the byte count. A scanned archive concentrates its bytes in a few large image streams, but carries hundreds of thousands of short streams and small strings where per-operation overhead, not AES, is the bill
The one mercy in the AES-256 design is key handling. Security handlers up to revision 4 derived a distinct key for every object by hashing the file key together with the object and generation numbers, forcing a fresh key schedule each time. The /V 5 schemes dropped per-object derivation: one random 256-bit file key encrypts every object in the document. That fact licenses every optimisation below — the expensive cryptographic state can be built once per file, not once per object
The R6 /Encrypt dictionary: one slow open, cheap objects
A revision 6 document declares its scheme in the trailer's /Encrypt dictionary, and the entries that matter fit in a few lines:
/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 selects the 256-bit key architecture and /R 6 the hardened ISO 32000-2 handshake. /CF defines the named crypt filter — /AESV3 means AES-256 in CBC mode with the prepended IV — and /StmF and /StrF assign that filter to streams and strings respectively. /O, /U, /OE, and /UE hold the password verification and key-wrapping material, and /Perms carries an AES-encrypted copy of the permission bits so a hostile editor cannot silently flip /P
The cost structure hides in /OE and /UE. Unwrapping the file key from them runs Algorithm 2.B, an iterated key-derivation function chaining SHA-256, SHA-384, and SHA-512 rounds — at least 64 of them, with a data-dependent stopping rule — built deliberately slow so that password guessing stays expensive. That price is paid once when the writer produces the file and once when a reader opens it, single-digit milliseconds each. On a half-million-object file the KDF is noise, and if a save is slow, Algorithm 2.B is not the suspect; the per-object loop is
Reuse the key handle, reuse the scratch buffer
The naive implementation is a tidy utility function: an EncryptAes256Cbc helper that opens the Windows CNG provider, selects CBC, generates the key object, encrypts one buffer, and tears everything down. Correct, unit-testable, and disastrous inside a 500,000-iteration loop. Microsoft's documentation flags BCryptOpenAlgorithmProvider as expensive and recommends caching the handle, and BCryptGenerateSymmetricKey runs the full AES key schedule and allocates provider state — pure waste when the key never changes across the document
The Delphi RTL ships no bcrypt import unit, so declare the entry points directly. The class below builds all cryptographic state once and then encrypts any number of objects with no steady-state allocation:
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);
// The AES key schedule is built once here and reused for every object
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
// Fresh random IV per object; it travels in the clear ahead of the data
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // nil for an empty input is valid: padding-only block
// Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
IVWork := IV; // BCryptEncrypt advances the IV buffer while it chains
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); // grows a handful of times, then stays put
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// AESV3 layout: the 16-byte IV, then the padded ciphertext
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
Three details are load-bearing. The size query — the first BCryptEncrypt call, with a nil output buffer — returns the padded ciphertext length, never equal to the input length; padding is deterministic, so you can compute ((Len div 16) + 1) * 16 yourself and halve the call count, but the query is the documented contract. Second, BCryptEncrypt advances the IV buffer in place as it chains, so a working copy goes into each call and the pristine IV lands in the output. Third, FScratch only grows, up to the largest object in the file, after which the loop allocates nothing
What handle reuse is worth, measured
The file that forced this exercise was a 1.8 GB scanned loan archive: 412,000 encrypted objects carrying 1,710 MB of payload once the plaintext structure is subtracted. Same machine, same file, NVMe storage, one thread:
- Per-call setup (provider opened and key generated inside the helper): encryption phase 71.3 s — 1,710 MB ÷ 71.3 s ≈ 24 MB/s
- State hoisted (the class above): 9.6 s — 1,710 MB ÷ 9.6 s ≈ 178 MB/s
The difference is 61.7 s across 412,000 calls, or roughly 150 µs per call spent opening a provider, setting a chaining mode, and rebuilding a key schedule for a key that never changed. None of that was cryptography. With AES-NI, CBC encryption of large buffers runs near 1.4 GB/s on one core, so the AES arithmetic itself accounts for about 1.2 s of the 9.6; most of the rest is the two user-mode BCryptEncrypt transitions per object plus per-object IV generation. Batching the IVs — one BCryptGenRandom call filling 4,096 of them — trimmed the run to 8.9 s. Past that you are at the per-object floor of the API, and the remaining lever is parallelism: /V 5 objects are independent under the shared file key, so four worker threads with one key object each took the phase to 3.1 s before the output writer became the serialisation point
Full rewrite versus incremental save
Granularity also decides what a save costs. Adding encryption to an existing plaintext document rewrites every object by definition: every stream and string changes both content and length, every cross-reference offset moves, and no incremental path exists. Budget it as a full sequential rewrite, and write to a temporary file that is renamed over the target, because a crash mid-encryption otherwise leaves a half-ciphered file that no password will open
The reverse direction is the cheap one. Once a file is encrypted, an incremental update appends new objects encrypted with the same file key and leaves every original byte untouched. Stamping an approval annotation onto a 2 GB encrypted archive costs kilobytes of appended output, not a 2 GB rewrite. The pipeline corollary: encrypt once, as the job's last step, and let subsequent touches ride incremental saves. A password rotation that also rotates the file key is a full rewrite again — schedule it like one
Measuring throughput without fooling yourself
Encryption throughput claims tend to be wrong in the numerator, the denominator, or both. The numerator should be payload bytes: the sum of stream and string lengths actually pushed through AES, after compression, which the writer can total as it goes. File size overstates it — the archive above is 1.8 GB on disk, but only 1,710 MB of it ever touches the cipher. The denominator should be the encryption phase alone, bracketed with TStopwatch from System.Diagnostics, with parsing, deflate, and disk I/O outside the brackets. Fold those in and the identical encryption code will measure several times slower on a file that merely compresses worse. The figures above are comparable precisely because both sides of the division are encryption-only
None of this has to be code you own. HotPDF wraps the same engineering behind component properties — ActivateProtection, CryptKeyLength, UseAES256R6 — at the right altitude for interactive VCL applications, with the assignment-order pitfalls covered in the HotPDF AES-256 article. For unattended pipelines, PDFlibPas applies AES-256 revision 6 to existing files in a single EncryptFile call at Strength 4 and verifies afterwards what landed on disk, a workflow walked through in the PDFlibPas encryption audit article
The encryption paths described here ship in the HotPDF Component for Delphi and C++Builder and in the PDFlibPas library; both product pages carry the complete encryption reference