Odborný článok

Vysokorýchlostné šifrovanie AES-256 v PDF pre masívne dokumenty

Šifrovanie 2 GB PDF dokumentu znie ako problém pre prúdy (streaming): otvorte súbor, pretlačte dva gigabajty cez AES-256, zapíšte výsledok. Tento mentálny model je však nesprávny takým spôsobom, ktorý rozhoduje o celom rozpočte na výkon. Norma ISO 32000-1 §7.6 stanovuje granularitu šifrovania PDF na úrovni jednotlivého objektu — každý jeden prúd (stream) a každý jeden reťazec (string) sa šifruje oddelene, každý so svojím vlastným inicializačným vektorom a vlastným zarovnaním (padding). 2 GB naskenovaný archív s 500 000 objektmi predstavuje 500 000 malých CBC operácií, nie jeden dlhý prechod, a pri takejto mierke záleží na fixných nákladoch spojených s každou operáciou viac než na samotnej aritmetike AES v jej vnútri

Tento článok je presne o týchto fixných nákladoch: kam odchádza čas, keď kód v Delphi aplikuje AES-256 na veľmi veľké dokumenty, a ako ho získať späť. Pre informácie týkajúce sa nastavenia — heslá, príznaky pre oprávnenia, otázky kompatibility revízie 5 voči 6 — si pozrite sprievodný článok o konfigurácii šifrovania AES-256 v HotPDF; nič z toho sa tu neopakuje

Pol milióna CBC operácií, nie jeden prechod

Kostra súboru zostáva v čistom texte (plaintext). Tabuľky krížových odkazov, čísla objektov, kľúče slovníkov, strom stránok: nič z toho nie je šifrované, vďaka čomu dokáže čítačka lokalizovať objekty ešte predtým, než validuje heslo. To, čo štandard šifruje, je obsah — dáta prúdov ako popisy stránok, obrázky, písma a prílohy, plus reťazce ako hodnoty metadát a texty anotácií. Pod kryptografickým filtrom AES-256 sa každý jeden z nich spracováva úplne samostatne: čerstvý náhodný 16-bajtový IV (inicializačný vektor), CBC nad bajtmi, zarovnanie bloku (padding) na 16-bajtovú hranicu a následne zapísanie IV v čistom texte pred šifrovaný text

Z toho vyplývajú dva dôsledky. Po prvé, šifrovaný text je vždy dlhší ako čistý text: IV pridáva 16 bajtov a zarovnanie pridáva ďalších 1 až 16, takže 100-bajtový reťazec zaberá na disku 128 bajtov a aj prázdny prúd stále produkuje 32 bajtov. Kód, ktorý dimenzuje výstupný buffer podľa dĺžky vstupu, alebo zapisuje späť len toľko bajtov, koľko prečítal, produkuje súbory, ktorých dešifrovanie zlyhá pri poslednom bloku každého jedného objektu. Po druhé, náklady sledujú počet objektov, nielen počet bajtov. Naskenovaný archív sústreďuje svoje bajty v niekoľkých veľkých obrázkových prúdoch, no zároveň nesie státisíce krátkych prúdov a malých reťazcov, kde na účte figuruje réžia na operáciu (per-operation overhead), nie AES

Jediným milosrdenstvom v dizajne AES-256 je spracovanie kľúčov. Bezpečnostné rutiny (security handlers) až do revízie 4 odvodzovali pre každý objekt odlišný kľúč tak, že hašovali kľúč súboru spolu s číslom objektu a číslom generácie, čo zakaždým vynútilo novú prípravu kľúča. Schémy /V 5 toto odvodzovanie na úrovni objektov zrušili: jeden náhodný 256-bitový kľúč súboru zašifruje každý objekt v dokumente. Tento fakt umožňuje každú z nižšie uvedených optimalizácií — drahý kryptografický stav je možné vybudovať len raz pre celý súbor, nie pre každý objekt zvlášť

Slovník /Encrypt v R6: jedno pomalé otvorenie, lacné objekty

Dokument v revízii 6 deklaruje svoju schému v slovníku /Encrypt v päte a dôležité záznamy sa zmestia do niekoľkých riadkov:

/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

Hodnota /V 5 vyberá 256-bitovú architektúru kľúčov a /R 6 tvrdený proces podľa normy ISO 32000-2 (hardened handshake). Položka /CF definuje pomenovaný kryptografický filter — /AESV3 znamená AES-256 v režime CBC s predradeným IV — a /StmF a /StrF priraďujú tento filter pre prúdy a reťazce. Záznamy /O, /U, /OE a /UE obsahujú overenie hesla a materiál na zabalenie kľúča (key-wrapping), a /Perms nesie pomocou AES šifrovanú kópiu bitov s oprávneniami, aby nepriateľský editor nemohol len tak potichu prehodiť hodnotu /P

