Technický článek

Vysokorychlostní šifrování PDF masivních dokumentů pomocí AES-256

Šifrování 2GB PDF zní jako problém streamování: otevřít soubor, protlačit dva gigabajty přes AES-256, zapsat výsledek. Tento mentální model je chybný takovým způsobem, který rozhoduje o celém výkonnostním rozpočtu (performance budget). ISO 32000-1 §7.6 nastavuje granularitu šifrování PDF na jednotlivý objekt — každý datový proud (stream) a každý řetězec je šifrován samostatně, každý s vlastním inicializačním vektorem a vlastním zarovnáním (padding). Naskenovaný archiv o velikosti 2 GB s 500 000 objekty představuje 500 000 malých operací CBC, nikoli jeden dlouhý průchod, a v takovém měřítku záleží na fixních nákladech (fixed cost) kolem každé operace více než na samotné aritmetice AES uvnitř

Tento článek pojednává právě o těchto fixních nákladech: kam se ztrácí čas, když kód v Delphi aplikuje šifrování AES-256 na velmi velké dokumenty, a jak ho získat zpět. Co se týče stránky nastavení — hesel, příznaků oprávnění (permission flags) a volby kompatibility mezi revizemi 5 a 6 — viz doprovodný materiál o konfiguraci šifrování AES-256 v HotPDF; nic z toho se zde neopakuje

Půl milionu operací CBC, ne jeden průchod

Kostra souboru zůstává v prostém textu (plaintext). Tabulky křížových odkazů, čísla objektů, klíče slovníků, strom stránek: nic z toho se nešifruuje, což je způsob, jak může čtečka lokalizovat objekty dříve, než ověří heslo. To, co standard šifruje, je obsah — data proudů (stream data) jako popisy stránek, obrázky, písma a přílohy, plus řetězce, jako jsou hodnoty metadat a text anotací. Pod šifrovacím filtrem (crypt filter) AES-256 je každý z nich zpracováván samostatně: čerstvý náhodný 16bajtový inicializační vektor (IV), CBC napříč bajty, zarovnání bloku (block padding) na 16bajtovou hranici a IV zapsaný v čitelném formátu před šifrovým textem (ciphertext)

Z toho plynou dva důsledky. Zaprvé, šifrový text je vždy delší než prostý text: IV přidá 16 bajtů a padding přidá 1 až 16 dalších, takže 100bajtový řetězec zabírá na disku 128 bajtů a prázdný datový proud (stream) stále vyprodukuje 32 bajtů. Kód, který dimenzuje výstupní buffer podle délky vstupu nebo zapisuje zpět pouze tolik bajtů, kolik přečetl, vytváří soubory, u nichž se nepodaří dešifrovat poslední blok každého objektu. Zadruhé, náklady (cost) sledují počet objektů, nejen počet bajtů. Naskenovaný archiv soustřeďuje své bajty do několika málo velkých obrazových proudů, ale nese statisíce krátkých proudů a malých řetězců, kde je oním poplatkem, který je nutné zaplatit (bill), režie na každou operaci (per-operation overhead), nikoli AES

Jediným milosrdenstvím v návrhu AES-256 je správa klíčů (key handling). Obslužné rutiny zabezpečení (security handlers) až do revize 4 odvozovaly pro každý objekt samostatný klíč pomocí hašování klíče souboru (file key) společně s čísly objektu a generace, což si pokaždé vynutilo nový rozvrh klíčů (key schedule). Schémata /V 5 upustila od odvozování pro každý objekt: jeden náhodný 256bitový klíč souboru zašifruje každý objekt v dokumentu. Tento fakt otevírá dveře (licenses) každé níže uvedené optimalizaci — drahý kryptografický stav (cryptographic state) lze vybudovat jednou za soubor, nikoli jednou za každý objekt

Slovník R6 /Encrypt: jedno pomalé otevření, levné objekty

Dokument revize 6 deklaruje své schéma ve slovníku /Encrypt v traileru a záznamy, na kterých záleží, se vejdou do několika řádků:

/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 vybírá architekturu 256bitového klíče a /R 6 tvrzený (hardened) handshake z ISO 32000-2. /CF definuje pojmenovaný šifrovací filtr — /AESV3 znamená AES-256 v režimu CBC s předsazeným (prepended) IV — a /StmF s /StrF přiřazují tento filtr k datovým proudům (streams), resp. řetězcům. /O, /U, /OE a /UE obsahují ověření hesla a materiál pro obalení klíče (key-wrapping material), a /Perms nese pomocí AES zašifrovanou kopii bitů oprávnění (permission bits), takže útočící (hostile) editor nemůže potichu přehodit /P

