Tehnički članak

Brza AES-256 PDF enkripcija za masivne dokumente

Šifrovanje PDF-a od 2 GB zvuči kao problem protoka (streaming-a): otvorite datoteku, progurate dva gigabajta kroz AES-256, zapišete rezultat. Taj mentalni model je pogrešan na način koji odlučuje o celokupnom budžetu performansi. ISO 32000-1 §7.6 postavlja granularnost PDF enkripcije na nivou pojedinačnog objekta — svaki tok (stream) i svaki string (niz) se šifruju odvojeno, svaki sa sopstvenim inicijalizacionim vektorom i sopstvenim popunjavanjem (padding). Skenirana arhiva od 2 GB sa 500.000 objekata predstavlja 500.000 malih CBC operacija, a ne jedan dug prolaz, i na toj skali fiksni trošak oko svake operacije znači više nego sama AES aritmetika unutar nje

Ovaj članak bavi se tim fiksnim troškom: na šta odlazi vreme kada Delphi kod primenjuje AES-256 na veoma velike dokumente i kako to vreme vratiti. Što se tiče podešavanja — lozinki, zastavica dozvola, poziva kompatibilnosti revizije 5 u odnosu na 6 — pogledajte prateći tekst o konfigurisanju AES-256 enkripcije u HotPDF-u; ništa od toga nije ponovljeno ovde

Pola miliona CBC operacija, ne jedan prolaz

Skelet datoteke ostaje u otvorenom tekstu (plaintext). Tabele unakrsnih referenci, brojevi objekata, ključevi rečnika, stablo stranica: ništa od toga nije šifrovano, što je način na koji čitač može da locira objekte pre nego što je validirao lozinku. Ono što standard šifruje je sadržaj — podaci toka kao što su opisi stranica, slike, fontovi i prilozi, plus stringovi kao što su vrednosti metapodataka i tekst anotacija. Pod AES-256 kripto filterom, svaki od njih se obrađuje zasebno: svež nasumičan 16-bajtni IV, CBC preko bajtova, popunjavanje blokova (block padding) do granice od 16 bajtova, i IV upisan otvoreno ispred šifrovanog teksta (ciphertext)

Slede dve posledice. Prvo, šifrovani tekst je uvek duži od otvorenog teksta: IV dodaje 16 bajtova, a popunjavanje dodaje još od 1 do 16, pa tako string od 100 bajtova zauzima 128 bajtova na disku, dok prazan tok i dalje proizvodi 32 bajta. Kod koji dimenzioniše izlazni bafer na dužinu ulaza, ili vraća samo onoliko bajtova koliko je pročitao, proizvodi datoteke koje ne uspevaju da se dešifruju u poslednjem bloku svakog objekta. Drugo, trošak prati broj objekata, a ne samo broj bajtova. Skenirana arhiva koncentriše svoje bajtove u nekoliko velikih tokova slika, ali nosi stotine hiljada kratkih tokova i malih stringova kod kojih najveći ceh plaća dodatno opterećenje po operaciji, a ne AES

Jedina milost u dizajnu AES-256 enkripcije je rukovanje ključem. Sigurnosni rukovaoci (security handlers) do revizije 4 su izvodili poseban ključ za svaki objekat tako što su hash-ovali ključ datoteke zajedno sa brojevima objekta i generacije, primoravajući novi raspored ključa svaki put. /V 5 šeme su napustile izvođenje ključa po objektu: jedan nasumičan 256-bitni ključ datoteke šifruje svaki objekat u dokumentu. Ova činjenica dozvoljava svaku optimizaciju ispod — skupo kriptografsko stanje se može izgraditi jednom po datoteci, a ne jednom po objektu

R6 /Encrypt rečnik: jedno sporo otvaranje, jeftini objekti

Dokument revizije 6 deklariše svoju šemu u /Encrypt rečniku trejlera, a stavke koje su bitne stanu u nekoliko redova:

