Šifriranje 2 GB velikega PDF-ja zveni kot problem pretakanja (streaming): odpri datoteko, potisni dva gigabajta skozi AES-256 in zapiši rezultat. Ta miselni model je napačen na način, ki odloča o celotnem proračunu zmogljivosti. Standard ISO 32000-1 §7.6 določa razdrobljenost šifriranja PDF-jev na ravni posameznega objekta — vsak tok in vsak niz sta šifrirana ločeno, vsak s svojim lastnim inicializacijskim vektorjem in lastnim zapolnjevanjem (padding). Skeniran arhiv velikosti 2 GB s 500.000 objekti pomeni 500.000 majhnih CBC operacij in ne enega dolgega prehoda, in pri takšnem obsegu so fiksni stroški okoli vsake operacije pomembnejši od same aritmetike AES znotraj nje
Ta članek obravnava te fiksne stroške: kam gre čas, ko Delphi koda aplicira AES-256 na zelo velike dokumente, in kako ga pridobiti nazaj. Za tisti del, ki se nanaša na nastavitev — gesla, zastavice dovoljenj in odločitev o združljivosti med revizijo 5 in 6 — si oglejte spremljevalni članek o konfiguriranju AES-256 šifriranja v HotPDF; nič od tega tu ne bomo ponavljali
Pol milijona CBC operacij, ne en prehod
Ogrodje datoteke ostane v golem besedilu (plaintext). Tabele navzkrižnih referenc, številke objektov, ključi slovarjev, drevo strani: nič od tega ni šifrirano, in prav zato lahko bralnik locira objekte še preden validira geslo. Standard šifrira vsebino — podatke o tokih, kot so opisi strani, slike, pisave in priloge, ter nize, kot so vrednosti metapodatkov in besedilo opomb. Pod šifrirnim filtrom AES-256 se vsak od njih obdela posebej: svež naključen 16-bajtni inicializacijski vektor (IV), CBC nad bajti, zapolnjevanje blokov do 16-bajtne meje ter IV, ki je zapisan v čisti obliki pred šifriranim besedilom
Iz tega sledita dve posledici. Prvič, šifrirano besedilo je vedno daljše od izvornega: IV doda 16 bajtov, zapolnjevanje pa od 1 do 16 dodatnih bajtov, zato 100-bajtni niz na disku zasede 128 bajtov, prazen tok pa vseeno proizvede 32 bajtov. Koda, ki dimenzionira izhodni medpomnilnik na dolžino vhoda ali nazaj zapiše samo toliko bajtov, kot jih je prebrala, proizvede datoteke, katerih dešifriranje pri zadnjem bloku vsakega objekta spodleti. Drugič, stroški sledijo številu objektov, ne samo številu bajtov. Skeniran arhiv skoncentrira svoje bajte v nekaj velikih slikovnih tokovih, vendar vsebuje na stotine tisočev kratkih tokov in majhnih nizov, kjer glavni strošek ni sam AES, temveč režijski stroški vsake posamezne operacije
Edino olajšanje pri zasnovi AES-256 je ravnanje s ključi. Varnostni mehanizmi do revizije 4 so izpeljali različen ključ za vsak objekt tako, da so zgostili (hash) ključ datoteke skupaj s številkama objekta in generacije, s čimer so vsakič prisilili v pripravo novega urnika ključev (key schedule). Sheme /V 5 so to izpeljavo na ravni posameznih objektov opustile: en naključen 256-bitni ključ datoteke šifrira vsak objekt v dokumentu. To dejstvo omogoča vse spodaj navedene optimizacije — drago kriptografsko stanje se lahko zgradi enkrat na datoteko in ne enkrat na objekt
Slovar R6 /Encrypt: eno počasno odpiranje, poceni objekti
Dokument revizije 6 svojo shemo deklarira v slovarju /Encrypt znotraj trailerja, in pomembni vnosi ustrezajo nekaj vrsticam:
/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 izbere 256-bitno arhitekturo ključa, /R 6 pa poostren postopek preverjanja pristnosti (handshake) po standardu ISO 32000-2. /CF definira poimenovan šifrirni filter — /AESV3 pomeni AES-256 v načinu CBC s predpono IV — medtem ko /StmF in /StrF dodelita ta filter tokovom in nizom. /O, /U, /OE in /UE vsebujejo material za preverjanje gesel in enkapsulacijo ključev (key-wrapping), /Perms pa prenaša z AES šifrirano kopijo bitov dovoljenj, tako da zlonamerni urejevalnik ne more tiho preklopiti /P
Struktura stroškov se skriva v /OE in /UE. Dekapsulacija ključa datoteke iz njiju sproži Algoritem 2.B, iterirano funkcijo za izpeljavo ključa (KDF), ki veriži kroge SHA-256, SHA-384 in SHA-512 — izvede se jih vsaj 64, s pravilom zaustavitve, odvisnim od podatkov —, in je načrtno počasna, da ugibanje gesel ostane drago. To ceno plačamo enkrat, ko pisec ustvari datoteko, in enkrat, ko jo bralnik odpre, vsakič gre za enomestno število milisekund. Pri datoteki s pol milijona objekti KDF predstavlja zgolj šum, in če je shranjevanje počasno, osumljenec ni Algoritem 2.B, temveč zanka, ki se izvaja za vsak objekt
Ponovna uporaba ročice za ključ, ponovna uporaba delovnega medpomnilnika
Naivna implementacija je urejena pomožna funkcija: pomočnik EncryptAes256Cbc, ki odpre Windows CNG providerja, izbere CBC, ustvari objekt ključa, šifrira en medpomnilnik in nato vse skupaj poruši. Je pravilna, se jo da testirati s testi enot in je katastrofalna znotraj zanke s 500.000 iteracijami. Microsoftova dokumentacija označuje BCryptOpenAlgorithmProvider kot drago operacijo in priporoča predpomnjenje ročice, medtem ko BCryptGenerateSymmetricKey izvede celoten razpored AES ključa in dodeli stanje providerja — čista potrata, glede na to, da se ključ skozi celoten dokument nikoli ne spremeni
Delphijev RTL ne vsebuje enote za uvoz bcrypt-a, zato vstopne točke deklarirajte neposredno. Spodnji razred zgradi vse kriptografsko stanje samo enkrat in nato šifrira poljubno število objektov brez alokacije v stabilnem stanju (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;
Trije podrobnosti nosijo glavno težo. Poizvedba o velikosti — prvi klic BCryptEncrypt z izhodnim medpomnilnikom nil — vrne dolžino zapolnjenega šifriranega besedila (padded ciphertext), ki nikoli ni enaka vhodni dolžini; zapolnjevanje je deterministično, zato lahko sami izračunate ((Len div 16) + 1) * 16 in s tem prepolovite število klicev, vendar je poizvedba tista, ki predstavlja dokumentirano pogodbo. Drugič, BCryptEncrypt med veriženjem neposredno premika medpomnilnik IV, zato gre v vsak klic delovna kopija, v izhodu pa pristane nedotaknjen (pristine) IV. Tretjič, FScratch samo raste, in sicer do največjega objekta v datoteki, po tem pa zanka ne alocira več ničesar
Koliko je vredna ponovna uporaba ročice v praksi (meritve)
Datoteka, ki nas je prisilila v to vajo, je bil 1,8 GB velik skeniran kreditni arhiv: 412.000 šifriranih objektov, ki so po odštetju strukture golega besedila nosili 1.710 MB koristnega tovora. Isti računalnik, ista datoteka, hramba NVMe, ena nit:
- Nastavitev na klic (provider odprt in ključ generiran znotraj pomočnika): faza šifriranja 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Izločeno stanje (zgornji razred): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
Razlika znaša 61,7 s na 412.000 klicev ali približno 150 µs na klic, porabljenih za odpiranje providerja, nastavljanje načina veriženja in ponovno gradnjo urnika ključev za ključ, ki se ni nikoli spremenil. Nič od tega ni bila kriptografija. Z uporabo inštrukcij AES-NI poteka CBC šifriranje velikih medpomnilnikov s hitrostjo blizu 1,4 GB/s na enem jedru, zato k 9,6 s sama aritmetika AES prispeva približno 1,2 s; večino preostalega časa predstavljata dva prehoda BCryptEncrypt v uporabniškem načinu (user-mode) na posamezen objekt ter generiranje IV za vsak objekt posebej. Združevanje IV-jev v pakete (batching) — en klic BCryptGenRandom, ki jih napolni 4.096 naenkrat — je zmanjšalo čas izvajanja na 8,9 s. Prek te meje trčite ob minimalen čas API-ja na objekt in preostali vzvod je paralelizem: objekti /V 5 so pri skupnem ključu datoteke neodvisni, zato so štiri delovne niti, vsaka s svojim objektom ključa, skrajšale fazo na 3,1 s, preden je pisec izhoda (output writer) postal ozko grlo serializacije
Celovito ponovno zapisovanje v primerjavi s postopnim shranjevanjem (incremental save)
Razdrobljenost odloča tudi o stroških shranjevanja. Dodajanje šifriranja v obstoječ dokument z golim besedilom po definiciji na novo zapiše vsak objekt: vsak tok in niz spremeni tako vsebino kot dolžino, vsak odmik (offset) navzkrižne reference se premakne in pot postopnega dopolnjevanja ne obstaja. Predvidite to kot celovito zaporedno ponovno zapisovanje in zapišite v začasno datoteko, ki se nato preimenuje v ciljno, saj bi zrušitev med šifriranjem v nasprotnem primeru pustila napol šifrirano datoteko, ki je ne bo odprlo nobeno geslo
Obratna smer je poceni. Ko je datoteka enkrat šifrirana, postopna posodobitev doda nove objekte, šifrirane z istim ključem datoteke, in pusti vsak izvorni bajt nedotaknjen. Odtiskovanje opombe odobritve na 2 GB velik šifriran arhiv stane le nekaj kilobajtov dodanega izhoda, ne pa ponovnega pisanja 2 GB. Posledica za cevovod: šifrirajte enkrat, kot zadnji korak opravila, poznejši posegi pa naj se izvajajo prek postopnega shranjevanja. Rotacija gesel, ki hkrati zamenja tudi ključ datoteke, pa spet zahteva celovito ponovno pisanje — načrtujte jo ustrezno
Merjenje pretočnosti brez samoprevare
Trditve o pretočnosti pri šifriranju so pogosto napačne v števcu, imenovalcu ali obojem. Števec bi morali biti bajti koristnega tovora: vsota dolžin tokov in nizov, ki so dejansko potisnjeni skozi AES po stiskanju, kar lahko pisec sproti sešteva. Velikost datoteke vrednost precenjuje — zgoraj omenjeni arhiv na disku zaseda 1,8 GB, vendar le 1.710 MB pride v stik s šifro. Imenovalec bi morala biti samo faza šifriranja, obdana s TStopwatch iz System.Diagnostics, medtem ko razčlenjevanje (parsing), stiskanje (deflate) ter branje/pisanje na disk ostanejo zunaj teh oklepajev. Če jih vključite, bo ista koda za šifriranje namerila večkrat počasnejše rezultate na datoteki, ki se preprosto le slabše stisne. Zgornje številke so primerljive prav zato, ker obe strani deljenja vsebujeta samo postopek šifriranja
Nič od tega ne rabi biti vaša lastna koda. HotPDF ovije enak inženiring v lastnosti komponente — ActivateProtection, CryptKeyLength, UseAES256R6 — na ravni, ki je primerna za interaktivne VCL aplikacije, s pastmi vrstnega reda dodeljevanja, ki so pokrite v članku o HotPDF AES-256. Za avtomatizirane (unattended) cevovode PDFlibPas v enem samem klicu EncryptFile z močjo (Strength) 4 aplicira AES-256 revizijo 6 na obstoječe datoteke in na koncu preveri, kaj je pristalo na disku; ta delovni postopek je podrobno razložen v članku o presoji šifriranja s PDFlibPas
Poti šifriranja, opisane tukaj, so del komponente HotPDF Component za Delphi in C++Builder ter knjižnice PDFlibPas library; obe strani izdelkov vsebujeta celotno referenco šifriranja