Teknisk artikel

Hvorfor signede AES-256-PDF'er loader langsomt: HotPDF-fixes

HotPDF loader signede, AES-256-krypterede PDF-filer i Delphi meget hurtigere end før, fordi tre separate omkostninger blev fjernet fra load-stien: upufferet token-parsing af kildestreamen, en anden kørsel af ISO 32000-2 Algorithm 2.A-passwordtjekket under dekryptering og en langsom indre AES-128-CBC-løkke inde i den R6-hærdede hash. På Delphi-mål tog R6-fixet alene et dokumentload fra omkring 66 ms til 16 ms

Ingen af dem viste sig på de små testfiler, de fleste suiter bruger. En unsigned, ukrypteret prøve på en eller to kilobyte åbner på et par hundrededele af et sekund, uanset hvad parseren gør. Besværet starter med de dokumenter, kunder reelt sender: en kontrakt med en indlejret CMS-signatur, omkring 35 KB, beskyttet med en revision 6-adgangskode. Den kombination slår hver dyr kodepath til på én gang, og hver af dem havde sin egen grund til at være langsommere, end den behøvede. Denne artikel gennemgår dem i den rækkefølge, de blev fundet, sammen med de cache-nøgle-fejl, der dukkede op, da signaturverifikationsresultater begyndte at blive memoiseret

Hvorfor tager en 34 KB signeret PDF 170 ms at loade?

En 34 KB signeret PDF tog 170 ms at loade, fordi parseren læste en nøgen THandleStream direkte, og hver token-byte blev til sit eget ReadFile-systemkald. En PDF-tokenizer rykker én byte ad gangen, mens den leder efter skilletegn, så en upufferet handle stream omdanner et billigt in-memory-scan til tusindvis af kernetransitioner

Detaljerne forklarer, hvorfor fejlen overlevede. THotPDF.LoadFromFile har længe pakket sin filkilde ind i en intern læsebuffer, og AutoDecrypt-proben var allerede fikset af samme grund, efter den brugte 1,4 s på at gen-spørge størrelsen på en 56 MB-fil. Signerede og krypterede dokumenter tager dog en anden rute. For at sikre, at lazy objektlæsninger og signaturinspektion altid ser den samme uforanderlige revision, selv efter kalderen frigiver sin egen stream, tager THotPDF.LoadFromStream et snapshot af kilden og parser den en anden gang. Det andet parse, sammen med det reuse-load, der bruges til autentificerede revisioner, læste snapshot-håndtaget uden buffer foran. Begge går nu gennem den samme interne bufferede læser, som filstien bruger

HotPDF loadpipeline for signerede AES-256-krypterede PDF'er i Delphi: snapshot-stream, bufferet strukturelt parse, Algorithm 2.A pre-scan, enkel dekryptering der genbruger den fundne nøgle og en R6-hærdet hash, der kalder native AES-128-CBC, hvilket skærer et load fra 66 ms til 16 ms
De tre fixes stables på én sti: buffering fjerner tusindvis af systemkald, den pre-scannede nøgle genbruges under et scope-flag, og R6'ens indre løkke når native CBC-backend

Bufferen er intern, så du opretter den aldrig selv. Det, du styrer, er den stream, du giver til LoadFromStream, og HotPDF læser den stream, som den er, under det første strukturelle pass. Er dataene allerede i hukommelsen, så giv en TMemoryStream med. Ligger den på disk, så kald LoadFromFile, eller på Delphi 10.1 Berlin og senere giv loaderen en TBufferedFileStream frem for en rå 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);   // ét bulk-læsning i stedet for pr. token-læsninger
    Mem.Position := 0;
    // LoadFromStream returnerer sideantallet, 0 når dokumentet ikke loadede
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

At frigive memory streamen lige efter loadet er sikkert for signeret og krypteret input, fordi komponenten allerede holder sit eget snapshot af bytes. Behold dog den oprindelige fil eller buffer, hvis du planlægger at verificere signaturer bagefter, da verifikationen hasher filens rå /ByteRange-segmenter frem for den dekrypterede objektmodel

Måling af load og verifikation som separate faser

Loadtid og signaturverifikationstid bør måles separat, fordi de fejler på forskellige måder og fikses forskellige steder. Loadfasen dækker parsing, passwordtjekket og dekryptering. Verifikationsfasen rehasher de signede byte-intervaller og tjekker CMS-signaturen. Folder du dem sammen til ét tal, skjuler en regression i den ene sig inde i den anden

