Tehnički članak

Brzo AES-256 PDF kriptiranje za masivne dokumente

Kriptiranje PDF-a od 2 GB zvuči kao problem strujanja: otvorite datoteku, gurnite dva gigabajta kroz AES-256, zapišite rezultat. Taj mentalni model je pogrešan na način koji odlučuje o cjelokupnom proračunu performansi. ISO 32000-1 §7.6 postavlja granularnost PDF kriptiranja na pojedinačni objekt — svaki tok i svaki niz znakova kriptira se zasebno, svaki s vlastitim inicijalizacijskim vektorom i vlastitim dodavanjem. Skenirana arhiva od 2 GB s 500.000 objekata zapravo je 500.000 malih CBC operacija, a ne jedan dugi prolaz, i pri takvom razmjeru fiksni trošak oko svake operacije znači više od same AES aritmetike unutar nje

Ovaj članak govori o tom fiksnom trošku: gdje odlazi vrijeme kada Delphi kod primjenjuje AES-256 na vrlo velike dokumente i kako to vrijeme vratiti. Za stranu podešavanja — lozinke, oznake dopuštenja, poziv na kompatibilnost revizije 5 u odnosu na 6 — pogledajte popratni dio o konfiguriranju AES-256 kriptiranja u HotPDF-u; ništa od toga se ovdje ne ponavlja

Pola milijuna CBC operacija, a ne jedan prolaz

Kostur datoteke ostaje u čistom tekstu. Tablice unakrsnih referenci, brojevi objekata, ključevi rječnika, stablo stranica: ništa od toga nije kriptirano, zbog čega čitač može locirati objekte prije nego što potvrdi valjanost lozinke. Ono što standard kriptira jest sadržaj — podaci tokova kao što su opisi stranica, slike, fontovi i privitci, plus nizovi znakova kao što su vrijednosti metapodataka i tekst bilješki. Pod kriptografskim filtrom AES-256 svaki se obrađuje zasebno: svježi nasumični 16-bajtni IV, CBC preko bajtova, dodavanje bloka do granice od 16 bajtova te jasno zapisan IV ispred šifriranog teksta

Slijede dvije posljedice. Prvo, šifrirani tekst je uvijek duži od čistog teksta: IV dodaje 16 bajtova, a padding dodaje još 1 do 16, pa niz znakova od 100 bajtova zauzima 128 bajtova na disku, a prazan tok još uvijek proizvodi 32. Kod koji dimenzionira izlazni međuspremnik na ulaznu duljinu, ili zapisuje natrag samo onoliko bajtova koliko je pročitao, proizvodi datoteke koje ne uspijevaju dešifrirati zadnji blok svakog objekta. Drugo, trošak prati broj objekata, a ne samo broj bajtova. Skenirana arhiva koncentrira svoje bajtove u nekoliko velikih tokova slika, ali nosi stotine tisuća kratkih tokova i malih nizova znakova gdje najveći dio računa stvara opterećenje po operaciji, a ne AES

Jedina milost u dizajnu AES-256 jest rukovanje ključevima. Sigurnosni rukovatelji do revizije 4 izvodili su poseban ključ za svaki objekt sažimajući ključ datoteke zajedno s brojevima objekta i generacije, tjerajući tako na svježi raspored ključeva svaki put. Sheme /V 5 odbacile su derivaciju po objektu: jedan nasumični 256-bitni ključ datoteke kriptira svaki objekt u dokumentu. Ta činjenica omogućuje svaku od navedenih optimizacija u nastavku — skupo kriptografsko stanje može se izgraditi jednom po datoteci, a ne jednom po objektu

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

Dokument revizije 6 deklarira svoju shemu u rječniku trailera /Encrypt, a bitni unosi stanu u nekoliko redaka:

