Technický článek

Proč se podepsané AES-256 PDF načítají pomalu: opravy HotPDF

HotPDF načítá podepsaná, AES-256 šifrovaná PDF v Delphi mnohem rychleji než dřív, protože se z cesty načítání odstranily tři oddělené náklady: nebufferované parsování tokenů zdrojového streamu, druhý běh kontroly hesla Algorithm 2.A z ISO 32000-2 a pomalá vnitřní smyčka AES-128-CBC uvnitř R6 hardened hashe. Na Delphi cílech vzala sama oprava R6 načtení dokumentu z asi 66 ms na 16 ms

Ani jeden z nich se neukázal na malých testovacích souborech, které většina sad používá. Nepodepsaný, nešifrovaný vzorek o jednom či dvou kilobajtech se otevře za pár setin sekundy, ať dělá parser cokoli. Potíže začínají u dokumentů, které zákazníci skutečně posílají: smlouva s embedded CMS podpisem, kolem 35 KB, chráněná heslem revize 6. Ta kombinace zapne každou drahou kódovou cestu najednou a každá z nich měla vlastní důvod, proč byla pomalejší, než musela být. Tenhle článek jimi projde v pořadí, v jakém se našly, spolu s chybami cache klíčů, které se objevily, když se začaly memoizovat výsledky ověřování podpisů

Proč načtení podepsaného PDF 34 KB trvá 170 ms?

Podepsané PDF 34 KB se načítalo 170 ms, protože četl parser holý THandleStream přímo a každý bajt tokenu se měnil ve vlastní systémové volání ReadFile. Tokenizér PDF postupuje bajt po bajtu, dokud hledá oddělovače, takže nebufferovaný handle stream promění levný sken v paměti na tisíce přechodů do jádra

Detaily vysvětlují, proč bug přežil. THotPDF.LoadFromFile už dlouho balí svůj souborový zdroj do interního read bufferu a AutoDecrypt sonda se už dřív opravila ze stejného důvodu, když trávila 1,4 s znovu dotazováním velikosti souboru 56 MB. Podepsané a šifrované dokumenty ale berou jinou cestu. Aby se zajistilo, že líné čtení objektů a inspekce podpisů vždycky vidí tutéž neměnnou revizi, i poté, co volající uvolní svůj vlastní stream, bere THotPDF.LoadFromStream snapshot zdroje a parsuje ho podruhé. Ten druhý parse, spolu s reuse load používaným pro autentizované revize, četl snapshot handle bez bufferu před ním. Oba teď jdou přes stejného interního bufferovaného čteče, kterého používá souborová cesta

Pipeline načítání podepsaného šifrovaného PDF AES-256 v HotPDF v Delphi: snapshot stream, bufferovaný strukturální parse, presken Algorithm 2.A, jediné dešifrování znovupoužívající obnovený klíč a R6 hardened hash volající nativní AES-128-CBC, zkrácení načtení z 66 ms na 16 ms
Tři opravy se vrství na jedné cestě: bufferování odstraní tisíce systémových volání, preskenovaný klíč se znovu použije pod scope příznakem a vnitřní smyčka R6 dosáhne na nativní CBC backend

Ten buffer je interní, takže si ho nikdy nevytváříte sami. Ovládáte stream, který předáte LoadFromStream, a HotPDF ho čte tak, jak je, během prvního strukturálního průchodu. Jsou-li data už v paměti, předejte TMemoryStream. Jsou-li na disku, volejte LoadFromFile, nebo na Delphi 10.1 Berlin a novější dejte loaderu TBufferedFileStream místo holého TFileStream

uses
  System.SysUtils, System.Classes, HPDFDoc;

function LoadEncryptedFromBytes(const FileName: string;
  const Password: AnsiString): THotPDF;
var
  Mem: TMemoryStream;