Struktura nákladů se skrývá v /OE a /UE. Rozbalení klíče souboru (unwrapping the file key) z nich spouští Algoritmus 2.B (Algorithm 2.B), což je iterovaná funkce odvozování klíčů řetězící kola SHA-256, SHA-384 a SHA-512 — nejméně 64 z nich, s pravidlem zastavení (stopping rule) závislým na datech — zabudovaná úmyslně tak, aby byla pomalá a hádání hesla zůstalo drahé. Tato cena se zaplatí jednou, když zapisovač vyprodukuje soubor, a podruhé, když jej čtečka otevře, v obou případech se jedná o jednotky milisekund. U souboru o půl milionu objektů je funkce KDF zanedbatelná (noise), a pokud je ukládání (save) pomalé, není podezřelým Algoritmus 2.B; ale cyklus na každý objekt (per-object loop)

Znovu použijte handle klíče, znovu použijte scratch buffer

Naivní implementací je úhledná obslužná funkce (utility function): pomocník EncryptAes256Cbc, který otevře poskytovatele (provider) Windows CNG, vybere CBC, vygeneruje objekt klíče, zašifruje jeden buffer a vše zase zahodí (tears everything down). Správné, testovatelné pomocí unit testů a katastrofální uvnitř cyklu s 500 000 iteracemi. Dokumentace společnosti Microsoft označuje BCryptOpenAlgorithmProvider za drahý a doporučuje cachovat (ukládat) handle do mezipaměti, a BCryptGenerateSymmetricKey spouští kompletní rozvrh klíčů (key schedule) AES a alokuje stav poskytovatele (provider state) — čisté plýtvání, když se klíč napříč dokumentem nikdy nemění

Knihovna RTL v Delphi neobsahuje jednotku pro import knihovny bcrypt, takže vstupní body deklarujte přímo. Níže uvedená třída vytvoří veškerý kryptografický stav pouze jednou a poté zašifruje libovolný počet objektů bez jakékoli stálé alokace (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 selhalo, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // pracovní prostor (workspace) klíče CNG, alokován jednou
    FScratch: TBytes;    // pracovní prostor pro šifrový text, roste a poté zůstává
  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('Klíč souboru AES-256 musí mít 32 bajtů');
  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);
  // Zde se rozvrh klíčů AES vybuduje jednou a znovu se použije pro každý objekt
  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
  // Čerstvý náhodný IV pro každý objekt; cestuje (travels) v čitelném formátu před daty
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // nil u prázdného vstupu je platné: jedná se o blok tvořený pouze výplní (padding-only block)

  // Dotaz na velikost (Size query): padding CBC vždy přidá 1..16 bajtů, takže Need > Length(Plain)
  IVWork := IV;  // BCryptEncrypt posouvá během řetězení (chains) vyrovnávací paměť IV
  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);  // vyroste jen párkrát a pak už zůstane (stays put)

  IVWork := IV;
  CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
    PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');

  // Rozložení AESV3: 16bajtový IV a za ním zašifrovaný text doplněný o zarovnání (padded ciphertext)
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Zásadní (load-bearing) jsou tři detaily. Dotaz na velikost (size query) — první volání BCryptEncrypt s nulovým výstupním bufferem (nil) — vrací délku zarovnaného šifrového textu, která se nikdy nerovná délce vstupu; zarovnání je deterministické, takže si můžete vypočítat ((Len div 16) + 1) * 16 sami a zkrátit počet volání na polovinu, ale dotaz (query) představuje zdokumentovanou dohodu (contract). Zadruhé, BCryptEncrypt při řetězení (as it chains) posouvá vyrovnávací paměť IV na svém místě, takže do každého volání vstupuje pracovní kopie a do výstupu se dostane onen původní čistý IV (pristine IV). Zatřetí, FScratch pouze roste, a to až na velikost největšího objektu v souboru, po čemž cyklus (loop) nealokuje už vůbec nic

Zhodnocení opětovného použití handle v číslech (measured)

Souborem, který si toto cvičení vyžádal, byl naskenovaný úvěrový archiv o velikosti 1,8 GB: 412 000 zašifrovaných objektů nesoucích 1 710 MB užitečného zatížení (payload) po odečtení struktury s prostým textem. Stejný stroj, stejný soubor, úložiště NVMe, jedno vlákno:

  • Nastavení při každém volání (Per-call setup) (poskytovatel otevřen a klíč vygenerován uvnitř pomocníka): fáze šifrování 71,3 s — 1 710 MB ÷ 71,3 s ≈ 24 MB/s
  • Vytáhnutý stav (State hoisted) (třída uvedená výše): 9,6 s — 1 710 MB ÷ 9,6 s ≈ 178 MB/s

