Het versleutelen van een 2 GB PDF klinkt als een streamingprobleem: open het bestand, duw twee gigabyte door AES-256, schrijf het resultaat. Dat mentale model is onjuist op een manier die het volledige prestatiebudget bepaalt. ISO 32000-1 §7.6 stelt de granulariteit van PDF-versleuteling in op het individuele object — elke stream en elke string wordt apart versleuteld, elk met een eigen initialisatievector en eigen opvulling (padding). Een gescand archief van 2 GB met 500.000 objecten is 500.000 kleine CBC-bewerkingen, geen enkele lange doorgang, en op die schaal doen de vaste kosten rond elke bewerking er meer toe dan de AES-rekenkunde daarbinnen
Dit artikel gaat over die vaste kosten: waar de tijd naartoe gaat wanneer Delphi-code AES-256 toepast op zeer grote documenten, en hoe je die terugkrijgt. Voor de instellingskant — wachtwoorden, toestemmingsvlaggen, de compatibiliteitsaanroep van revisie 5 versus 6 — zie het begeleidende stuk over het configureren van AES-256 versleuteling in HotPDF; niets daarvan wordt hier herhaald
Een half miljoen CBC-bewerkingen, niet één doorgang
Het skelet van het bestand blijft in leesbare tekst. Kruisverwijzingstabellen (cross-reference tables), objectnummers, dictionary-sleutels, de paginastructuur: niets hiervan is versleuteld, waardoor een lezer objecten kan lokaliseren voordat deze een wachtwoord heeft gevalideerd. Wat de standaard versleutelt is inhoud — streamgegevens zoals paginabeschrijvingen, afbeeldingen, lettertypen en bijlagen, plus strings zoals metagegevenswaarden en annotatietekst. Onder het AES-256 cryptfilter wordt elk op zichzelf verwerkt: een nieuwe willekeurige 16-byte IV, CBC over de bytes, blokopvulling (block padding) tot een 16-byte grens, en de IV duidelijk geschreven vóór de cijfertekst
Hieruit volgen twee consequenties. Ten eerste is cijfertekst altijd langer dan leesbare tekst: de IV voegt 16 bytes toe en de opvulling voegt er 1 tot 16 meer aan toe, dus een string van 100 bytes neemt 128 bytes op schijf in beslag en een lege stream produceert er nog steeds 32. Code die de uitvoerbuffer afstemt op de invoerlengte, of slechts evenveel bytes terugschrijft als werd gelezen, produceert bestanden die niet kunnen worden ontsleuteld bij het laatste blok van elk object. Ten tweede volgen de kosten het aantal objecten, niet alleen het aantal bytes. Een gescand archief concentreert zijn bytes in een paar grote afbeeldingsstreams, maar bevat honderdduizenden korte streams en kleine strings waarbij de overhead per bewerking, en niet AES, de rekening vormt
De enige genade in het AES-256-ontwerp is de sleutelafhandeling. Beveiligingshandlers tot en met revisie 4 leidden voor elk object een afzonderlijke sleutel af door de bestandssleutel samen met de object- en generatienummers te hashen, wat elke keer een nieuw sleutelschema afdwong. De /V 5-schema's lieten de afleiding per object vallen: één willekeurige 256-bit bestandssleutel versleutelt elk object in het document. Dat feit rechtvaardigt elke onderstaande optimalisatie — de dure cryptografische status kan één keer per bestand worden opgebouwd, in plaats van één keer per object
De R6 /Encrypt-dictionary: één trage opening, goedkope objecten
Een revisie 6-document verklaart zijn schema in de /Encrypt-dictionary van de trailer, en de items die ertoe doen passen op een paar regels:
/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 selecteert de 256-bit sleutelarchitectuur en /R 6 de geharde ISO 32000-2 handdruk (handshake). /CF definieert het benoemde cryptfilter — /AESV3 betekent AES-256 in CBC-modus met de voorafgaande IV — en /StmF en /StrF wijzen dat filter respectievelijk toe aan streams en strings. /O, /U, /OE en /UE bevatten de materiaal voor wachtwoordverificatie en sleutelverpakking (key-wrapping), en /Perms bevat een met AES-versleutelde kopie van de toestemmingsbits, zodat een kwaadwillende editor /P niet stilzwijgend kan omschakelen
De kostenstructuur verbergt zich in /OE en /UE. Het uitpakken van de bestandssleutel hieruit voert Algoritme 2.B uit, een iteratieve sleutelafleidingsfunctie (KDF) die SHA-256-, SHA-384- en SHA-512-rondes aaneenschakelt — minstens 64 daarvan, met een data-afhankelijke stopregel — die opzettelijk traag is gebouwd zodat het raden van wachtwoorden duur blijft. Die prijs wordt eenmaal betaald wanneer de schrijver het bestand produceert en eenmaal wanneer een lezer het opent, elk slechts enkele milliseconden. Op een bestand van een half miljoen objecten is de KDF ruis, en als een opslag traag is, is Algoritme 2.B niet de verdachte; de lus per object wel
Hergebruik de sleutel-handle, hergebruik de krasbuffer (scratch buffer)
De naïeve implementatie is een nette hulpprogrammafunctie: een EncryptAes256Cbc-helper die de Windows CNG-provider opent, CBC selecteert, het sleutelobject genereert, één buffer versleutelt en alles weer afbreekt. Correct, unit-testbaar, en rampzalig binnen een lus met 500.000 iteraties. De documentatie van Microsoft markeert BCryptOpenAlgorithmProvider als duur en raadt aan de handle te cachen, en BCryptGenerateSymmetricKey voert het volledige AES-sleutelschema uit en wijst de providerstatus toe — pure verspilling wanneer de sleutel binnen het document nooit verandert
De Delphi RTL levert geen bcrypt-import-unit, dus declareer de invoerpunten direct. De onderstaande klasse bouwt de volledige cryptografische status eenmalig op en versleutelt vervolgens een willekeurig aantal objecten zonder toewijzing in stationaire toestand (steady-state):
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;
Drie details zijn cruciaal. De grootte-query — de eerste BCryptEncrypt-aanroep, met een nil-uitvoerbuffer — retourneert de opgevulde cijfertekstlengte, die nooit gelijk is aan de invoerlengte; opvulling is deterministisch, dus je kunt ((Len div 16) + 1) * 16 zelf berekenen en het aantal aanroepen halveren, maar de query is het gedocumenteerde contract. Ten tweede schuift BCryptEncrypt de IV-buffer op zijn plaats vooruit terwijl het chaint, dus gaat er in elke aanroep een werkkopie, waarna de zuivere IV in de uitvoer belandt. Ten derde groeit FScratch alleen, tot aan het grootste object in het bestand, waarna de lus niets meer toewijst
Wat hergebruik van handles waard is, gemeten
Het bestand dat deze oefening afdwong, was een gescand leningarchief van 1,8 GB: 412.000 versleutelde objecten met een laadvermogen (payload) van 1.710 MB nadat de leesbare tekststructuur was afgetrokken. Dezelfde machine, hetzelfde bestand, NVMe-opslag, één thread (thread):
- Setup per aanroep (provider geopend en sleutel gegenereerd binnen de helper): versleutelingsfase 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Status gehesen (hoisted) (de klasse hierboven): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
Het verschil is 61,7 s over 412.000 aanroepen, of ruwweg 150 µs per aanroep besteed aan het openen van een provider, het instellen van een chaining-modus en het opnieuw opbouwen van een sleutelschema voor een sleutel die nooit is veranderd. Niets daarvan was cryptografie. Met AES-NI draait CBC-versleuteling van grote buffers op bijna 1,4 GB/s op één kern, dus de AES-rekenkunde zelf is goed voor ongeveer 1,2 s van de 9,6; het grootste deel van de rest zijn de twee user-mode BCryptEncrypt-overgangen per object plus de IV-generatie per object. Het batchen van de IV's — één BCryptGenRandom-aanroep die er 4.096 vult — verkortte de uitvoering tot 8,9 s. Voorbij dat punt bevind je je op de bodem per object van de API, en de resterende hefboom is parallellisme: /V 5 objecten zijn onafhankelijk onder de gedeelde bestandssleutel, dus vier werkthreads (worker threads) met elk één sleutelobject brachten de fase naar 3,1 s voordat de uitvoerschrijver het serialisatiepunt werd
Volledige herschrijving versus incrementele opslag
Granulariteit bepaalt ook wat een opslag kost. Het toevoegen van versleuteling aan een bestaand document met leesbare tekst herschrijft per definitie elk object: elke stream en string verandert zowel van inhoud als van lengte, elke kruisverwijzing-offset (cross-reference offset) verplaatst zich en er bestaat geen incrementeel pad. Budgetteer het als een volledige sequentiële herschrijving en schrijf naar een tijdelijk bestand dat over het doel heen wordt hernoemd, want een crash halverwege de versleuteling laat anders een half-versleuteld bestand achter dat met geen enkel wachtwoord kan worden geopend
De omgekeerde richting is de goedkope. Zodra een bestand is versleuteld, voegt een incrementele update nieuwe objecten toe die zijn versleuteld met dezelfde bestandssleutel, en laat elke originele byte onaangeroerd. Het stempelen van een goedkeuringsannotatie op een versleuteld archief van 2 GB kost kilobytes aan toegevoegde uitvoer, geen herschrijving van 2 GB. Het logische gevolg voor de pijplijn: versleutel eenmalig, als de laatste stap van de taak, en laat daaropvolgende aanrakingen via incrementele opslag verlopen. Een wachtwoordrotatie die ook de bestandssleutel roteert, is opnieuw een volledige herschrijving — plan dit als zodanig in
Doorvoer meten zonder jezelf voor de gek te houden
Claims over de doorvoer van versleuteling blijken vaak onjuist in de teller, de noemer, of beide. De teller zou de payload-bytes moeten zijn: de som van stream- en stringlengtes die daadwerkelijk door AES zijn geduwd, na compressie, wat de schrijver gaandeweg kan totaliseren. Bestandsgrootte overdrijft het — het bovenstaande archief is 1,8 GB op de schijf, maar slechts 1.710 MB ervan raakt ooit de cijfering (cipher). De noemer zou uitsluitend de versleutelingsfase moeten zijn, omkaderd met TStopwatch van System.Diagnostics, waarbij parseren, deflate en schijf-I/O buiten deze haken vallen. Als je die erbij betrekt, zal de identieke versleutelingscode meerdere malen trager meten op een bestand dat toevallig slechter comprimeert. De bovenstaande cijfers zijn vergelijkbaar, juist omdat beide kanten van de deling uitsluitend om versleuteling draaien
Niets hiervan hoeft code te zijn die je zelf bezit. HotPDF wikkelt dezelfde techniek achter componenteigenschappen — ActivateProtection, CryptKeyLength, UseAES256R6 — op de juiste hoogte voor interactieve VCL-toepassingen, waarbij de valkuilen voor toewijzingsvolgorde worden behandeld in het HotPDF AES-256-artikel. Voor pijplijnen zonder toezicht past PDFlibPas AES-256 revisie 6 toe op bestaande bestanden in één enkele EncryptFile-aanroep op Strength 4, en verifieert achteraf wat er op de schijf is beland, een workflow die wordt doorgenomen in het PDFlibPas-artikel over de versleutelingsaudit
De hier beschreven versleutelingspaden worden geleverd in de HotPDF-component voor Delphi en C++Builder en in de PDFlibPas-bibliotheek; beide productpagina's bevatten de volledige versleutelingsreferentie