begin
  Result := THotPDF.Create(nil);
  Mem := TMemoryStream.Create;
  try
    Mem.LoadFromFile(FileName);   // jeden hromadný read místo readů po tokenech
    Mem.Position := 0;
    // LoadFromStream vrací počet stránek, 0 když se dokument nenačetl
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

Uvolnění memory streamu hned po načtení je bezpečné pro podepsaný a šifrovaný vstup, protože komponenta už drží vlastní snapshot bajtů. Ponechte si stejně původní soubor nebo buffer, pokud plánujete poté ověřovat podpisy, protože ověření hashuje syrové segmenty /ByteRange souboru, ne dešifrovaný objektový model

Měření načtení a ověření jako oddělených fází

Čas načtení a čas ověření podpisu se mají měřit odděleně, protože selhávají odlišně a opravují se na různých místech. Fáze načtení pokrývá parsování, kontrolu hesla a dešifrování. Fáze ověření znovu hashuje podepsané bajtové rozsahy a kontroluje CMS podpis. Slijete-li je do jednoho čísla, regrese v jednom se skryje v druhém

RTL TStopwatch ze System.Diagnostics na to stačí. Harness níže načítá s heslovým overloadem THotPDF.LoadFromFile a potom ověří první podpis přes TStream overload VerifyLoadedSignature. Stream overload je správná volba pro šifrovaný soubor, protože heslová načítací cesta nedrží název zdrojového souboru, který by bezparametrové overloady mohly znovuotevřít

uses
  System.SysUtils, System.Classes, System.Diagnostics,
  HPDFDoc, HPDFSignVerify;

procedure TimeSignedEncryptedLoad(const FileName: string;
  const Password: AnsiString);
var
  Pdf: THotPDF;
  Src: TFileStream;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  Watch: TStopwatch;
  Pages: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Watch := TStopwatch.StartNew;
    Pages := Pdf.LoadFromFile(FileName, Password);
    Writeln(Format('Load + decrypt: %d ms, %d pages, encrypted=%s',
      [Watch.ElapsedMilliseconds, Pages, BoolToStr(Pdf.IsEncrypted, True)]));
    if (Pages < 1) or (Pdf.GetLoadedSignatureFieldCount = 0) then
      Exit;

    Src := TFileStream.Create(FileName, fmOpenRead or fmShareDenyWrite);
    try
      Watch := TStopwatch.StartNew;
      Status := Pdf.VerifyLoadedSignature(0, Src, Info);
      Writeln(Format('Verify signature 0: %d ms, status %d, signer %s',
        [Watch.ElapsedMilliseconds, Ord(Status), string(Info.SignerName)]));
    finally
      Src.Free;
    end;
  finally
    Pdf.Free;
  end;
end;

Pusťte to několikrát a ignorujte první běh. První volání platí jednorázové náklady nastavení, včetně stavby AES tabulek a běhu jejich self-testu, a disková cache zkreslí i první čtení. Hledáte ustálené číslo pro skutečný zákaznický soubor, měřené proti nepodepsané, nešifrované kopii téhož dokumentu

Proč se AES-256 souborový klíč odvozoval dvakrát?

HotPDF odvozoval AES-256 souborový klíč dvakrát, protože dvě fáze téhož načtení si každá pustila celou kontrolu hesla sama. Před strukturálním parsem prescanuje RecoverReloadKeysFromStreamRanges syrové bajty, aby se daly dešifrovat kontejnery šifrovaných object streamů, zatímco se dekódují filtry. Ten presken už provádí kontroly Algorithm 2.A z ISO 32000-2 §7.6.4.3.3, validuje heslo proti solím uživatele a vlastníka a obnovuje souborový šifrovací klíč. DecryptLoadedDocument pak ten výsledek ignoroval a dělal celé odvození znovu

U revize 4 a starších stála ta duplikace málo víc než smyčka MD5. U revize 6 volá Algorithm 2.A hardened hash Algorithm 2.B, takže duplikace zdvojnásobila nejdražší část otevírání souboru. Znovupoužít obnovený klíč je jednoduché. Znovu ho použít bezpečně je ta těžší část, protože cachovaný klíč, který přežije vlastní kontrolu hesla, se změní v obejití autentizace

