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
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'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
69c4e0d86a7b0430d8cdb78070b4c55afor nøglen000102…0fog plaintext00112233…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
-Crog-Corejser Delphi-agtige eksplicitteByte()-konverteringer og byte-bredde shift-aritmetik range- eller overflow-exceptions. Tabelindekser og GF(2^8)-fordoblingen beregnes nu ved native integer-bredde og maskeres medand $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 markeretinlineunder FPC. At fjerneinlinefiksede 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
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