Műszaki cikk

Nagy sebességű AES-256 PDF titkosítás hatalmas dokumentumokhoz

Egy 2 GB-os PDF titkosítása adatfolyam (streaming) problémának hangzik: nyissa meg a fájlt, nyomjon át két gigabájtot az AES-256-on, majd írja ki az eredményt. Ez a mentális modell téves, és ez a tévedés határozza meg a teljes teljesítményköltségvetést. Az ISO 32000-1 §7.6 a PDF-titkosítás granularitását az egyedi objektumok szintjén határozza meg — minden streamet és minden karakterláncot külön titkosítanak, mindegyiket saját inicializációs vektorral (IV) és saját kitöltéssel (padding). Egy 2 GB-os beolvasott archívum 500 000 objektummal 500 000 kis CBC műveletet jelent, nem egy hosszú menetet, és ezen a skálán az egyes műveletek körüli fix költség többet számít, mint a benne lévő AES aritmetika

Ez a cikk arról a fix költségről szól: hová tűnik az idő, amikor a Delphi kód AES-256-ot alkalmaz nagyon nagy dokumentumokra, és hogyan lehet azt visszanyerni. A beállítási oldalról — jelszavak, engedélyező jelzők (permission flags), az 5. versus 6. revíziós kompatibilitási hívás — tekintse meg az AES-256 titkosítás HotPDF-ben történő konfigurálásáról szóló kiegészítő cikket; abból semmit sem ismétlünk meg itt

Félmillió CBC művelet, nem egyetlen menet

A fájl váza nyílt szöveg marad. Kereszthivatkozási táblázatok, objektumszámok, szótárkulcsok, az oldalfa: egyik sem titkosított, így az olvasó meg tudja találni az objektumokat, mielőtt érvényesítené a jelszót. Amit a szabvány titkosít, az a tartalom — stream adatok, például oldalleírások, képek, betűtípusok és mellékletek, plusz karakterláncok, például metaadat-értékek és annotációk szövege. Az AES-256 crypt szűrő alatt mindegyik feldolgozása önállóan történik: egy friss, véletlenszerű 16 bájtos IV, CBC a bájtokon, blokk kitöltése egy 16 bájtos határig, és a tiszta szövegben, a titkosított szöveg (ciphertext) előtt elhelyezett IV

Két következmény adódik ebből. Először is, a titkosított szöveg mindig hosszabb, mint a nyílt szöveg: az IV hozzáad 16 bájtot, a kitöltés pedig 1-16 bájtot, így egy 100 bájtos karakterlánc 128 bájtot foglal el a lemezen, és egy üres stream is legalább 32 bájtot eredményez. Az a kód, amely a kimeneti puffert a bemenet hosszára méretezi, vagy csak annyi bájtot ír vissza, amennyit beolvasott, olyan fájlokat hoz létre, amelyeknél minden objektum utolsó blokkja esetén meghiúsul a visszafejtés. Másodszor, a költség az objektumok számát követi nyomon, nem csak a bájtok számát. Egy szkennelt archívum a bájtokat néhány nagy kép streambe koncentrálja, de több százezer rövid streamet és apró karakterláncot hordoz, ahol a műveletenkénti többletköltség — és nem az AES — a valódi költség

Az egyetlen irgalom az AES-256 kialakításában a kulcskezelés. A 4. revízióig a biztonsági kezelők (security handlers) minden egyes objektumhoz külön kulcsot származtattak a fájlkulcs, valamint az objektum és generáció számok együttes hashelésével, ami minden alkalommal friss kulcsütemezést (key schedule) kényszerített ki. A /V 5 sémák elvetették az objektumonkénti származtatást: egyetlen véletlenszerű 256 bites fájlkulcs titkosítja a dokumentum összes objektumát. Ez a tény engedélyezi az összes alábbi optimalizációt — a drága kriptográfiai állapotot fájlonként egyszer lehet felépíteni, nem pedig objektumonként egyszer

Az R6 /Encrypt szótár: egy lassú nyitás, olcsó objektumok

Egy 6. revíziós dokumentum a trailer /Encrypt szótárában deklarálja a sémáját, és a számító bejegyzések elférnek néhány sorban:

/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

A /V 5 kiválasztja a 256 bites kulcs architektúrát, a /R 6 pedig a megerősített ISO 32000-2 kézfogást (handshake). A /CF meghatározza a megnevezett crypt szűrőt — a /AESV3 az AES-256-ot jelenti CBC módban, eléillesztett IV-vel — és az /StmF, valamint a /StrF hozzárendeli ezt a szűrőt a streamekhez és a karakterláncokhoz. A /O, /U, /OE és /UE tárolja a jelszóellenőrző és kulcsbecsomagoló anyagot, a /Perms pedig az engedélybitek AES-titkosított másolatát hordozza, így egy rosszindulatú szerkesztő nem tudja csendben átállítani a /P értékét