/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 odabire arhitekturu 256-bitnog ključa, a /R 6 pojačano rukovanje prema ISO 32000-2. /CF definira imenovani kriptografski filtar — /AESV3 znači AES-256 u CBC načinu rada s umetnutim IV na početku — dok /StmF i /StrF dodjeljuju taj filtar tokovima odnosno nizovima znakova. /O, /U, /OE i /UE drže provjeru lozinke i materijal za omatanje ključa, a /Perms nosi AES-om kriptiranu kopiju bitova dopuštenja kako neprijateljski urednik ne bi mogao tiho okrenuti /P

Struktura troškova skriva se u /OE i /UE. Odmatanje 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 — njih najmanje 64, uz pravilo zaustavljanja ovisno o podacima — izgrađenu tako da bude namjerno spora kako bi pogađanje lozinke ostalo skupo. Ta cijena se plaća jednom kada pisač proizvede datoteku i jednom kada je čitač otvori, a svaki put to iznosi samo nekoliko milisekundi. Na datoteci s pola milijuna objekata KDF predstavlja tek šum, pa ako je spremanje sporo, Algoritam 2.B nije sumnjivac; petlja po objektu jest

Ponovna upotreba key handlea, ponovna upotreba scratch međuspremnika

Naivna implementacija je uredna pomoćna funkcija: pomoćnik EncryptAes256Cbc koji otvara Windows CNG provajder, odabire CBC, generira ključni objekt, kriptira jedan međuspremnik i sve to ruši. Ispravno, pogodno za jedinično testiranje i pogubno unutar petlje s 500.000 iteracija. Microsoftova dokumentacija označava BCryptOpenAlgorithmProvider kao skupa operacija i preporučuje predmemoriranje handlea, dok BCryptGenerateSymmetricKey pokreće cjelokupni raspored AES ključa i dodjeljuje stanje provajderu — čisti gubitak kada se ključ nikada ne mijenja kroz cijeli dokument

Delphi RTL ne isporučuje uvoznu jedinicu za bcrypt, stoga izravno deklarirajte ulazne točke. Klasa u nastavku jednom gradi sve kriptografsko stanje, a zatim kriptira bilo koji broj objekata bez stacionarne alokacije:

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 opterećenje. Upit o veličini — prvi poziv BCryptEncrypt, s nultim izlaznim međuspremnikom — vraća produljenu duljinu šifriranog teksta, koja nikada nije jednaka ulaznoj duljini; padding je deterministički, stoga možete sami izračunati ((Len div 16) + 1) * 16 i prepoloviti broj poziva, ali upit predstavlja dokumentirani ugovor. Drugo, BCryptEncrypt unapređuje IV međuspremnik na mjestu dok se ulančava, pa u svaki poziv ide radna kopija dok netaknuti IV slijeće u izlaz. Treće, FScratch samo raste, sve do najvećeg objekta u datoteci, nakon čega petlja ne alocira ništa

Koliko vrijedi ponovna upotreba handlea u mjerenjima

Datoteka koja je forsirala ovu vježbu bila je skenirana arhiva kredita od 1.8 GB: 412.000 kriptiranih objekata koji nose 1.710 MB korisnog opterećenja kada se oduzme struktura čistog teksta. Isti stroj, ista datoteka, NVMe pohrana, jedna dretva:

  • Postavljanje po pozivu (provajder se otvara i ključ generira unutar pomoćnika): faza kriptiranja 71.3 s — 1.710 MB ÷ 71.3 s ≈ 24 MB/s
  • Stanje podignuto (klasa iznad): 9.6 s — 1.710 MB ÷ 9.6 s ≈ 178 MB/s

