Teknisk artikel

Signerade AES-256-PDF:er: varför de laddas långsamt i Delphi

HotPDF laddar signerade, AES-256-krypterade PDF:er i Delphi mycket snabbare än förr, eftersom tre separata kostnader togs bort från laddningsvägen: obuffrad token-parsning av källströmmen, ett andra varv av ISO 32000-2 Algorithm 2.A-lösenordskontrollen under dekrypteringen, och en långsam inre AES-128-CBC-loop inuti R6 hardened hash. På Delphi-mål tog R6-fixen ensam en dokumentladdning från omkring 66 ms till 16 ms

Inget av det här syntes på de små testfilerna de flesta sviter använder. Ett osignerat, okrypterat exempel på en eller två kilobyte öppnas på några hundradelar av en sekund vad parsaren än gör. Trubblen börjar med dokumenten kunder faktiskt skickar: ett avtal med en inbäddad CMS-signatur, omkring 35 KB, skyddat med ett revision 6-lösenord. Den kombinationen slår på alla dyra kodvägar samtidigt, och var och en av dem hade sitt eget skäl att vara långsammare än den behövde vara. Den här artikeln går igenom dem i den ordning de hittades, tillsammans med cache-nyckelmisstagen som dök upp när signaturverifieringsresultat började memoizeras

Varför tar en 34 KB signerad PDF 170 ms att ladda?

En 34 KB signerad PDF tog 170 ms att ladda eftersom parsaren läste en naken THandleStream direkt, och varje token-byte blev sitt eget ReadFile-systemanrop. En PDF-tokenizer avancerar en byte i taget medan den letar avgränsare, så en obuffrad handle-ström omvandlar en billig inminnesskanning till tusentals kärnövergångar

Detaljerna förklarar varför buggen överlevde. THotPDF.LoadFromFile har länge wrappat sin filkälla i en intern läsbuffer, och AutoDecrypt-probet hade redan fixats av samma skäl efter att det spenderat 1,4 s på att fråga om storleken på en 56 MB-fil. Signerade och krypterade dokument tar en annan väg däremot. För att säkerställa att lata objektläsningar och signaturgranskning alltid ser samma oföränderliga revision, även efter att anroparen frigör sin egen ström, tar THotPDF.LoadFromStream en ögonblicksbild av källan och parsar den en andra gång. Det andra parsvarvet, liksom återanvändningsladdningen som används för autentiserade revisioner, läste ögonblicksbildshandtaget utan buffer framför. Båda går nu genom samma interna buffrade läsare som filvägen använder

Laddningspipelinen för signerade AES-256-krypterade PDF:er i HotPDF i Delphi: ögonblicksbildsström, buffrad strukturanalys, Algorithm 2.A-förskanning, en enda dekryptering som återanvänder den återvunna nyckeln, och en R6 hardened hash som anropar nativ AES-128-CBC, vilket sänker en laddning från 66 ms till 16 ms
De tre fixarna staplas på en väg: buffring tar bort tusentals systemanrop, den förskannade nyckeln återanvänds under en scope-flagga, och R6:s inre loop når den nativa CBC-backend

Den buffern är intern, så du skapar den aldrig själv. Det du styr är strömmen du ger till LoadFromStream, och HotPDF läser den strömmen som den är under första strukturarvarvet. Är datan redan i minnet, skicka en TMemoryStream. Ligger den på disk, anropa LoadFromFile, eller på Delphi 10.1 Berlin och senare ge laddaren en TBufferedFileStream i stället för 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);   // en bulk-läsning i stället för läsningar per token
    Mem.Position := 0;
    // LoadFromStream returnerar sidantalet, 0 när dokumentet inte laddades
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

Att frigöra minnesströmmen direkt efter laddningen är säkert för signerad och krypterad indata, eftersom komponenten redan håller sin egen ögonblicksbild av bytena. Behåll originalfilen eller buffern ändå om du planerar att verifiera signaturer efteråt, eftersom verifieringen hasherar filens råa /ByteRange-segment i stället för den dekrypterade objektmodellen

Att mäta laddning och verifiering som separata faser

Laddningstid och signaturverifieringstid bör mätas separat, eftersom de misslyckas på olika sätt och fixas på olika ställen. Laddningsfasen täcker parsning, lösenordskontrollen och dekrypteringen. Verifieringsfasen hasherar om de signerade byteintervallen och kontrollerar CMS-signaturen. Slår du ihop dem till ett tal gömmer sig en regression i den ena inne i den andra

