Kryptering af en 2 GB PDF lyder som et streaming-problem: åbn filen, skub to gigabyte gennem AES-256, skriv resultatet. Den mentale model er forkert på en måde, der afgør hele ydeevnebudgettet. ISO 32000-1 §7.6 sætter granulariteten af PDF-kryptering ved det enkelte objekt — hver strøm og hver streng krypteres separat, hver med sin egen initialiseringsvektor (initialization vector) og sin egen polstring (padding). Et 2 GB scannet arkiv med 500.000 objekter er 500.000 små CBC-operationer, ikke én lang gennemgang, og på den skala betyder de faste omkostninger omkring hver operation mere end AES-aritmetikken indeni den
Denne artikel handler om den faste omkostning: hvor tiden går hen, når Delphi-kode anvender AES-256 på meget store dokumenter, og hvordan man får den tilbage. For opsætningssiden — adgangskoder, rettighedsflag, revision 5 versus 6-kompatibilitetskaldet — se den ledsagende artikel om opsætning af AES-256-kryptering i HotPDF; intet af det gentages her
En halv million CBC-operationer, ikke én gennemgang
Filens skelet forbliver i almindelig tekst (plaintext). Krydsreference-tabeller, objektnumre, ordbogsnøgler, sidetræet: intet af det er krypteret, hvilket er hvordan en læser kan lokalisere objekter, før den har valideret en adgangskode. Det, standarden krypterer, er indhold — strøm-data såsom sidebeskrivelser, billeder, skrifttyper og vedhæftede filer, plus strenge såsom metadata-værdier og annotationstekst. Under AES-256-kryptofiltret behandles hver enkelt for sig: en frisk tilfældig 16-byte IV, CBC over bytene, blok-polstring (block padding) til en 16-byte grænse, og IV'en skrevet i klartekst foran cifferteksten (ciphertext)
Der følger to konsekvenser. For det første er ciffertekst altid længere end almindelig tekst: IV'en tilføjer 16 bytes, og polstringen tilføjer 1 til 16 mere, så en 100-byte streng optager 128 bytes på disken, og en tom strøm producerer stadig 32. Kode, der dimensionerer output-bufferen til inputlængden, eller kun skriver lige så mange bytes tilbage, som den læste, producerer filer, der fejler ved dekryptering ved den sidste blok af hvert objekt. For det andet følger omkostningerne (cost tracks) objekttællingen, ikke kun bytetællingen. Et scannet arkiv koncentrerer sine bytes i et par store billedstrømme, men bærer hundredtusindvis af korte strømme og små strenge, hvor pr.-operation overhead, ikke AES, udgør regningen
Den ene barmhjertighed i AES-256-designet er nøglehåndtering (key handling). Sikkerhedshandlere op til revision 4 afledte en distinkt nøgle for hvert objekt ved at hashe fil-nøglen sammen med objekt- og generationsnumrene, hvilket tvang en frisk nøgleplan (key schedule) hver gang. /V 5-skemaerne droppede pr.-objekt afledning: én tilfældig 256-bit fil-nøgle krypterer hvert objekt i dokumentet. Denne kendsgerning berettiger enhver optimering nedenfor — den dyre kryptografiske tilstand kan bygges én gang pr. fil, ikke én gang pr. objekt
R6 /Encrypt-ordbogen: én langsom åbning, billige objekter
Et revision 6 dokument erklærer sit skema i trailerens /Encrypt-ordbog, og de poster, der betyder noget, passer ind på et par linjer:
/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ælger 256-bit nøglearkitekturen og /R 6 det hærdede ISO 32000-2 håndtryk. /CF definerer det navngivne kryptofilter — /AESV3 betyder AES-256 i CBC-tilstand med den foranstillede IV — og /StmF og /StrF tildeler det filter til henholdsvis strømme og strenge. /O, /U, /OE og /UE rummer adgangskodebekræftelsen (password verification) og nøgleindpakningsmaterialet (key-wrapping material), og /Perms bærer en AES-krypteret kopi af rettighedsbittene, så en fjendtlig editor ikke i stilhed kan vende (flip) /P
Omkostningsstrukturen gemmer sig i /OE og /UE. Udpakning (unwrapping) af fil-nøglen fra dem kører Algoritme 2.B, en itereret nøgleafledningsfunktion (KDF), der kæder SHA-256, SHA-384 og SHA-512 runder — mindst 64 af dem, med en dataafhængig stopregel — bygget bevidst langsom, så gætning af adgangskoder forbliver dyrt. Den pris betales én gang, når writeren producerer filen, og én gang, når en læser åbner den, encifrede millisekunder hver. På en fil med en halv million objekter er KDF støj, og hvis en gemning er langsom, er Algoritme 2.B ikke den mistænkte; det er pr.-objekt løkken
Genbrug nøgle-handle, genbrug scratch-buffer
Den naive implementering er en ryddelig hjælpefunktion: en EncryptAes256Cbc-hjælper, der åbner Windows CNG-provideren, vælger CBC, genererer nøgleobjektet, krypterer én buffer og river alt ned. Korrekt, enhedstestbar og katastrofal inde i en 500.000-iterations løkke. Microsofts dokumentation markerer BCryptOpenAlgorithmProvider som dyr og anbefaler at cache handle'et, og BCryptGenerateSymmetricKey kører hele AES-nøgleplanen og allokerer provider-tilstand — rent spild, når nøglen aldrig ændres over dokumentet
Delphi RTL leverer ingen bcrypt import-unit, så erklær entry points direkte. Klassen nedenfor bygger al kryptografisk tilstand én gang og krypterer derefter et hvilket som helst antal objekter uden steady-state allokering:
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 er bærende (load-bearing). Størrelsesforespørgslen (size query) — det første BCryptEncrypt-kald med en nil output-buffer — returnerer den polstrede ciffertekst-længde, som aldrig er lig med inputlængden; polstring er deterministisk, så du kan beregne ((Len div 16) + 1) * 16 selv og halvere kald-antallet, men forespørgslen er den dokumenterede kontrakt. For det andet fremfører BCryptEncrypt IV-bufferen på stedet (in place), efterhånden som den kæder, så en arbejdskopi går ind i hvert kald, og den uberørte IV lander i outputtet. For det tredje vokser FScratch kun, op til det største objekt i filen, hvorefter løkken intet allokerer
Hvad handle-genbrug er værd, målt
Den fil, der fremtvang denne øvelse, var et 1,8 GB scannet lånearkiv: 412.000 krypterede objekter med 1.710 MB payload, når først den almindelige tekststruktur er trukket fra. Samme maskine, samme fil, NVMe-lagring, én tråd:
- Pr.-kald opsætning (provider åbnet og nøgle genereret inde i hjælperen): krypteringsfase 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Tilstand løftet ud (klassen ovenfor): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
Forskellen er 61,7 s på tværs af 412.000 kald, eller groft sagt 150 µs pr. kald brugt på at åbne en provider, sætte en chaining-mode og genopbygge en nøgleplan for en nøgle, der aldrig ændrede sig. Intet af det var kryptografi. Med AES-NI kører CBC-kryptering af store buffere nær 1,4 GB/s på én kerne, så selve AES-aritmetikken tegner sig for omkring 1,2 s af de 9,6; det meste af resten er de to user-mode BCryptEncrypt-overgange (transitions) pr. objekt plus pr.-objekt IV-generering. At batche IV'erne — ét BCryptGenRandom-kald, der fylder 4.096 af dem — trimmede kørslen til 8,9 s. Forbi det er du på pr.-objekt-gulvet for API'et, og den resterende løftestang er parallelitet: /V 5 objekter er uafhængige under den delte fil-nøgle, så fire arbejdertråde med ét nøgleobjekt hver bragte fasen ned på 3,1 s, før output-writeren blev serialiseringspunktet
Fuld omskrivning kontra trinvis gemning
Granularitet afgør også, hvad en gemning koster. Tilføjelse af kryptering til et eksisterende almindeligt tekstdokument omskriver hvert objekt per definition: hver strøm og streng ændrer både indhold og længde, hvert krydsreference-offset flyttes, og der eksisterer ingen trinvis (incremental) sti. Budgetter det som en fuld sekventiel omskrivning, og skriv til en midlertidig fil, der omdøbes over målet, fordi et nedbrud midt i krypteringen ellers efterlader en halv-cifreret fil, som ingen adgangskode vil åbne
Den omvendte retning er den billige. Når en fil er krypteret, tilføjer en trinvis opdatering (incremental update) nye objekter krypteret med den samme fil-nøgle og lader hver original byte være uberørt. At stemple en godkendelsesannotation på et 2 GB krypteret arkiv koster kilobytes af tilføjet (appended) output, ikke en 2 GB omskrivning. Pipelinen-følgeslutningen (corollary): krypter én gang, som jobbets sidste trin, og lad efterfølgende berøringer ride på trinvise gemninger (incremental saves). En adgangskode-rotation, der også roterer fil-nøglen, er en fuld omskrivning igen — planlæg det som én
Måling af gennemstrømning (throughput) uden at narre dig selv
Påstande om krypterings-gennemstrømning har en tendens til at være forkerte i tælleren, nævneren eller begge dele. Tælleren bør være payload-bytes: summen af strøm- og strenglængder, der faktisk skubbes gennem AES, efter komprimering, som writeren kan summere op (total) undervejs. Filstørrelse overvurderer det — arkivet ovenfor er 1,8 GB på disken, men kun 1.710 MB af det rører nogensinde cifferet (the cipher). Nævneren bør alene være krypteringsfasen, indrammet (bracketed) med TStopwatch fra System.Diagnostics, med parsing, deflate og disk I/O uden for parenteserne. Fold dem ind, og den identiske krypteringskode vil måle flere gange langsommere på en fil, der blot komprimeres dårligere. Tallene ovenfor er sammenlignelige, netop fordi begge sider af divisionen kun er kryptering
Intet af dette behøver at være kode, du ejer. HotPDF pakker den samme ingeniørkunst bag komponentegenskaber — ActivateProtection, CryptKeyLength, UseAES256R6 — i den rigtige højde for interaktive VCL-applikationer, med tildelingsrækkefølge-faldgruberne dækket i HotPDF AES-256 artiklen. For uovervågede (unattended) pipelines anvender PDFlibPas AES-256 revision 6 til eksisterende filer i et enkelt EncryptFile-kald ved Strength 4 og bekræfter bagefter, hvad der landede på disken, et workflow gennemgået i PDFlibPas-artiklen om krypteringsrevision (encryption audit)
De krypteringsstier (encryption paths), der er beskrevet her, leveres i HotPDF-komponenten til Delphi og C++Builder og i PDFlibPas-biblioteket; begge produktsider bærer den komplette krypteringsreference