Štruktúra nákladov sa ukrýva v /OE a /UE. Rozbaľovanie (unwrapping) kľúča súboru z nich spúšťa Algoritmus 2.B, funkciu na iterované odvodenie kľúča, ktorá spája kolá SHA-256, SHA-384 a SHA-512 — a to najmenej 64 z nich, so zastavovacím pravidlom závislým od dát — postavenú úmyselne tak pomaly, aby hádanie hesla zostalo pre útočníka nákladné. Táto cena sa platí len raz, keď zapisovač produkuje súbor, a raz, keď ho čítačka otvára, pričom každá táto operácia trvá len jednociferné milisekundy. Pri súbore s pol miliónom objektov je KDF len akýmsi malým šumom a ak je ukladanie pomalé, Algoritmus 2.B nie je hlavným podozrivým; tým je samotný cyklus pre jednotlivé objekty (per-object loop)

Znovupoužitie handle kľúča, znovupoužitie pracovného buffera

Naivná implementácia zvyčajne predstavuje úhľadnú pomocnú funkciu: pomocník EncryptAes256Cbc, ktorý otvorí poskytovateľa Windows CNG, vyberie CBC, vygeneruje objekt kľúča, zašifruje jeden buffer a potom všetko opäť zničí a zruší. Z hľadiska fungovania je to správne, otestovateľné pomocou jednotkových testov a úplne katastrofálne vo vnútri slučky s 500 000 iteráciami. Dokumentácia od spoločnosti Microsoft označuje volanie BCryptOpenAlgorithmProvider ako veľmi nákladné a odporúča cachovanie jeho handle, a navyše BCryptGenerateSymmetricKey spúšťa plný rozvrh kľúčov AES a alokuje stav poskytovateľa — čo je len čisté plytvanie v situácii, kedy sa kľúč v celom dokumente vôbec nikdy nemení