RTL:s TStopwatch från System.Diagnostics räcker för det. Harnessen nedan laddar med lösenordsoverloaden av THotPDF.LoadFromFile och verifierar sedan första signaturen genom TStream-overloaden av VerifyLoadedSignature. Ström-overloaden är rätt val för en krypterad fil, eftersom lösenordsladdningsvägen inte behåller något källfilnamn åt de parameterlösa overloaderna att återöppna

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 flera gånger och ignorera första körningen. Första anropet betalar engångskostnader, inklusive att bygga AES-tabeller och köra deras självtest, och diskcachning skevar också första läsningen. Det du letar efter är steady-state-talet för en riktig kundfil, mätt mot en osignerad, okrypterad kopia av samma dokument

Varför härleddes AES-256-lösenordet två gånger?

HotPDF härledde AES-256-filnyckeln två gånger eftersom två steg av samma laddning var och en körde hela lösenordskontrollen på egen hand. Före strukturarvarvet förskannar RecoverReloadKeysFromStreamRanges de råa bytena så att krypterade objektströmcontainrar kan dekrypteras medan filter avkodas. Den förskanningen utför redan ISO 32000-2 §7.6.4.3.3 Algorithm 2.A-kontrollerna, validerar lösenordet mot användar- och ägarsalterna och återvinner filkrypteringsnyckeln. DecryptLoadedDocument ignorerade sedan det resultatet och gjorde hela härledningen igen

För revision 4 och äldre kostade dubbletten knappt mer än en MD5-loop. För revision 6 anropar Algorithm 2.A Algorithm 2.B hardened hash, så dubbletten fördubblade den dyraste delen av att öppna filen. Att återanvända den återvunna nyckeln är enkelt. Att återanvända den säkert är den svårare delen, eftersom en cachad nyckel som överlever sin egen lösenordskontroll förvandlas till en autentiseringsförbigåelse

Fixen begränsar återanvändningen med en scope-flagga som är sann bara mellan förskanningen och dekrypteringsanropet inne i lösenordsoverloaderna av LoadFromFile och LoadFromStream, och som rensas i ett finally-block. Flaggan sätts bara när förskanningen faktiskt validerade lösenordet. Ett direkt anrop till DecryptLoadedDocument efter en vanlig laddning kör alltid hela härledningen, för att det lösenordet aldrig förskannades. Utan den begränsningen kunde en inaktuell återläsningsnyckel från en tidigare lyckad öppning tyst dekryptera dokumentet åt en senare anropare som skickade fel lösenord, och det är en betydligt värre bugg än en långsam laddning

Varför spenderar R6 hardened hash sin tid?

R6 hardened hash spenderar nästan all sin tid på AES-128-CBC-kryptering. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B upprepar en ronda minst 64 gånger. Varje ronda bygger en buffer med 64 kopior av lösenordet, aktuella hash och, på ägarsidan, strängen /U, krypterar den buffern med AES-128-CBC med de första 16 bytena av hashen som nyckel och nästa 16 som IV, och väljer SHA-256, SHA-384 eller SHA-512 för nästa hash utifrån de första 16 bytena av chiffrertexten lästa som ett tal modulo 3. Hasharna är korta. Buffrarna som krypteras är flera kilobyte per ronda

Loopdiagram för HotPDF R6 Algorithm 2.B hardened hash i Delphi: var och en av minst 64 ronder bygger en buffer med 64 kopior av lösenordet, hashen och /U-strängen, krypterar den med AES-128-CBC med hashen som nyckel och IV, och väljer SHA-256, SHA-384 eller SHA-512 från de första chiffrertextbytena
R6-kostnaden bor i AES-128-CBC-bufferkrypteringen, inte i digesterna, vilket är varför en nativ CBC-rutin inuti PDF20HashDanceR6 sänker en fullständig dokumentladdning från omkring 66 ms till 16 ms

HotPDF:s implementation av den loopen, PDF20HashDanceR6, brukade kryptera buffern ett block i taget genom en byteorienterad referens-AES. Den försöker nu först en nativ utfyllnadsfri CBC-rutin, som på Delphi-mål når den länkade C-AES-backend. Med den ändringen sjönk en fullständig dokumentladdning från omkring 66 ms till 16 ms. Är den nativa rutinen otillgänglig eller returnerar en längd som inte stämmer, faller koden tillbaka till den historiska blockloopen, och den loopen kan i sin tur använda en tabelldriven AES-128-block vars T-tabeller genereras från den befintliga S-boxen. Chiffret är samma i varje fall, så den härledda nyckeln är byte-identisk oavsett vilken väg som beräknade den