A költségstruktúra az /OE és /UE mögött rejtőzik. A fájlkulcs ezekből történő kicsomagolása a 2.B algoritmust futtatja, amely egy iterált kulcsszármaztató függvény (KDF), amely SHA-256, SHA-384 és SHA-512 köröket láncol — legalább 64-et, egy adatoktól függő megállási szabállyal — szándékosan lassúra építve, hogy a jelszó kitalálása drága maradjon. Ezt az árat egyszer fizetjük meg, amikor az író előállítja a fájlt, és egyszer, amikor az olvasó megnyitja azt, ami egyenként egyszámjegyű milliszekundumot jelent. Egy félmillió objektumot tartalmazó fájl esetén a KDF zaj, és ha a mentés lassú, nem a 2.B algoritmus a gyanúsított; hanem az objektumonkénti ciklus

Használd újra a kulcsleírót, használd újra a munkapuffert

A naiv implementáció egy tiszta segédfüggvény: egy EncryptAes256Cbc segítő, amely megnyitja a Windows CNG szolgáltatót (provider), kiválasztja a CBC-t, generálja a kulcsobjektumot, titkosít egy puffert, és mindent lebont. Helyes, egységtesztelhető és katasztrofális egy 500 000 iterációs cikluson belül. A Microsoft dokumentációja a BCryptOpenAlgorithmProvider-t drágaként jelöli meg, és a leíró (handle) gyorsítótárazását javasolja, a BCryptGenerateSymmetricKey pedig futtatja a teljes AES kulcsütemezést és allokálja a szolgáltató állapotát — tiszta pazarlás, amikor a kulcs soha nem változik a dokumentumban

A Delphi RTL nem tartalmaz bcrypt import egységet, így a belépési pontokat (entry points) közvetlenül kell deklarálni. Az alábbi osztály az összes kriptográfiai állapotot egyszer felépíti, majd bármennyi objektumot titkosít állandó állapotú (steady-state) memóriafoglalás nélkül:

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 meghiúsult, NTSTATUS 0x%.8x',
      [Api, Cardinal(Status)]);
end;

type
  TPdfObjectEncryptor = class
  private
    FAlg: BCRYPT_HANDLE;
    FKey: BCRYPT_HANDLE;
    FKeyObject: TBytes;  // CNG kulcs-objektum munkaterület, egyszer allokálva
    FScratch: TBytes;    // titkosított szöveg karcoló (scratch), növekszik majd marad
  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('Az AES-256 fájlkulcsnak 32 bájtosnak kell lennie');
  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);
  // Az AES kulcsütemezés itt épül fel egyszer, és minden objektumhoz újra felhasználják
  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
  // Friss véletlenszerű IV objektumonként; ez tiszta formában utazik az adatok előtt
  CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
    'BCryptGenRandom');
  Src := PByte(Plain);  // egy üres bemenet nil-je érvényes: csak kitöltést tartalmazó blokk

  // Méretlekérdezés: a CBC kitöltés mindig 1..16 bájtot ad hozzá, így Need > Length(Plain)
  IVWork := IV;  // A BCryptEncrypt helyben mozgatja előre az IV puffert a láncolás közben
  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);  // néhányszor megnő, utána a helyén marad

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

  // AESV3 elrendezés: a 16 bájtos IV, majd a kitöltött titkosított szöveg
  Dest.WriteBuffer(IV[0], 16);
  Dest.WriteBuffer(FScratch[0], Written);
end;

Három részlet a teherviselő. A méretlekérdezés — az első BCryptEncrypt hívás, nil kimeneti pufferrel — visszaadja a kitöltött titkosított szöveg hosszát, amely soha nem egyenlő a bemeneti hosszal; a kitöltés determinisztikus, tehát kiszámíthatja az ((Len div 16) + 1) * 16 értéket saját maga is, amivel felére csökkenti a hívások számát, de a lekérdezés a dokumentált szerződés. Másodszor, a BCryptEncrypt láncolás közben a helyén mozgatja előre az IV puffert, így minden hívásba egy munkapéldány kerül, és a tiszta IV kerül a kimenetbe. Harmadszor, a FScratch csak nőni tud, egészen a fájl legnagyobb objektumáig, ami után a ciklus nem allokál semmit

Mennyit ér a leíró (handle) újrafelhasználása, mérve

Az a fájl, amely kikényszerítette ezt a gyakorlatot, egy 1,8 GB-os beszkennelt hitelarchívum volt: 412 000 titkosított objektum, amely 1 710 MB hasznos terhet (payload) hordozott, miután a nyílt szövegű struktúrát levontuk belőle. Ugyanaz a gép, ugyanaz a fájl, NVMe tároló, egyetlen szál:

  • Hívásonkénti beállítás (a szolgáltató megnyílt és a kulcs a segéden belül generálódott): titkosítási fázis 71,3 mp — 1 710 MB ÷ 71,3 mp ≈ 24 MB/s
  • Állapot kiemelve (a fenti osztály): 9,6 mp — 1 710 MB ÷ 9,6 mp ≈ 178 MB/s