Rozdíl činí 61,7 s napříč 412 000 voláními, neboli zhruba 150 µs na volání strávené otevíráním poskytovatele, nastavováním režimu řetězení (chaining mode) a znovuvytvářením rozvrhu klíčů (rebuilding a key schedule) pro klíč, který se nikdy nezměnil. Nic z toho nebyla kryptografie. S rozšířením AES-NI běží CBC šifrování velkých bufferů na jednom jádru rychlostí téměř 1,4 GB/s, takže samotná aritmetika AES představuje z celkových 9,6 asi 1,2 s; většinu zbytku tvoří dva přechody do uživatelského režimu (user-mode) u volání BCryptEncrypt na každý objekt plus generování IV (per-object IV generation). Dávkování inicializačních vektorů (Batching the IVs) — jedno volání BCryptGenRandom plnící 4 096 z nich — zkrátilo běh (trimmed the run) na 8,9 s. Jakmile se dostanete za tento bod, jste na minimálních nákladech pro každý objekt stanovených v rámci daného API (per-object floor of the API), a zbývající pákou je paralelismus: objekty schématu /V 5 jsou pod sdíleným klíčem souboru (shared file key) nezávislé, takže se čtyřmi pracovními vlákny (worker threads), z nichž každé mělo jeden objekt klíče (key object), se fáze šifrování dostala na 3,1 s, než se výstupní zapisovač stal bodem pro serializaci

Kompletní přepsání vs. přírůstkové ukládání

Granularita také rozhoduje o tom, kolik stojí ukládání (save). Přidání šifrování do existujícího textového (plaintext) dokumentu z definice přepisuje každý objekt: u každého datového proudu i řetězce se mění obsah i délka, každý offset (posun) u křížových odkazů se posune a neexistuje žádná přírůstková cesta. Počítejte s tím ve vašem výkonnostním rozpočtu jako s plným sekvenčním přepisem (full sequential rewrite) a zapisujte do dočasného souboru (temporary file), který je následně přejmenován na koncový soubor (target), protože pád (crash) v polovině šifrování jinak zanechá napůl zašifrovaný soubor, který už žádné heslo neotevře

Opačný směr je ten levný. Jakmile je soubor zašifrován, přírůstková aktualizace připojí (appends) nové objekty zašifrované stejným klíčem souboru (file key) a každý z původních bajtů ponechá nedotčený. Otisk schvalovací anotace (approval annotation) na 2GB šifrovaný archiv bude stát řádově jednotky kilobajtů přidaného výstupu, nikoli kompletní 2GB přepis. Důsledek pro pipeline: šifrujte jen jednou, jako poslední krok dané úlohy (job), a nechte následující zásahy fungovat s přírůstkovým ukládáním. Rotace (střídání) hesel (password rotation), která zrotuje také klíč souboru, představuje opětovný kompletní přepis (full rewrite) — a přesně tak by se to mělo plánovat

Měření propustnosti bez vlastního klamání

Tvrzení o propustnosti (throughput claims) šifrování bývají často chybná v čitateli, ve jmenovateli nebo v obou. Čitatelem by měly být bajty užitečného zatížení (payload bytes): součet délek datových proudů a řetězců, které se skutečně protlačí přes AES, po kompresi, což si zapisovač může nasčítat za chodu. Velikost souboru tyto údaje zveličuje — výše zmíněný archiv má na disku 1,8 GB, ale pouze 1 710 MB z něj se vůbec dotkne šifry. Jmenovatelem by měla být pouze fáze šifrování ohraničená časovačem TStopwatch z System.Diagnostics, kdy parsování (parsing), deflace a diskové I/O (disk I/O) zůstanou mimo tyto závorky. Pokud tyto věci zahrnete, stejný šifrovací kód (encryption code) se při měření projeví jako několikrát pomalejší na souboru, který se zkrátka jen hůře komprimuje. Výše uvedené hodnoty jsou srovnatelné právě proto, že obě strany dělení zahrnují výhradně jen šifrování

Nic z toho nemusí být váš vlastní kód. HotPDF obaluje totožné inženýrství za vlastnostmi (properties) komponenty — ActivateProtection, CryptKeyLength, UseAES256R6 — ve správné nadmořské výšce pro interaktivní aplikace VCL, s tím, že úskalími v pořadí přiřazení (assignment-order pitfalls) se zabývá článek o AES-256 v HotPDF. Pro bezobslužné (unattended) pipeline aplikuje PDFlibPas na stávající soubory šifrování AES-256 revize 6 v rámci jediného volání EncryptFile se sílou 4 (Strength 4) a následně ověří to, co se uložilo na disk, což je pracovní postup, kterým provází článek o auditu šifrování v PDFlibPas

Zde popsané cesty šifrování se dodávají jako součást řešení HotPDF Component pro Delphi a C++Builder a v knihovně PDFlibPas; produktové stránky pro obě řešení nesou kompletní šifrovací referenci