Den här artikeln handlar om fart. Att få Algorithm 2.B:s avslutsregel rätt är ett separat korrekthetsproblem, och ett off-by-one där avvisar korrekta lösenord bara ibland. Konfigurationssidan av R5 mot R6 täcks i guiden till AES-256 PDF-krypteringskonfiguration med HotPDF

Fyra sätt en snabb AES-block kan ljuga för dig

En tabelldriven AES-block kan producera fel chiffrertext och ändå passera sitt eget självtest. Var och en av de fyra fällorna nedan dök upp medan snabbvägen byggdes, och två av dem kom förbi självtestet som det först designades

  • Självtestets re-entrans. Beredskapsrutinen sätter sin initialiserad-flagga innan den kör självtestet, så ett re-entränt anrop gjort under testet tar referensvägen. Testets första version jämförde därför referensimplementationen med sig själv. Fixen är ett obevakat rått snabbblock som självtestet anropar direkt, så det är tabelloppet som faktiskt testas
  • Bytta tabellayouter. Att byta byteordning på två av de fyra rondtabellerna (Te1 och Te3) ger fel chiffrertext som det re-enträna självtestet inte kunde notera. Det som fångade det var ett fristående probprogram som skrev ut FIPS-197 Appendix C.1-chiffrertexten 69c4e0d86a7b0430d8cdb78070b4c55a för nyckel 000102…0f och klartext 00112233…ff. Självtestet kräver nu både den vektorn och byte-jämförelse med referensblocket innan det litar på snabbvägen
  • FPC-intervall- och spillkontroller. Under Free Pascal med -Cr och -Co kastar Delphi-stilens explicita Byte()-konverteringar och byte-bredda skiftaritmetik intervall- eller spillundantag. Tabellindices och GF(2^8)-fördubblingen beräknas nu vid nativ heltalsbredd och maskas med and $FF
  • FPC-inliners felkompilering. Små big-endian-läs- och skrivhjälpare som tar PByte-parametrar kompilerades till access violations när de märktes inline under FPC. Att ta bort inline fixade dem, och anropskostnaden är försumbar jämfört med tabelluppslagen

Samma erfarenhet ligger bakom den slutliga kabeldragningen. Att dirigera varje AES-128-block i biblioteket genom snabbtabellerna orsakade en deterministisk FPC-access violation under en publiceringskandidat-laddning, så den ändringen rullades tillbaka. Den levererade designen är konservativ: nativ CBC först, en explicit snabbtabellgren inne i R6-loopen, och referensblocket överallt annars. Den allmänna lärdomen är att ny kryptografisk kod som måste byggas under flera kompilatorer behöver ett oberoende known-answer-prob som körs på varje målkompilator. Ett DLL-nivå-testfel kan inte tala om ifall du har fel tabell eller en krasch. Mer om Free Pascal-sidan i anteckningarna om HotPDF Deflate- och AES-backends under Free Pascal

Hur cachar man signaturverifiering utan att cacha fel svar?

En signaturverifieringscache är bara säker om dess nyckel täcker varje indata som kan ändra domslutet, inklusive indatan som lever utanför de signerade byteintervallen. HotPDF:s headless-dokumentlager memoizerar CMS-verifieringsresultat genom att hasha CMS-bytena, bytena i signaturordboken i /Contents-gapet, innehållet i de täckta intervallen, ett policy-fingeravtryck, verifieringsbudgeterna, den totala källstorleken och en minutstidsbucket. Tre av de indatan lades till bara efter att en bugg visade att de saknades

Indata till cache-nyckeln för signaturverifiering i HotPDF för Delphi: CMS-byte, signaturordboken i /Contents-gapet, täckta intervallens innehåll, policy-fingeravtryck, budgeter, total källstorlek och en minutstidsbucket hashas tillsammans innan ett CMS-domslut memoizeras
Ett domslut kan bara återanvändas när varje indata som kunde ändra det finns i nyckeln - källstorleken spelar roll eftersom en basrevision och dess inkrementella uppdatering kan dela identiska täckta intervall