RTL'ens TStopwatch fra System.Diagnostics er nok hertil. Testopsætningen nedenfor loader med password-overloaden af THotPDF.LoadFromFile og verificerer derefter den første signatur gennem TStream-overloaden af VerifyLoadedSignature. Stream-overloaden er det rigtige valg til en krypteret fil, fordi password-loadstien ikke gemmer et kildefilnavn til de parameterløse overloads at genåbne

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;

Kør den flere gange og ignorér første kørsel. Første kald betaler engangsopsætningsomkostninger, herunder at bygge AES-tabeller og køre deres self-test, og disk-caching skævvrider også den første læsning. Det, du leder efter, er steady-state-tallet for en rigtig kundefil, målt op mod en unsigned, ukrypteret kopi af samme dokument

Hvorfor blev AES-256-adgangskoden afledt to gange?

HotPDF afledte AES-256-filnøglen to gange, fordi to stadier af samme load hver kørte det fulde passwordtjek på egen hånd. Før det strukturelle parse pre-scanner RecoverReloadKeysFromStreamRanges de rå bytes, så krypterede object stream-containere kan dekrypteres, mens filtre dekodes. Den pre-scan udfører allerede ISO 32000-2 §7.6.4.3.3 Algorithm 2.A-tjekkene, validerer adgangskoden mod bruger- og ejer-saltene og finder filkrypteringsnøglen. DecryptLoadedDocument ignorerede så det resultat og lavede hele afledningen igen

For revision 4 og ældre kostede duplikatet lidt mere end en MD5-løkke. For revision 6 kalder Algorithm 2.A den hærdede Algorithm 2.B-hash, så duplikatet fordoblede den dyreste del af at åbne filen. At genbruge den fundne nøgle er simpelt. At genbruge den sikkert er den svære del, fordi en cached nøgle, der overlever sit eget passwordtjek, bliver til en authentication bypass

Fixet begrænser genbrugen med et scope-flag, der kun er sandt mellem pre-scannen og decrypt-kaldet inde i password-overloadene af LoadFromFile og LoadFromStream og ryddes i en finally-blok. Flaget sættes kun, når pre-scannen reelt validerede adgangskoden. Et direkte kald til DecryptLoadedDocument efter et almindeligt load kører altid hele afledningen, fordi den adgangskode aldrig blev pre-scannet. Uden den begrænsning kunne en forældet reload-nøgle fra en tidligere vellykket åbning stille dekryptere dokumentet for en senere kalder, der gav den forkerte adgangskode, og det er en langt værre fejl end et langsomt load

Hvor bruger den R6-hærdede hash sin tid?

Den R6-hærdede hash bruger næsten al sin tid på AES-128-CBC-kryptering. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B gentager en runde mindst 64 gange. Hver runde bygger en buffer af 64 kopier af adgangskoden, den aktuelle hash og, på ejersiden, /U-strengen, krypterer bufferen med AES-128-CBC med de første 16 bytes af hashen som nøgle og de næste 16 som IV og vælger SHA-256, SHA-384 eller SHA-512 til næste hash ud fra de første 16 bytes af ciphertexten læst som et tal modulo 3. Hasherne er korte. De buffere, der krypteres, er flere kilobytes pr. runde

HotPDF R6 Algorithm 2.B-hærdet hash-løkkediagram til Delphi: hver af mindst 64 runder bygger en buffer af 64 kopier af adgangskoden, hashen og /U-strengen, krypterer den med AES-128-CBC med hashen som nøgle og IV og vælger SHA-256, SHA-384 eller SHA-512 ud fra de første ciphertext-bytes
R6-omkostningen ligger i AES-128-CBC-bufferkrypteringen, ikke i digests, hvilket er grunden til, at en native CBC-rutine inde i PDF20HashDanceR6 sænker et fuldt dokumentload fra omkring 66 ms til 16 ms

HotPDF's implementering af den løkke, PDF20HashDanceR6, krypterede bufferen én blok ad gangen gennem en byte-orienteret reference-AES. Den prøver nu først en native no-padding CBC-rutine, som på Delphi-mål når den linkede C AES-backend. Med den ændring faldt et fuldt dokumentload fra omkring 66 ms til 16 ms. Er den native rutine utilgængelig eller returnerer en længde, der ikke matcher, falder koden tilbage til den historiske blokløkke, og den løkke kan til gengæld bruge en tabeldrevet AES-128-blok, hvis T-tabeller genereres ud fra den eksisterende S-boks. Cipheren er den samme i alle tilfælde, så den afledte nøgle er byte-identisk uanset hvilken sti, der beregnede den

