Att kryptera en 2 GB stor PDF låter som ett strömningsproblem: öppna filen, tryck två gigabyte genom AES-256, skriv resultatet. Den mentala modellen är fel på ett sätt som avgör hela prestandabudgeten. ISO 32000-1 §7.6 sätter granulariteten för PDF-kryptering vid det enskilda objektet — varje ström och varje sträng krypteras separat, var och en med sin egen initialiseringsvektor och sin egen utfyllnad (padding). Ett skannat arkiv på 2 GB med 500 000 objekt innebär 500 000 små CBC-operationer, inte en lång körning, och vid den skalan betyder den fasta kostnaden kring varje operation mer än AES-aritmetiken inuti den
Den här artikeln handlar om den fasta kostnaden: vart tiden tar vägen när Delphi-kod tillämpar AES-256 på mycket stora dokument, och hur man får tillbaka den. För konfigurationssidan — lösenord, behörighetsflaggor, anropet för kompatibilitet mellan revision 5 och 6 — se den medföljande texten om konfiguration av AES-256-kryptering i HotPDF; inget av det upprepas här
En halv miljon CBC-operationer, inte en körning
Filens skelett förblir i klartext. Korsreferenstabeller, objektnummer, ordboksnycklar, sidträdet: inget av det är krypterat, vilket är hur en läsare kan hitta objekt innan den har validerat ett lösenord. Vad standarden krypterar är innehåll — strömdata som sidbeskrivningar, bilder, typsnitt och bilagor, plus strängar som metadatavärden och kommentarstexter. Under AES-256 kryptofiltret bearbetas varje del för sig: en ny slumpmässig 16-byte IV, CBC över byten, blockutfyllnad till en 16-bytes gräns, och IV skriven i klartext före chiffertexten
Två konsekvenser följer. För det första är chiffertext alltid längre än klartext: IV lägger till 16 byte och utfyllnaden lägger till ytterligare 1 till 16, så en sträng på 100 byte tar upp 128 byte på disken och en tom ström producerar fortfarande 32. Kod som dimensionerar utdatabufferten efter indatalängden, eller bara skriver tillbaka så många byte som den läste, producerar filer som misslyckas med att dekrypteras vid det sista blocket av varje objekt. För det andra följer kostnaden antalet objekt, inte bara antalet byte. Ett skannat arkiv koncentrerar sina byte i ett fåtal stora bildströmmar, men bär hundratusentals korta strömmar och små strängar där omkostnaden per operation, inte AES, är det som kostar
Den enda nåden i AES-256-designen är nyckelhanteringen. Säkerhetshanterare upp till revision 4 härledde en distinkt nyckel för varje objekt genom att hasha filnyckeln tillsammans med objektet och generationsnumren, vilket tvingade fram ett nytt nyckelschema varje gång. /V 5-scheman släppte den objektbaserade härledningen: en enda slumpmässig 256-bitars filnyckel krypterar varje objekt i dokumentet. Det faktumet möjliggör varje optimering nedan — det dyra kryptografiska tillståndet kan byggas en gång per fil, inte en gång per objekt
R6 /Encrypt-ordboken: en långsam öppning, billiga objekt
Ett dokument i revision 6 deklarerar sitt schema i trailerns /Encrypt-ordbok, och posterna som spelar roll får plats på några få rader:
/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 väljer 256-bitars nyckelarkitektur och /R 6 den härdade ISO 32000-2 handskakningen. /CF definierar det namngivna kryptofiltret — /AESV3 betyder AES-256 i CBC-läge med inledande IV — och /StmF och /StrF tilldelar det filtret till strömmar respektive strängar. /O, /U, /OE och /UE innehåller lösenordsverifieringen och nyckelinkapslingsmaterialet, och /Perms bär en AES-krypterad kopia av behörighetsbitarna så att en fientlig redigerare inte tyst kan vända på /P
Kostnadsstrukturen döljer sig i /OE och /UE. Att packa upp filnyckeln från dem kör Algoritm 2.B, en itererad nyckelhärledningsfunktion som kedjar SHA-256-, SHA-384- och SHA-512-rundor — minst 64 av dem, med en databeroende stoppregel — avsiktligt byggd långsamt så att lösenordsgissning förblir dyrt. Det priset betalas en gång när skribenten producerar filen och en gång när en läsare öppnar den, ensiffriga millisekunder vardera. På en fil med en halv miljon objekt är KDF brus, och om en sparning är långsam är inte Algoritm 2.B den misstänkta; loopen per objekt är det
Återanvänd nyckelhandtaget, återanvänd skrapbufferten
Den naiva implementationen är en prydlig verktygsfunktion: en EncryptAes256Cbc-hjälpare som öppnar Windows CNG-leverantören, väljer CBC, genererar nyckelobjektet, krypterar en buffert och river ner allt. Korrekt, enhetstestbar och katastrofal inuti en loop med 500 000 iterationer. Microsofts dokumentation flaggar BCryptOpenAlgorithmProvider som dyr och rekommenderar att man cachar handtaget, och BCryptGenerateSymmetricKey kör hela AES-nyckelschemat och allokerar leverantörstillstånd — rent slöseri när nyckeln aldrig ändras genom hela dokumentet
Delphi RTL levererar ingen bcrypt-importenhet, så deklarera ingångspunkterna direkt. Klassen nedan bygger hela det kryptografiska tillståndet en gång och krypterar sedan valfritt antal objekt utan allokering i jämviktstillstånd (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;
Tre detaljer är bärande. Storleksförfrågan — det första BCryptEncrypt-anropet, med en nil-utdatabuffert — returnerar den utfyllda chiffertextens längd, aldrig lika med indatalängden; utfyllnad är deterministisk, så du kan beräkna ((Len div 16) + 1) * 16 själv och halvera antalet anrop, men förfrågan är det dokumenterade kontraktet. För det andra för BCryptEncrypt fram IV-bufferten på plats när den kedjas, så en arbetskopia går in i varje anrop och den orörda IV:n hamnar i utdata. För det tredje växer FScratch bara, upp till det största objektet i filen, varefter loopen inte allokerar någonting
Vad återanvändning av handtag är värt, uppmätt
Filen som framtvingade denna övning var ett 1,8 GB skannat lånearkiv: 412 000 krypterade objekt bärande 1 710 MB av nyttolast när klartextstrukturen subtraherats. Samma maskin, samma fil, NVMe-lagring, en tråd:
- Inställning per anrop (leverantör öppnad och nyckel genererad inuti hjälparen): krypteringsfas 71,3 s — 1 710 MB ÷ 71,3 s ≈ 24 MB/s
- Tillstånd upplyft (klassen ovan): 9,6 s — 1 710 MB ÷ 9,6 s ≈ 178 MB/s
Skillnaden är 61,7 s över 412 000 anrop, eller ungefär 150 µs per anrop spenderat på att öppna en leverantör, ställa in ett kedjeläge och bygga om ett nyckelschema för en nyckel som aldrig ändrades. Inget av det var kryptografi. Med AES-NI körs CBC-kryptering av stora buffertar i nära 1,4 GB/s på en kärna, så själva AES-aritmetiken står för cirka 1,2 s av de 9,6; det mesta av resten är de två BCryptEncrypt-övergångarna i användarläge per objekt plus IV-generering per objekt. Att bunta ihop IV:erna — ett BCryptGenRandom-anrop som fyller 4 096 av dem — trimmade körningen till 8,9 s. Efter det är du på API:ets golv per objekt, och den återstående hävstången är parallellism: /V 5-objekt är oberoende under den delade filnyckeln, så fyra arbetstrådar med varsitt nyckelobjekt tog fasen till 3,1 s innan utdataskrivaren blev serialiseringspunkten
Full omskrivning kontra inkrementell sparning
Granularitet avgör också vad en sparning kostar. Att lägga till kryptering i ett befintligt klartextdokument skriver per definition om varje objekt: varje ström och sträng ändrar både innehåll och längd, varje korsreferensförskjutning flyttas och det finns ingen inkrementell väg. Budgetera det som en fullständig sekventiell omskrivning, och skriv till en temporär fil som döps om över målet, eftersom en krasch mitt i krypteringen annars lämnar en halvkrypterad fil som inget lösenord kan öppna
Den omvända riktningen är den billiga. När en fil väl är krypterad lägger en inkrementell uppdatering till nya objekt krypterade med samma filnyckel och lämnar varje ursprunglig byte orörd. Att stämpla en godkännandeanteckning på ett 2 GB krypterat arkiv kostar kilobyte av bifogad utdata, inte en 2 GB omskrivning. Följdsatsen för pipelinen: kryptera en gång, som jobbets sista steg, och låt efterföljande beröringar rida på inkrementella sparningar. En lösenordsrotation som också roterar filnyckeln är återigen en fullständig omskrivning — schemalägg den som en sådan
Att mäta genomströmning utan att lura sig själv
Krypteringsgenomströmningspåståenden tenderar att ha fel i täljaren, nämnaren eller båda. Täljaren ska vara nyttolastbyte: summan av ström- och stränglängder som faktiskt pressats genom AES, efter komprimering, som skrivaren kan summera vartefter den går. Filstorleken överdriver det — arkivet ovan är 1,8 GB på disken, men bara 1 710 MB av det rör någonsin chiffret. Nämnaren bör enbart vara krypteringsfasen, inramad med TStopwatch från System.Diagnostics, med tolkning, deflate och disk-I/O utanför parenteserna. Vik in dessa så kommer den identiska krypteringskoden att mäta flera gånger långsammare på en fil som helt enkelt komprimeras sämre. Siffrorna ovan är jämförbara just för att båda sidorna av divisionen enbart är kryptering
Inget av detta måste vara kod som du äger. HotPDF slår in samma ingenjörskonst bakom komponentegenskaper — ActivateProtection, CryptKeyLength, UseAES256R6 — på rätt höjd för interaktiva VCL-applikationer, med tilldelningsordningens fallgropar täckta i HotPDF AES-256-artikeln. För obevakade pipelines applicerar PDFlibPas AES-256 revision 6 på befintliga filer i ett enda EncryptFile-anrop på Strength 4 och verifierar i efterhand vad som landade på disken, ett arbetsflöde som gås igenom i PDFlibPas-krypteringsrevisionsartikeln
Krypteringsvägarna som beskrivs här levereras i HotPDF-komponenten för Delphi och C++Builder och i PDFlibPas-biblioteket; båda produktsidorna innehåller den kompletta krypteringsreferensen