Razlika iznosi 61.7 s u 412.000 poziva, ili otprilike 150 µs po pozivu utrošeno na otvaranje provajdera, postavljanje načina ulančavanja i obnovu rasporeda ključeva za ključ koji se nikada nije mijenjao. Ništa od toga nije bila kriptografija. S AES-NI, CBC kriptiranje velikih međuspremnika radi na oko 1.4 GB/s na jednoj jezgri, pa AES aritmetika sama po sebi čini oko 1.2 s od 9.6; većina preostalog dijela su dvije prilagodbe u korisničkom načinu BCryptEncrypt po objektu plus generiranje IV po objektu. Grupiranje IV-ova — jedan poziv BCryptGenRandom puni njih 4.096 — smanjilo je izvođenje na 8.9 s. Nakon toga nalazite se na minimumu po objektu za taj API, a jedina preostala poluga je paralelizam: /V 5 objekti neovisni su pod dijeljenim ključem datoteke, tako da su četiri radne dretve svaka s po jednim objektom ključa smanjile fazu na 3.1 s prije nego što je pisač na izlazu postao točka serijalizacije

Potpuno prepisivanje nasuprot inkrementalnom spremanju

Granularnost također odlučuje koliko košta spremanje. Dodavanje kriptiranja postojećem dokumentu s običnim tekstom prepisuje svaki objekt po definiciji: svaki tok i niz mijenja i sadržaj i duljinu, svaki pomak unakrsne reference se pomiče, i ne postoji inkrementalna staza. Budžetirajte to kao cjelovito sekvencijalno prepisivanje te zapisujte u privremenu datoteku koja se kasnije preimenuje preko ciljne, jer pad usred kriptiranja inače ostavlja polušifriranu datoteku koju nijedna lozinka neće otvoriti

Obrnuti smjer je jeftinija opcija. Nakon što je datoteka kriptirana, inkrementalno ažuriranje dodaje nove objekte kriptirane istim ključem datoteke na kraj i ostavlja svaki izvorni bajt netaknutim. Stavljanje pečata s bilješkom o odobrenju na kriptiranu arhivu od 2 GB košta kilobajte na izlazu, a ne prepisivanje cijelih 2 GB. Zaključak za cjevovod: kriptirajte jednom, kao posljednji korak posla, i neka se naknadne promjene provode pomoću inkrementalnih spremanja. Promjena lozinke koja istovremeno rotira i ključ datoteke ponovno znači potpuno prepisivanje — planirajte to prema tome

Mjerenje propusnosti bez samozavaravanja

Tvrdnje o propusnosti kriptiranja obično su pogrešne u brojniku, nazivniku ili oboje. Brojnik bi trebao predstavljati bajtove korisnog tereta: zbroj duljina tokova i nizova koji su stvarno progurani kroz AES nakon kompresije, a što pisač može zbrajati u hodu. Veličina datoteke to precjenjuje — gornja arhiva zauzima 1.8 GB na disku, ali se od toga na šifru odnosi samo 1.710 MB. Nazivnik bi trebao obuhvaćati isključivo fazu kriptiranja, označen pomoću TStopwatch iz System.Diagnostics, s parsiranjem, deflate kompresijom i ulazno/izlaznim operacijama diska ostavljenima izvan toga. Ako ih uračunate, identičan kod za kriptiranje izmjerit će se kao višestruko sporiji na datoteci koja se tek slabije komprimira. Gornje brojke su usporedive upravo zato što se obje strane dijeljenja odnose isključivo na kriptiranje

Ništa od ovoga ne mora biti vaš vlastiti kod. HotPDF obuhvaća isti inženjering unutar svojstava komponente — ActivateProtection, CryptKeyLength, UseAES256R6 — na ispravnoj visini za interaktivne VCL aplikacije, dok su zamke vezane za redoslijed dodjele obrađene u HotPDF AES-256 članku. Za nenadzirane cjevovode, PDFlibPas primjenjuje AES-256 revizije 6 na postojeće datoteke jednim pozivom EncryptFile pri snazi 4 i potom provjerava što se zapisalo na disk, tijek rada koji je detaljno objašnjen u članku o reviziji PDFlibPas kriptiranja

Ovdje opisane putanje kriptiranja isporučuju se u HotPDF komponenti za Delphi i C++Builder, kao i u PDFlibPas biblioteci; obje stranice proizvoda sadrže potpunu referencu za kriptiranje