A különbség 61,7 másodperc 412 000 hívás alatt, vagyis hívásonként nagyjából 150 µs ment el a szolgáltató megnyitására, a láncolási mód beállítására és egy olyan kulcs kulcsütemezésének (key schedule) újraépítésére, amely sosem változott. Ennek semmi köze nem volt a kriptográfiához. Az AES-NI használatával a nagy pufferek CBC titkosítása 1,4 GB/s közelében fut egy magon, így maga az AES aritmetika a 9,6 másodpercből nagyjából 1,2 másodpercet tesz ki; a többi nagyrészt a két felhasználói módú (user-mode) BCryptEncrypt átmenet objektumonként, plusz az objektumonkénti IV generálás. Az IV-k kötegelt feldolgozása (batching) — egy BCryptGenRandom hívás, amely 4 096-ot tölt meg belőlük — 8,9 másodpercre vágta le a futást. Ezen túl az API objektumonkénti padlóján vagyunk, és a megmaradt fogantyú a párhuzamosság: a /V 5 objektumok függetlenek a megosztott fájlkulcs alatt, így négy munkaszál egy-egy kulcsobjektummal 3,1 másodpercre csökkentette a fázist, mielőtt a kimeneti író vált volna a sorosítási ponttá

Teljes újraírás versus növekményes mentés

A granularitás azt is eldönti, mibe kerül egy mentés. Ha titkosítást adunk egy már létező nyílt szövegű dokumentumhoz, az definíció szerint minden objektumot újraír: minden stream és karakterlánc megváltoztatja a tartalmát és a hosszát is, minden kereszthivatkozási eltolás (offset) elmozdul, és nem létezik növekményes út. Úgy tervezze a költségvetést, mint egy teljes szekvenciális újraírást, és írjon egy ideiglenes fájlba, amelyet utána átnevez a célnak, mert egy titkosítás közbeni összeomlás egyébként egy félig titkosított fájlt hagy maga után, amit semmilyen jelszó nem nyit meg

A fordított irány az olcsó. Miután egy fájl titkosítva van, egy növekményes frissítés (incremental update) hozzáfűzi ugyanazzal a fájlkulccsal titkosított új objektumokat, és érintetlenül hagyja az összes eredeti bájtot. Egy jóváhagyási annotáció rábélyegzése egy 2 GB-os titkosított archívumra kilobájtnyi hozzáfűzött kimenetbe kerül, nem egy 2 GB-os újraírásba. A folyamat következménye: titkosítson egyszer, a munkafolyamat utolsó lépéseként, a későbbi érintések pedig lovagoljanak a növekményes mentéseken. A jelszóváltás, amely egyben a fájlkulcsot is forgatja, ismét egy teljes újraírás — időzítse úgy

A sávszélesség (throughput) mérése anélkül, hogy becsapnánk magunkat

A titkosítási sávszélességre vonatkozó állítások gyakran hibásak a számlálóban, a nevezőben vagy mindkettőben. A számlálónak a hasznos teher (payload) bájtoknak kell lennie: a streamek és a karakterláncok hosszának összege, amelyeket valóban átnyomtak az AES-en tömörítés után, és amelyeket az író menet közben is összesíteni tud. A fájlméret túlbecsüli ezt — a fenti archívum 1,8 GB a lemezen, de ebből csak 1 710 MB érinti a titkosítót (cipher). A nevező csak a titkosítási fázis kellene legyen, a System.Diagnostics TStopwatch-ával közrezárva, míg a feldolgozás, a kicsomagolás (deflate) és a lemezes I/O a zárójeleken kívül marad. Ha ezeket belevonjuk, ugyanaz a titkosító kód sokkal lassabbnak tűnhet egy olyan fájlon, ami egyszerűen rosszabbul tömöríthető. A fenti adatok pontosan azért összehasonlíthatóak, mert az osztás mindkét oldala csak a titkosítást tartalmazza

Ennek a kódnak egyáltalán nem kell sajátnak lennie. A HotPDF ugyanezt a mérnöki munkát komponens-tulajdonságok (ActivateProtection, CryptKeyLength, UseAES256R6) mögé rejti — megfelelő szinten az interaktív VCL alkalmazásokhoz, miközben az értékadás sorrendjéből fakadó csapdákat a HotPDF AES-256 cikk fedi le. Felügyelet nélküli (unattended) folyamatok esetében a PDFlibPas a 6. revíziós AES-256-ot egyetlen EncryptFile hívásban alkalmazza (4. erősséggel) a már meglévő fájlokon, és utólag ellenőrzi, hogy mi landolt a lemezen, amit részletesen a PDFlibPas titkosítási auditcikkben ismertetünk

Az itt bemutatott titkosítási útvonalak megtalálhatóak a Delphihez és C++Builderhez készített HotPDF Komponensben és a PDFlibPas könyvtárban; mindkét termékoldal tartalmazza a teljes titkosítási referenciát