/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 bira 256-bitnu arhitekturu ključa, a /R 6 ojačano rukovanje preuzeto iz ISO 32000-2. /CF definiše imenovani kripto filter — /AESV3 znači AES-256 u CBC režimu sa predupisanim IV-om — dok /StmF i /StrF dodeljuju taj filter redom tokovima i stringovima. /O, /U, /OE i /UE drže materijal za verifikaciju lozinke i uvijanje ključa (key-wrapping), dok /Perms nosi AES-šifrovanu kopiju bitova dozvole, kako bi se sprečilo da neprijateljski urednik nečujno promeni /P

Struktura troškova se krije u /OE i /UE. Odvijanje ključa datoteke iz njih pokreće Algoritam 2.B, iteriranu funkciju izvođenja ključa koja ulančava runde SHA-256, SHA-384 i SHA-512 algoritama — najmanje 64 njih, sa pravilom zaustavljanja zavisnim od podataka — namerno napravljeno sporo tako da pogađanje lozinke ostane skupo. Ta cena se plaća jednom kada pisač napravi datoteku i jednom kada je čitač otvori, a radi se o vrednostima reda jednocifrenog broja milisekundi za svaku. Kod datoteke sa pola miliona objekata, KDF predstavlja običan šum, i ako je čuvanje sporo, Algoritam 2.B nije osumnjičeni; per-objekt petlja jeste

Ponovna upotreba handle-a ključa, ponovna upotreba scratch bafera

Naivna implementacija je uredna uslužna funkcija: pomoćnik EncryptAes256Cbc koji otvara Windows CNG provajder, bira CBC, generiše objekat ključa, šifruje jedan bafer i zatim sve sruši. Tačno, moguće testirati jedinice koda (unit-testable), ali katastrofalno unutar petlje od 500.000 iteracija. Microsoft-ova dokumentacija označava BCryptOpenAlgorithmProvider kao skup i preporučuje keširanje handle-a, dok BCryptGenerateSymmetricKey pokreće potpuni raspored AES ključa i dodeljuje stanje provajdera — čisto bacanje resursa kada se ključ nikada ne menja kroz čitav dokument