Oprava omezuje znovupoužití scope příznakem, který je true jen mezi preskenem a voláním dešifrování uvnitř heslových overloadů LoadFromFile a LoadFromStream a maže se v bloku finally. Příznak se nastavuje jen tehdy, když presken heslo skutečně zvalidoval. Přímé volání DecryptLoadedDocument po prostém načtení vždycky pustí celé odvození, protože to heslo nebylo nikdy preskenované. Bez toho omezení by mohl zastaralý reload klíč z dřívějšího úspěšného otevření tiše dešifrovat dokument pro pozdějšího volajícího, který zadal špatné heslo, a to je o dost horší bug než pomalé načtení

Kde hardened hash R6 tráví svůj čas?

Hardened hash R6 tráví skoro všechen čas šifrováním AES-128-CBC. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B opakuje kolo nejméně 64krát. Každé kolo postaví buffer 64 kopií hesla, aktuálního hashe a, na straně vlastníka, řetězce /U, zašifruje ten buffer AES-128-CBC s prvních 16 bajty hashe jako klíčem a dalšími 16 jako IV a vybere SHA-256, SHA-384 nebo SHA-512 pro další hash z prvních 16 bajtů ciphertextu čtených jako číslo modulo 3. Hashy jsou krátké. Buffery, které se šifrují, jsou několik kilobajtů na kolo

Diagram smyčky hardened hashe R6 Algorithm 2.B v HotPDF pro Delphi: každé z nejméně 64 kol postaví buffer 64 kopií hesla, hashe a řetězce /U, zašifruje ho AES-128-CBC s hashem jako klíčem a IV a vybere SHA-256, SHA-384 nebo SHA-512 z prvních bajtů ciphertextu
Cena R6 žije v šifrování bufferu AES-128-CBC, ne v digestech, proto nativní CBC rutina uvnitř PDF20HashDanceR6 sejme celé načtení dokumentu z asi 66 ms na 16 ms

Implementace té smyčky v HotPDF, PDF20HashDanceR6, šifrovala buffer blok po bloku přes bajtově orientované referenční AES. Teď nejdřív zkusí nativní CBC rutinu bez paddingu, která na Delphi cílech dosáhne na linkovaný C AES backend. S tou změnou kleslo celé načtení dokumentu z asi 66 ms na 16 ms. Není-li nativní rutina dostupná nebo vrací-li délku, která nesedí, sáhne kód po historické blokové smyčce a ta smyčka může zase použít table-driven AES-128 blok, jehož T-tabulky se generují z existujícího S-boxu. Šifra je ve všech případech stejná, takže odvozený klíč je bajtově identický, bez ohledu na to, která cesta ho spočítala

Tenhle článek je o rychlosti. Dostat správně terminační pravidlo Algorithm 2.B je samostatný korektnostní problém a off-by-one tam odmítá správná hesla jen občas. Konfigurační stranu R5 versus R6 popisuje průvodce nastavením šifrování PDF AES-256 s HotPDF

Čtyři způsoby, jak vám rychlý AES blok může lhat