Denne artikel handler om hastighed. At få Algorithm 2.B's termineringsregel rigtigt er et separat korrekthedsproblem, og en off-by-one dér afviser korrekte adgangskoder kun nogle gange. Konfigurationssiden af R5 versus R6 er dækket i guiden til AES-256 PDF-krypteringsopsætning med HotPDF

Fire måder en hurtig AES-blok kan lyve for dig på

En tabeldrevet AES-blok kan producere forkert ciphertext og stadig bestå sin egen self-test. Hver af de fire fælder nedenfor dukkede op under bygningen af den hurtige sti, og to af dem kom forbi self-testen, som den først var designet

  • Self-test re-entrancy. Klar-rutinen sætter sit initialized-flag, før den kører self-testen, så et re-entrant-kald foretaget under testen tager referencestien. Den første version af testen sammenlignede derfor referenceimplementeringen med sig selv. Fixet er en ubevogtet rå hurtig blok, som self-testen kalder direkte, så tabelstien er den kode, der reelt bliver testet
  • Ombyttede tabelayouts. At bytte byte-rækkefølgen af to af de fire rundetabeller (Te1 og Te3) producerer forkert ciphertext, som den re-entrante self-test på ingen måde kunne bemærke. Det, der fangede det, var et selvstændigt probe-program, der printede FIPS-197 tillæg C.1-ciphertexten 69c4e0d86a7b0430d8cdb78070b4c55a for nøglen 000102…0f og plaintext 00112233…ff. Self-testen kræver nu både den vektor og byte-lighed med referenceblokken, før den stoler på den hurtige sti
  • FPC range- og overflow-tjek. Under Free Pascal med -Cr og -Co rejser Delphi-agtige eksplicitte Byte()-konverteringer og byte-bredde shift-aritmetik range- eller overflow-exceptions. Tabelindekser og GF(2^8)-fordoblingen beregnes nu ved native integer-bredde og maskeres med and $FF
  • FPC inliner-fejlkompilering. Små big-endian load- og store-hjælpere, der tager PByte-parametre, blev kompileret til access violations, når de var markeret inline under FPC. At fjerne inline fiksede dem, og kaldets omkostning er ubetydelig ved siden af tabelopslagene

Samme erfaring ligger bag den endelige kobling. At sende hver AES-128-blok i biblioteket gennem de hurtige tabeller forårsagede en deterministisk FPC access violation under ét publiceringskandidatload, så den ændring blev tilbageført. Det udsendte design er konservativt: native CBC først, en eksplicit hurtig tabel-gren inde i R6-løkken og referenceblokken alle andre steder. Den generelle lektie er, at ny kryptografisk kode, der skal bygge under flere compilere, behøver et uafhængigt known-answer-probe kørt på hver målcompiler. En DLL-niveau-testfejl kan ikke fortælle dig, om du har en forkert tabel eller et crash. For mere om Free Pascal-siden, se noterne om HotPDF Deflate- og AES-backends under Free Pascal

Hvordan cacher man signaturverifikation uden at cache det forkerte svar?

En signaturverifikationscache er kun sikker, hvis dens nøgle dækker hvert input, der kan ændre dommen, inklusive input, der lever uden for de signede byte-intervaller. HotPDF's headless dokumentlag memoiserer CMS-verifikationsresultater ved at hash'e CMS-bytes, bytes af signaturdictionaryen i /Contents-gapet, indholdet af de dækkede intervaller, et policy-fingerprint, verifikationsbudgetterne, den samlede kildesstørrelse og en ét-minutters tidsbøtte. Tre af de input blev kun tilføjet, efter en fejl viste, at de manglede

HotPDF signaturverifikations-cache-nøgle-input til Delphi: CMS-bytes, signaturdictionaryen i /Contents-gapet, dækkede intervalindhold, policy-fingerprint, budgetter, samlet kildesstørrelse og en ét-minutters tidsbøtte hashes sammen, før en CMS-dom memoiseres
En dom kan kun genbruges, når hvert input, der kunne ændre den, er i nøglen - kildesstørrelsen betyder noget, fordi en basisrevision og dens incremental update kan dele identiske dækkede intervaller