Delphi RTL ne isporučuje bcrypt jedinicu za uvoz, stoga direktno deklarišite ulazne tačke. Klasa ispod jednom gradi sve kriptografsko stanje i zatim šifruje bilo koji broj objekata bez stacionarnog dodeljivanja (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;

Tri detalja nose težinu opterećenja. Upit o veličini — prvi poziv BCryptEncrypt, sa nil izlaznim baferom — vraća dužinu popunjenog šifrovanog teksta, koja nikada nije jednaka ulaznoj dužini; popunjavanje je determinističko, tako da možete sami da izračunate ((Len div 16) + 1) * 16 i prepolovite broj poziva, ali upit je dokumentovan ugovor. Drugo, BCryptEncrypt na licu mesta unapređuje IV bafer kako ulančava operacije, tako da radna kopija ide u svaki poziv a netaknuti IV stiže na izlaz. Treće, FScratch samo raste, i to do veličine najvećeg objekta u datoteci, nakon čega petlja ne dodeljuje ništa

Koliko zaista vredi ponovna upotreba handle-a, izmereno

Datoteka koja je iznudila ovu vežbu bila je skenirana arhiva kredita od 1.8 GB: 412.000 šifrovanih objekata koji su nosili 1.710 MB korisnog tereta (payload) kada se struktura u otvorenom tekstu (plaintext) oduzme. Ista mašina, ista datoteka, NVMe skladište, jedan thread:

  • Podešavanje po pozivu (per-call setup) (provajder otvoren i ključ generisan unutar pomoćnika): faza šifrovanja 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
  • Uzdignuto stanje (state hoisted) (klasa iznad): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s

Razlika je 61,7 s na 412.000 poziva, ili grubo 150 µs po pozivu potrošeno na otvaranje provajdera, postavljanje režima ulančavanja i ponovno građenje rasporeda ključa za ključ koji se nikada nije promenio. Ništa od toga nije bila kriptografija. Sa AES-NI, CBC enkripcija velikih bafera se kreće blizu 1.4 GB/s na jednom jezgru, tako da sama AES aritmetika objašnjava oko 1,2 s od ukupnih 9,6 s; većina onog što preostaje su dva usermode BCryptEncrypt prelaza po objektu, plus generisanje IV-a za svaki objekat. Grupisanje IV-ova u paket — gde jedan poziv BCryptGenRandom popunjava njih 4.096 — skratilo je trku na 8,9 s. Pored toga nalazite se na podu API-ja za rad po objektu, i preostala poluga je paralelizam: /V 5 objekti su nezavisni pod podeljenim ključem datoteke, tako da su četiri radna thread-a sa po jednim objektom ključa smanjili tu fazu na 3,1 s, pre nego što je izlazni pisač (output writer) postao tačka serijalizacije

Potpuno prepisivanje naspram inkrementalnog čuvanja

Granularnost takođe odlučuje koliko košta čuvanje. Dodavanje enkripcije postojećem dokumentu u otvorenom tekstu (plaintext) po definiciji prepisuje svaki objekat: svaki tok i string menjaju i sadržaj i dužinu, svaki ofset unakrsne reference se pomera, i ne postoji inkrementalna putanja. Planirajte to u budžetu kao potpuno sekvencijalno prepisivanje (full sequential rewrite), i pišite u privremenu datoteku koja se zatim preimenuje preko ciljne datoteke, jer pad sistema usred šifrovanja ostavlja napola šifrovanu datoteku koju nijedna lozinka neće otvoriti

Obrnuti pravac je jeftin. Kada se datoteka jednom šifruje, inkrementalno ažuriranje dodaje nove objekte šifrovane istim ključem datoteke i ostavlja svaki originalni bajt netaknutim. Stavljanje anotacije o odobrenju (approval annotation) na 2 GB veliku šifrovanu arhivu košta u kilobajtima dodatih izlaza, a ne u prepisivanju 2 GB iz početka. Posledica za cevovod glasi: šifrujte jednom, kao poslednji korak na poslu, i pustite da se svi sledeći dodiri sprovode kroz inkrementalna čuvanja. Rotacija lozinke koja istovremeno rotira ključ datoteke je opet potpuno prepisivanje — isplanirajte je tako

Merenje protoka bez zavaravanja samog sebe

Tvrdnje o protoku (throughput) enkripcije često su pogrešne u brojiocu, imeniocu, ili oboje. Brojilac (numerator) treba da bude u korisnim bajtovima (payload bytes): suma dužina tokova i stringova zapravo proguranih kroz AES, posle kompresije, koju pisač može sabirati usput. Veličina datoteke ovo preuveličava — gornja arhiva je 1.8 GB na disku, ali svega 1.710 MB ikada dotakne šifarnik (cipher). Imenilac bi trebalo da bude isključivo faza enkripcije, uokvirena pomoću TStopwatch iz System.Diagnostics, dok je parsiranje, kompresovanje i I/O diska ostavljeno van tih okvira. Ugurajte i to, i identičan kod za šifrovanje biće izmeren nekoliko puta sporije na datoteci koja se prosto slabije kompresuje. Cifre iznad su uporedive upravo zato što su obe strane delenja orijentisane isključivo na šifrovanje

Ništa od ovog koda ne mora biti u vašem vlasništvu. HotPDF uokviruje ovaj isti inženjering iza osobina komponenti — ActivateProtection, CryptKeyLength, UseAES256R6 — na pravoj visini za interaktivne VCL aplikacije, pri čemu su zamke redosleda dodeljivanja pokrivene u HotPDF AES-256 članku. Za cevovode bez nadzora, PDFlibPas primenjuje AES-256 revizije 6 na postojeće datoteke unutar jednog poziva EncryptFile na Snazi 4 i nakon toga verifikuje šta je završilo na disku, što je radni tok opisan u članku o proveri (audit) enkripcije sa PDFlibPas-om

Opisani putevi enkripcije se isporučuju u sklopu komponente HotPDF Component za Delphi i C++Builder, i u PDFlibPas biblioteci; obe stranice o proizvodima nose kompletnu referencu za enkripciju