Table-driven AES blok může produkovat špatný ciphertext a pořád projít vlastním self-testem. Každá ze čtyř pastí níže se objevila při stavbě rychlé cesty a dvě z nich přešly self-testem, jak byl poprvé navržený

  • Reentrance self-testu. Rutina připravenosti nastavuje svůj inicializační příznak před během self-testu, takže reentratní volání udělané během testu sáhne po referenční cestě. První verze testu proto srovnávala referenční implementaci se sebou samou. Opravou je nechráněný syrový rychlý blok, který self-test volá přímo, takže testovaným kódem je opravdu cesta tabulek
  • Prohozené layouty tabulek. Výměna byte pořadí dvou ze čtyř round tabulek (Te1 a Te3) produkuje špatný ciphertext, který reentratní self-test neměl jak si všimnout. Chytila ho samostatná průzkumná utilitka, která tiskla ciphertext FIPS-197 přílohy C.1 69c4e0d86a7b0430d8cdb78070b4c55a pro klíč 000102…0f a plaintext 00112233…ff. Self-test teď vyžaduje obojí, ten vektor i bajtovou rovnost s referenčním blokem, než rychlé cestě věří
  • Range a overflow kontroly FPC. Pod Free Pascal s -Cr a -Co vyhazují Delphi stylu explicitní konverze Byte() a aritmetika posunů v bajtové šířce výjimky range nebo overflow. Indexy tabulek a zdvojnásobení GF(2^8) se teď počítají v nativní integer šířce a maskují s and $FF
  • Miskompilace inlinerem FPC. Malé helpery big-endian load a store beroucí parametry PByte se zkompilovaly do access violation, když je pod FPC označil inline. Odstranění inline je opravilo a cena volání je zanedbatelná vedle table lookupů

Za finálním zapojením je ta samá zkušenost. Směrování každého AES-128 bloku v knihovně přes rychlé tabulky způsobilo deterministickou FPC access violation během jednoho načtení publikačního kandidáta, takže ta změna se vrátila zpět. Dodaný design je konzervativní: nejdřív nativní CBC, explicitní rychlotabulková větev uvnitř smyčky R6 a referenční blok všude jinde. Obecná lekce je, že nový kryptografický kód, který se musí sestavit pod několika kompilátory, potřebuje nezávislou known-answer sondu puštěnou na každém cílovém kompilátoru. Selhání testu na úrovni DLL vám neřekne, zda máte špatnou tabulku, nebo pád. Více ke straně Free Pascal v poznámkách o backendech Deflate a AES v HotPDF pod Free Pascal

Jak cachovat ověření podpisu, aniž byste cachovali špatnou odpověď?

Cache ověřování podpisů je bezpečná jen tehdy, když její klíč pokrývá každý vstup, který může změnit verdikt, včetně vstupů žijících mimo podepsané bajtové rozsahy. Headless dokumentová vrstva HotPDF memoizuje výsledky CMS ověření hashováním bajtů CMS, bajtů podpisového slovníku v mezeře /Contents, obsahů pokrytých rozsahů, fingerprintu politiky, rozpočtů ověření, celkové velikosti zdroje a jednominutového časového kbelíku. Tři z těch vstupů se přidaly jen poté, co bug ukázal, že chybí

Vstupy klíče cache ověřování podpisů v HotPDF pro Delphi: bajty CMS, podpisový slovník v mezeře /Contents, obsahy pokrytých rozsahů, fingerprint politiky, rozpočty, celková velikost zdroje a jednominutový časový kbelík se hashují dohromady, než se verdikt CMS memoizuje
Verdikt se smí znovu použít, jen když je každý vstup, který ho může změnit, v klíči - velikost zdroje záleží, protože základní revize a její inkrementální update mohou sdílet identické pokryté rozsahy

Důvěryhodné certifikátové soubory se klíčují obsahem, ne cestou. Operace vrstva zapisuje tutéž PEM materiál pod čerstvým dočasným názvem souboru pro každý příkaz, takže klíč založený na cestě se nikdy netrefil a cache nikdy nezabrala. Celková velikost zdroje musela být součástí klíče, protože hlášené UnsignedTrailingBytes na ní závisí: základní revize a pozdější inkrementální update mohou sdílet přesně tentýž pokrytý obsah při různých délkách závěru a cache klíčovaná jen rozsahy by podala verdikt základní revize updatovanému souboru. Kontext toho, co ty závěrečné bajty znamenají, je v průvodci HotPDF ověřováním digitálních podpisů PDF v Delphi

Třetí bug byla jazyková past v Pascalu, ne designová chyba. Hash rutiny berou netypovaný const buffer a předání dynamického pole do netypovaného parametru předá adresu reference na pole, ne data pole