Delphi RTL neobsahuje žiadnu importovaciu jednotku pre bcrypt, takže vstupné body (entry points) deklarujte priamo. Nižšie uvedená trieda buduje celý kryptografický stav len raz a následne dokáže zašifrovať ľubovoľný počet objektov bez akejkoľvek alokácie v stabilnom stave (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;

Zásadný význam majú tri detaily. Dotaz na veľkosť (size query) — prvé volanie BCryptEncrypt s výstupným bufferom nastaveným na nil — vráti dĺžku šifrovaného textu aj so zarovnaním (padding), čo sa nikdy nerovná dĺžke vstupu; zarovnávanie je deterministické, takže si síce môžete aj sami vypočítať hodnotu pre ((Len div 16) + 1) * 16 a znížiť tak počet volaní na polovicu, ale samotný dotaz na veľkosť predstavuje dokumentovanú dohodu. Po druhé, BCryptEncrypt počas reťazenia (chaining) modifikuje buffer s IV na mieste, takže do každého volania musí ísť pracovná kópia a pôvodný nedotknutý IV končí bezpečne vo výstupe. A do tretice, buffer FScratch sa už len zväčšuje, a to maximálne až do hodnoty veľkosti najväčšieho objektu prítomného v súbore, po čom daná slučka vlastne už nealokuje vôbec nič

Akú má opätovné použitie handle hodnotu, v číslach (measured)

Súbor, ktorý si vôbec vynútil celé toto cvičenie a postup, bol 1,8 GB naskenovaný úverový archív: 412 000 zašifrovaných objektov s 1 710 MB úžitkového zaťaženia (payload) po odčítaní samotnej textovej (plaintext) štruktúry. Rovnaký stroj, rovnaký súbor, úložisko NVMe, jedno vlákno:

  • Nastavenie per-call (na volanie) (otvorenie poskytovateľa a generovanie kľúča priamo vnútri pomocníka): fáza šifrovania 71,3 s — 1 710 MB ÷ 71,3 s ≈ 24 MB/s
  • Naviazaný stav (state hoisted) (trieda uvedená vyššie): 9,6 s — 1 710 MB ÷ 9,6 s ≈ 178 MB/s

Rozdiel je 61,7 s naprieč 412 000 volaniami, čo predstavuje zhruba 150 µs na jedno volanie strávené pri otváraní poskytovateľa, nastavovaní režimu zreťazenia (chaining mode) a následnom prebudovávaní rozvrhu kľúčov pre jeden a ten istý kľúč, ktorý sa vlastne ani raz nezmenil. Nič z toho pritom nebola samotná kryptografia. Vďaka AES-NI beží CBC šifrovanie veľkých bufferov na jednom jadre rýchlosťou zhruba 1,4 GB/s, takže samotná aritmetika AES predstavuje len asi 1,2 s z celkových 9,6; väčšinu zvyšného času tvoria dva prechody cez používateľský režim v rámci BCryptEncrypt pre každý objekt plus samostatné generovanie IV na objekt. Dávkovanie vektorov IV — jediné volanie BCryptGenRandom pre naplnenie 4 096 z nich — skrátilo tento beh na 8,9 s. Za týmto bodom ste už vlastne dosiahli úplné API dno v rámci prepočtov pre jednotlivé objekty, a tak jedinou zostávajúcou pákou je už iba paralelizmus: objekty pre /V 5 sú nezávislé pod vplyvom zdieľaného kľúča súboru, takže štyri pracovné vlákna, z ktorých má každé jeden svoj vlastný objekt s kľúčom, skrátili túto fázu na 3,1 s ešte predtým, ako sa zapisovač pre výstup stal samotným bodom serializácie

Úplný prepis versus inkrementálne ukladanie

Granularita takisto rozhoduje o tom, koľko stojí samotné ukladanie. Pridanie šifrovania do existujúceho dokumentu s čistým textom z princípu prepíše úplne každý objekt: každý jeden prúd a každý jeden reťazec mení aj svoj obsah a aj svoju dĺžku, mení sa každý posun v krížových odkazoch a neexistuje absolútne žiadna inkrementálna cesta. Naplánujte si to teda priamo vo forme úplného sekvenčného prepisu a zapisujte do dočasného súboru, ktorý sa až následne premenuje a prepíše cez cieľ, pretože inak zanechá akýkoľvek pád uprostred šifrovania na disku len napoly zašifrovaný súbor, ktorý neotvorí žiadne heslo

Opačný smer je už ten lacný. Keď je raz súbor zašifrovaný, inkrementálna aktualizácia k nemu už len pripája nové objekty šifrované rovnakým kľúčom súboru a pritom ponecháva každý jeden pôvodný bajt úplne nedotknutý. Vyrazenie schvaľovacej anotácie do 2 GB zašifrovaného archívu tak stojí skutočne len niekoľko kilobajtov pripojeného výstupu, a určite to neznamená žiadny opätovný 2 GB prepis. Dôsledok pre vyhodnocovací kanál: šifrujte len raz, zvyčajne ako úplne posledný krok v celom procese, a všetky nasledujúce dotyky a zásahy už prenechajte prístupom cez inkrementálne ukladanie. Zmena a rotácia hesla, ktorá otáča aj kľúč súboru, naopak opäť znamená plný a ucelený prepis — a tak ho podľa toho aj plánujte a naplánujte

Meranie priepustnosti bez toho, aby ste klamali sami seba

Tvrdenia o priepustnosti šifrovania bývajú veľmi často nesprávne, a to buď už v čitateli, menovateli, alebo v oboch naraz. Čitateľ by mal predstavovať úžitkové bajty: súčet dĺžok prúdov a reťazcov, ktoré boli po kompresii reálne pretlačené a posunuté cez AES, čo si dokáže zapisovač v chode bez problémov spočítať. Zverejňovanie len samotnej veľkosti súboru túto hodnotu pomerne prestreľuje a nadhodnocuje — spomínaný archív vyššie má síce na disku veľkosť 1,8 GB, no do styku so šifrou sa reálne dostane len 1 710 MB. Menovateľ by mala tvoriť už iba samotná fáza pre šifrovanie ohraničená pomocou TStopwatch zo System.Diagnostics, zatiaľ čo parsovanie, funkcia deflate, ako aj vstupy a výstupy z disku by mali zostať už mimo tohto ohraničenia zátvoriek. Pokiaľ to celé zahrniete naraz do jedného balíka, identický kód pre šifrovanie dokáže namerať niekoľkokrát pomalšie výsledky len a len pre súbor, ktorý sa o niečo horšie komprimuje. Uvedené a predkladané čísla v článku sú porovnateľné presne a jedine preto, že obe strany z tohto delenia figurujú v statuse pre nasadenie len na samotné šifrovanie

Nič z toho ale pritom nemusí byť nevyhnutne váš vlastný kód. Riešenie HotPDF plne zabaľuje to isté technické inžinierstvo za vlastnosti svojho komponentu — ActivateProtection, CryptKeyLength, UseAES256R6 — vo výbornej kondícii a pozícii pre interaktívne VCL aplikácie, pričom záludnosti týkajúce sa priradení pokrýva priamo článok HotPDF venovaný práve úlohám pri AES-256. Pre bezobslužné linky uplatňuje a spúšťa PDFlibPas revíziu 6 pre AES-256 na už existujúce súbory jediným volaním EncryptFile s intenzitou nastavenou na 4 a následne si len a len overí, čo presne pristálo a uložilo sa na disk, čo je pracovný postup ktorým vás bližšie a do detailu prevedie článok o audite šifrovania za pomoci PDFlibPas

Tu popísané šifrovacie cesty sa dodávajú priamo ako súčasť komponentu HotPDF pre Delphi a C++Builder a knižnice PDFlibPas; produktové stránky pre obe z nich naďalej disponujú a poskytujú plnú a ucelenú referenčnú príručku k úlohám na šifrovanie