Troværdige certifikatfiler nøgles efter indhold, ikke efter sti. Operationslaget skriver det samme PEM-materiale til et frisk midlertidigt filnavn for hver kommando, så en stibaseret nøgle aldrig matchede, og cachen aldrig ramte. Den samlede kildesstørrelse skulle være en del af nøglen, fordi den rapporterede UnsignedTrailingBytes afhænger af den: en basisrevision og en senere incremental update kan dele præcis det samme dækkede indhold, mens de har forskellige trailing-længder, og en cache nøglet kun på intervallerne ville give basisrevisionens dom til den opdaterede fil. Baggrunden for, hvad de trailing-bytes betyder, ligger i HotPDF-guiden til verifikation af PDF-digitale signaturer i Delphi

Den tredje fejl var en Pascal-sprogfælder, ikke en designfejl. Hash-rutiner tager en utypet const-buffer, og at give en dynamisk array-variabel med til en utypet parameter giver adressen på array-referencen, ikke array-dataene

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
  // Forkert: læser array-referencen og hvad end der følger efter den på stakken.
  // En 16 KB-læsning derfra løber ud over stack-siden og rejser en access
  // violation, og Delphi og FPC fejler på forskellige punkter.
  Writeln(Checksum(CMS, Length(CMS)));

  // Rigtigt: giv det første element med, bevogtet for det tomme array
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

Cache-låsen er SyncObjs.TCriticalSection frem for Windows' InitializeCriticalSection-API, fordi samme unit også bygger under FPC på Linux, hvor det API ikke findes

Hvad forbliver langsomt med vilje

Selve R6-nøgleafledningen caches aldrig hen over loads, og det er bevidst. Eksterne bytes og en eksplicit angivet adgangskode går altid gennem den rigtige autentificeringssti, så hvert password-load af et R6-dokument betaler for mindst én Algorithm 2.B-hash og både en bruger- og en ejer-side-hash, når ejeradgangskoden prøves. Den scope-afgrænsede genbrug beskrevet ovenfor fjerner duplikatet inde i et enkelt load og intet ud over det

  • Tallet 66 ms til 16 ms gælder Delphi-mål med den native AES-backend linket ind. FPC-builds tager den tabeldrevne blok, og FPC-kodegenerering på Windows kørte 2 til 4 gange langsommere end Delphi under samme assertions
  • Verifikationscachen binder sig til en ét-minutters tidsbøtte, så gentagne tjek af de samme bytes inden for et kort vindue er billige, mens en cached dom aldrig overlever minuttet, den blev beregnet i
  • En merged-revision authority-cache blev implementeret, debugget og derefter fjernet. Den lukkede ikke FPC-gabet og gjorde Delphi security callbacks målbart langsommere, hvilket er en god påmindelse om at måle, før man beholder en cache
  • Signaturverifikation behøver de oprindelige krypterede filbytes, så hold kilden tilgængelig. Hurtigere dekryptering gør intet for at fjerne det krav

FAQ

Svækker genbrug af den pre-scannede nøgle passwordtjekket?

Nej. HotPDF genbruger den fundne filnøgle kun inde i samme password-overload-kald, der validerede adgangskoden med de fulde Algorithm 2.A-tjek, og scope-flagget ryddes i en finally-blok. Et selvstændigt DecryptLoadedDocument-kald udfører altid den komplette afledning, så en nøgle tilbage fra en tidligere åbning kan ikke lånes med en forkert adgangskode

Hvorfor ikke cache R6-hash-resultatet mellem loads?

At cache hashen mellem loads ville betyde at acceptere en adgangskode uden at tjekke den mod filen, der åbnes. HotPDF behandler hver ekstern bytekilde og hver eksplicit adgangskode som utroværdigt input, der skal autentificere sig selv, så den målbare speedup kom fra at gøre Algorithm 2.B hurtigere frem for at springe den over

Skal jeg give en TFileStream med til LoadFromStream?

Foretræk LoadFromFile til filer på disk, fordi den allerede buffrer internt. Behøver du stream-overloaden, så giv en TMemoryStream eller en TBufferedFileStream med, da HotPDF læser den stream, du leverer, direkte under det første strukturelle pass, og en rå handle stream koster et systemkald pr. lille læsning

Password-beskyttet indlæsning, AES-256 R6-kryptering, CMS-signaturverifikation og resten af loaded-document-API'en følger sammen i ét native VCL-bibliotek til Delphi og C++Builder. Funktionsdetaljer og understøttede IDE-versioner findes på HotPDF Component-produktsiden