function Checksum(const Data; Count: Integer): Cardinal;
var
  P: PByte;
  I: Integer;
begin
  Result := 0;
  P := @Data;
  for I := 0 to Count - 1 do
  begin
    Result := Result + P^;
    Inc(P);
  end;
end;

procedure DigestCMS(const CMS: TBytes);
begin
  // Špatně: čte referenci na pole a cokoli, co za ní následuje na zásobníku.
  // Čtení 16 KB odtud přeběhne stack page a vyhodí access
  // violation, a Delphi a FPC selhávají na různých místech.
  Writeln(Checksum(CMS, Length(CMS)));

  // Správně: předejte první prvek, ohlídané pro prázdné pole
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

Zámek cache je SyncObjs.TCriticalSection místo API Windows InitializeCriticalSection, protože tutéž unit se staví i pod FPC na Linuxu, kde to API neexistuje

Co zůstává pomalé záměrně

Samotné odvození klíče R6 se mezi načteními nikdy necachuje a je to záměr. Externí bajty a explicitně zadané heslo vždycky procházejí skutečnou autentizační cestou, takže každé heslové načtení dokumentu R6 platí aspoň jeden hash Algorithm 2.B a obojí, hash ze strany uživatele i vlastníka, když se zkouší heslo vlastníka. Scope znovupoužití popsané výše odstraní duplikaci uvnitř jediného načtení a nic víc

  • Číslo 66 ms na 16 ms platí pro Delphi cíle s linkovaným nativním AES backendem. FPC buildy berou table-driven blok a FPC generování kódu na Windows běželo pod stejnými asserty 2 až 4krát pomaleji než Delphi
  • Ověřovací cache se váže na jednominutový časový kbelík, takže opakované kontroly stejných bajtů v krátkém okně jsou levné, zatímco cachovaný verdikt nikdy nepřežije minutu, ve které se spočítal
  • Autority cache sloučených revizí se implementovala, odladila a pak odstranila. Nezavřela FPC mezeru a znamenitě zpomalila Delphi bezpečnostní callbacky, což je dobrá připomínka měřit, než cache necháte
  • Ověření podpisu potřebuje původní šifrované bajty souboru, takže zdroj mějte dostupný. Rychlejší dešifrování ten požadavek neodstraní

FAQ

Oslabuje znovupoužití preskenovaného klíče kontrolu hesla?

Ne. HotPDF znovu používá obnovený souborový klíč jen uvnitř téhož volání heslového overloadu, které zvalidovalo heslo plnými kontrolami Algorithm 2.A, a scope příznak se maže v bloku finally. Samostatné volání DecryptLoadedDocument vždycky provede kompletní odvození, takže klíč zbylý z dřívějšího otevření se nepůjčí se špatným heslem

Proč necachovat výsledek R6 hashe mezi načteními?

Cachování hashe mezi načteními by znamenalo přijmout heslo bez zkontrolování proti otevíranému souboru. HotPDF bere každý externí bajtový zdroj a každé explicitní heslo jako nedůvěryhodný vstup, který se musí autentizovat sám, takže měřitelné zrychlení přišlo z toho, že se Algorithm 2.B zrychlil, ne z jeho vynechání

Mám předávat TFileStream do LoadFromStream?

Pro soubory na disku preferujte LoadFromFile, protože bufferuje interně sám. Když potřebujete stream overload, předejte TMemoryStream nebo TBufferedFileStream, protože HotPDF čte dodaný stream přímo během prvního strukturálního průchodu a holý handle stream stojí systémové volání za každé malé čtení

Načítání chráněné heslem, šifrování AES-256 R6, ověřování CMS podpisů i zbytek API načteného dokumentu dodává najednou jedna nativní VCL knihovna pro Delphi a C++Builder. Detaily funkcí a podporované verze IDE jsou na stránce produktu HotPDF Component