Betrodda certifikatfiler nycklas på innehåll, inte på sökväg. Operationslagret skriver samma PEM-material till ett nytt temporärt filnamn för varje kommando, så en sökvägsbaserad nyckel matchade aldrig och cachen träffade aldrig. Den totala källstorleken behövde vara en del av nyckeln eftersom den rapporterade UnsignedTrailingBytes beror på den: en basrevision och en senare inkrementell uppdatering kan dela exakt samma täckta innehåll samtidigt som de har olika avslutande längder, och en cache nycklad bara på intervallen skulle ge basrevisionens domslut till den uppdaterade filen. Bakgrunden till vad de avslutande bytena betyder finns i HotPDF-guiden till att verifiera PDF-digitala signaturer i Delphi

Den tredje buggen var en Pascal-språkfälla, inte ett designmisstag. Hashrutiner tar en otypad const-buffer, och att skicka en dynamisk arrayvariabel till en otypad parameter skickar adressen till arrayreferensen, inte arraydatan

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
  // Fel: läser arrayreferensen och allt som följer den på stacken.
  // En läsning på 16 KB därifrån springer av stacksidan och kastar ett
  // access violation, och Delphi och FPC misslyckas på olika punkter.
  Writeln(Checksum(CMS, Length(CMS)));

  // Rätt: skicka första elementet, med bevakning för tom array
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

Cachelåset är SyncObjs.TCriticalSection i stället för Windows-API:et InitializeCriticalSection, eftersom samma unit också bygger under FPC på Linux, där det API:et inte finns

Vad som förblir långsamt med flit

R6-nyckelhärledningen i sig cachas aldrig över laddningar, och det är med flit. Externa byte och ett explicit skickat lösenord går alltid genom den riktiga autentiseringsvägen, så varje lösenordsladdning av ett R6-dokument betalar för minst en Algorithm 2.B-hash, och både en användar- och en ägarhash när ägarlösenordet provas. Den avgränsade återanvändningen som beskrivits ovan tar bort dubbletten inom en enda laddning och inget utöver det

  • Siffran 66 ms till 16 ms gäller Delphi-mål med den nativa AES-backend länkad. FPC-byggen tar det tabelldrivna blocket, och FPC-kodgenerering på Windows körde 2 till 4 gånger långsammare än Delphi under samma assertioner
  • Verifieringscachen binder till en minutstidsbucket, så upprepade kontroller av samma byte inom ett kort fönster är billiga, medan ett cachat domslut aldrig överlever minuten det beräknades i
  • En sammanslagningsrevisions-auktoritetscache implementerades, avlusades och togs sedan bort. Den stängde inte FPC-gapet och gjorde Delphi-säkerhetscallbacks mätbart långsammare, vilket är en god påminnelse att mäta innan man behåller en cache
  • Signaturverifiering behöver originalfilens krypterade byte, så håll källan tillgänglig. Snabbare dekryptering gör ingenting åt det kravet

FAQ

Försvagar återanvändning av den förskannade nyckeln lösenordskontrollen?

Nej. HotPDF återanvänder den återvunna filnyckeln bara inom samma lösenordsoverload-anrop som validerade lösenordet med de fullständiga Algorithm 2.A-kontrollerna, och scope-flaggan rensas i ett finally-block. Ett fristående DecryptLoadedDocument-anrop utför alltid den kompletta härledningen, så en nyckel kvar från en tidigare öppning kan inte lånas med fel lösenord

Varför inte cacha R6-hashresultatet mellan laddningar?

Att cacha hashen mellan laddningar skulle betyda att acceptera ett lösenord utan att kontrollera det mot filen som öppnas. HotPDF behandlar varje extern bytekälla och varje explicit lösenord som obetrodd indata som måste autentisera sig själv, så den mätbara fartvinsten kom från att göra Algorithm 2.B snabbare i stället för att skippa den

Ska jag skicka en TFileStream till LoadFromStream?

Föredra LoadFromFile för filer på disk, eftersom den redan buffrar internt. När du behöver ström-overloaden, skicka en TMemoryStream eller en TBufferedFileStream, eftersom HotPDF läser strömmen du levererar direkt under första strukturarvarvet och en rå handle-ström kostar ett systemanrop per liten läsning

Lösenordsskyddad laddning, AES-256 R6-kryptering, CMS-signaturverifiering och resten av API:et för laddade dokument levereras tillsammans i ett nativt VCL-bibliotek för Delphi och C++Builder. Funktionsdetaljer och stödda IDE-versioner finns på produktsidan för HotPDF Component