Technisch artikel

Ondertekende AES-256 PDFs traag laden in Delphi: de fixes

HotPDF laadt in Delphi ondertekende, met AES-256 versleutelde PDFs veel sneller dan voorheen, omdat drie aparte kosten van het laadpad zijn afgehaald: ongebufferd token-parsen van de bronstream, een tweede ronde van de ISO 32000-2 Algorithm 2.A-wachtwoordcontrole tijdens decryptie, en een trage innerlijke AES-128-CBC-lus in de R6 hardened hash. Op Delphi-doelen haalde de R6-fix alleen al een documentlaad van zo'n 66 ms naar 16 ms

Geen van die kosten verscheen op de kleine testbestanden die de meeste suites gebruiken. Een niet-ondertekend, niet-versleuteld staaltje van een of twee kilobyte opent in een paar honderdsten van een seconde wat de parser ook doet. De problemen beginnen bij de documenten die klanten werkelijk sturen: een contract met een ingebedde CMS-handtekening, zo'n 35 KB, beveiligd met een wachtwoord van revisie 6. Die combinatie zet elk dure codepad tegelijk aan, en elk had zijn eigen reden om trager te zijn dan nodig. Dit artikel loopt ze langs in de volgorde waarin ze werden gevonden, samen met de cache-key-fouten die opdoken toen resultaten van handtekeningverificatie begonnen te worden gememoïseerd

Waarom duurt het laden van een ondertekende PDF van 34 KB 170 ms?

Een ondertekende PDF van 34 KB duurde 170 ms om te laden, omdat de parser een kale THandleStream rechtstreeks las en elke tokenbyte zijn eigen ReadFile-systeemaanroep werd. Een PDF-tokenizer schuift één byte per keer op terwijl hij naar scheidingstekens zoekt, dus een ongebufferde handle stream maakt van een goedkope scan in geheugen duizenden kernelovergangen

De details verklaren waarom de bug overleefde. THotPDF.LoadFromFile pakt zijn bestandsbron al lang in een interne leesbuffer, en de AutoDecrypt-probe was om dezelfde reden al gerepareerd nadat hij 1.4 s verspilde aan het herhaaldelijk opvragen van de grootte van een bestand van 56 MB. Ondertekende en versleutelde documenten nemen een andere route. Om te garanderen dat lazy object reads en handtekeninginspectie altijd dezelfde onveranderlijke revisie zien, ook nadat de aanroeper zijn eigen stream vrijgeeft, neemt THotPDF.LoadFromStream een snapshot van de bron en parst die een tweede keer. Die tweede pars, samen met de reuse load voor geauthenticeerde revisies, las de snapshot-handle zonder buffer ervoor. Beide gaan nu door dezelfde interne gebufferde lezer die het bestandspad gebruikt

Laadpipeline van HotPDF voor ondertekende met AES-256 versleutelde PDFs in Delphi: snapshot-stream, gebufferde structurele pars, Algorithm 2.A pre-scan, één decryptie die de herstelde sleutel hergebruikt, en een R6 hardened hash die native AES-128-CBC aanroept, wat een laad van 66 ms naar 16 ms brengt
De drie fixes stapelen op één pad: buffering haalt duizenden systeemaanroepen weg, de pre-gescande sleutel wordt hergebruikt onder een scope-vlag, en de R6 binnenlus bereikt de native CBC-backend

Die buffer is intern, dus u maakt hem nooit zelf aan. Wat u beheerst is de stream die u aan LoadFromStream geeft, en HotPDF leest die stream zoals hij is tijdens de eerste structurele pas. Zit de data al in het geheugen, geef dan een TMemoryStream mee. Staat hij op schijf, roep dan LoadFromFile aan, of geef de loader vanaf Delphi 10.1 Berlin een TBufferedFileStream in plaats van een kale 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);   // één bulklezing in plaats van lezingen per token
    Mem.Position := 0;
    // LoadFromStream geeft het paginaaantal terug, 0 wanneer het document niet laadde
    if Result.LoadFromStream(Mem, Password) < 1 then
      raise Exception.Create('Could not load ' + FileName);
  except
    Result.Free;
    Mem.Free;
    raise;
  end;
  Mem.Free;
end;

De memorystream vlak na de laad vrijgeven is veilig voor ondertekende en versleutelde invoer, want de component houdt al zijn eigen snapshot van de bytes vast. Bewaar het originele bestand of de buffer toch als u daarna handtekeningen wilt verifiëren, want verificatie hasht de rauwe /ByteRange-segmenten van het bestand in plaats van het ontsleutelde objectmodel

Laden en verificatie als aparte fasen meten

Laadtijd en handtekeningverificatietijd horen apart gemeten te worden, omdat ze op verschillende manieren falen en op verschillende plekken worden gerepareerd. De laadfase omvat parsen, de wachtwoordcontrole en decryptie. De verificatiefase hasht de ondertekende bytebereiken opnieuw en controleert de CMS-handtekening. Vouw ze samen tot één getal, dan verbergt een regressie in de een zich in de ander

De RTL-TStopwatch uit System.Diagnostics is daarvoor genoeg. De harness hieronder laadt met de wachtwoordoverload van THotPDF.LoadFromFile en verifieert daarna de eerste handtekening via de TStream-overload van VerifyLoadedSignature. De streamoverload is de juiste keuze voor een versleuteld bestand, want het wachtwoordlaadpad houdt geen bronbestandsnaam vast voor de parameterloze overloads om te heropenen

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;

Draai hem meerdere keren en negeer de eerste run. De eerste aanroep betaalt eenmalige opzetkosten, waaronder het bouwen van AES-tabellen en het draaien van hun self-test, en schijfcaching vertekent ook de eerste lezing. Wat u zoekt is het steady-state getal voor een echt klantbestand, gemeten tegen een niet-ondertekende, niet-versleutelde kopie van hetzelfde document

Waarom werd het AES-256-wachtwoord twee keer afgeleid?

HotPDF leidde de AES-256-bestandssleutel twee keer af, omdat twee stadia van dezelfde laad elk op eigen houtje de volledige wachtwoordcontrole draaiden. Vóór de structurele pars scant RecoverReloadKeysFromStreamRanges de rauwe bytes vooraf, zodat versleutelde object stream-containers kunnen worden ontsleuteld terwijl filters worden gedecodeerd. Die pre-scan voert al de ISO 32000-2 §7.6.4.3.3 Algorithm 2.A-controles uit, valideert het wachtwoord tegen de user- en owner-salts en herstelt de bestandsversleutelingssleutel. DecryptLoadedDocument negeerde dat resultaat en deed de hele afleiding opnieuw

Voor revisie 4 en ouder kostte de duplicaat weinig meer dan een MD5-lus. Voor revisie 6 roept Algorithm 2.A de hardened hash van Algorithm 2.B aan, dus de duplicaat verdubbelde het duurste deel van het openen van het bestand. De herstelde sleutel hergebruiken is simpel. Hem veilig hergebruiken is het lastige deel, want een gecachte sleutel die zijn eigen wachtwoordcontrole overleeft wordt een authentication bypass

De fix begrenst het hergebruik met een scope-vlag die alleen true is tussen de pre-scan en de decryptie-aanroep binnen de wachtwoordoverloads van LoadFromFile en LoadFromStream, en die in een finally-blok wordt gewist. De vlag wordt alleen gezet wanneer de pre-scan het wachtwoord werkelijk heeft gevalideerd. Een directe aanroep van DecryptLoadedDocument na een gewone laad draait altijd de volledige afleiding, want dat wachtwoord is nooit pre-gescand. Zonder die beperking zou een verlopen reload-sleutel van een eerdere geslaagde opening het document stilletjes ontsleutelen voor een latere aanroeper die het verkeerde wachtwoord opgaf, en dat is een veel ergere bug dan een trage laad

Waar gaat de tijd van de R6 hardened hash in zitten?

De R6 hardened hash steekt bijna al zijn tijd in AES-128-CBC-versleuteling. ISO 32000-2 §7.6.4.3.4 Algorithm 2.B herhaalt een ronde ten minste 64 keer. Elke ronde bouwt een buffer van 64 kopieën van het wachtwoord, de huidige hash en, aan de owner-kant, de /U-string, versleutelt die buffer met AES-128-CBC met de eerste 16 bytes van de hash als sleutel en de volgende 16 als IV, en kiest SHA-256, SHA-384 of SHA-512 voor de volgende hash uit de eerste 16 bytes van de ciphertext gelezen als een getal modulo 3. De hashes zijn kort. De buffers die worden versleuteld zijn enkele kilobytes per ronde

Lusdiagram van de HotPDF R6 Algorithm 2.B hardened hash voor Delphi: elke ronde van ten minste 64 bouwt een buffer van 64 kopieën van wachtwoord, hash en /U-string, versleutelt die met AES-128-CBC met hash als sleutel en IV, en kiest SHA-256, SHA-384 of SHA-512 uit de eerste ciphertext-bytes
De R6-kost zit in de AES-128-CBC-bufferversleuteling, niet in de digests, en daarom haalt een native CBC-routine binnen PDF20HashDanceR6 een volledige documentlaad van zo'n 66 ms naar 16 ms

De implementatie van die lus in HotPDF, PDF20HashDanceR6, versleutelde de buffer vroeger blok voor blok via een byte-georiënteerde referentie-AES. Hij probeert nu eerst een native CBC-routine zonder padding, die op Delphi-doelen de gelinkte C AES-backend bereikt. Met die verandering zakte een volledige documentlaad van zo'n 66 ms naar 16 ms. Is de native routine niet beschikbaar of geeft hij een niet-matchende lengte terug, dan valt de code terug op de historische bloklus, en die lus kan op zijn beurt een table-driven AES-128-blok gebruiken waarvan de T-tabellen uit de bestaande S-box worden gegenereerd. Het cijfer is in elk geval hetzelfde, dus de afgeleide sleutel is byte-identiek ongeacht welk pad hem berekende

Dit artikel gaat over snelheid. De terminatieregel van Algorithm 2.B goed krijgen is een apart correctheidsprobleem, en een off-by-one daar verwerpt correcte wachtwoorden maar soms. De configuratiekant van R5 tegenover R6 wordt behandeld in de gids voor AES-256 PDF-versleuteling met HotPDF

Vier manieren waarop een snelle AES-blok u kan bedriegen

Een table-driven AES-blok kan verkeerde ciphertext opleveren en toch door zijn eigen self-test komen. Alle vier de valkuilen hieronder kwamen boven bij het bouwen van het snelle pad, en twee van hen kwamen langs de self-test zoals die eerst was ontworpen

  • Self-test re-entrantie. De readiness-routine zet zijn initialized-vlag vóór het draaien van de self-test, dus een re-entrante aanroep tijdens de test neemt het referentiepad. De eerste versie van de test vergeleek de referentie-implementatie dus met zichzelf. De fix is een onbewaakt rauw snel blok dat de self-test rechtstreeks aanroept, zodat het tabelpad de code is die werkelijk wordt getest
  • Gewisselde tabellay-outs. De bytevolgorde van twee van de vier rondetabellen (Te1 en Te3) omwisselen levert verkeerde ciphertext die de re-entrante self-test op geen enkele manier kon opmerken. Wat hem ving was een standalone probe-programma dat de FIPS-197 Appendix C.1-ciphertext 69c4e0d86a7b0430d8cdb78070b4c55a printte voor sleutel 000102…0f en plaintext 00112233…ff. De self-test eist nu zowel die vector als byte-gelijkheid met het referentieblok voordat hij het snelle pad vertrouwt
  • FPC range- en overflowchecks. Onder Free Pascal met -Cr en -Co gooien Delphi-stijl expliciete Byte()-conversies en shiften op bytbreedte range- of overflowexceptions. Tabelindices en de GF(2^8)-verdubbeling worden nu op native integerbreedte berekend en gemaskeerd met and $FF
  • FPC-inliner-miscompilatie. Kleine big-endian load- en store-helpers die PByte-parameters nemen werden gecompileerd tot access violations wanneer ze onder FPC inline waren gemarkeerd. inline weghalen repareerde ze, en de kosten van de aanroep zijn verwaarloosbaar naast de tabelopzoekingen

Dezelfde ervaring zit achter de uiteindelijke bedrading. Elke AES-128-blok in de library door de snelle tabellen leiden veroorzaakte een deterministische FPC access violation tijdens één publicatiekandidaat-laad, dus die verandering is teruggedraaid. Het uitgegeven ontwerp is conservatief: eerst native CBC, een expliciete fast-table-tak binnen de R6-lus, en het referentieblok overal elders. De algemene les is dat nieuwe cryptografische code die onder meerdere compilers moet bouwen een onafhankelijke known-answer-probe nodig heeft die op elke doelcompiler wordt gedraaid. Een testmislukking op DLL-niveau kan u niet vertellen of u een verkeerde tabel hebt of een crash. Meer over de Free Pascal-kant in de notities over HotPDF Deflate- en AES-backends onder Free Pascal

Hoe cachet u handtekeningverificatie zonder het verkeerde antwoord te cachen?

Een handtekeningverificatiecache is alleen veilig als zijn sleutel elke invoer dekt die de uitspraak kan veranderen, inclusief invoer die buiten de ondertekende bytebereiken leeft. De headless documentlaag van HotPDF memoïseert CMS-verificatieresultaten door de CMS-bytes, de bytes van de signature dictionary in de /Contents-gap, de inhoud van de gedekte bereiken, een policy-vingerafdruk, de verificatiebudgetten, de totale bronomvang en een tijdbak van één minuut te hashen. Drie van die invoeren zijn pas toegevoegd nadat een bug liet zien dat ze ontbraken

Sleutelinvoer van de HotPDF-handtekeningverificatiecache voor Delphi: CMS-bytes, de signature dictionary in de /Contents-gap, inhoud van gedekte bereiken, policy-vingerafdruk, budgets, totale bronomvang en een tijdbak van één minuut worden samen gehasht voordat een CMS-uitspraak wordt gememoïseerd
Een uitspraak kan alleen worden hergebruikt wanneer elke invoer die hem kon veranderen in de sleutel zit - de bronomvang telt mee want een basisrevisie en zijn incrementele update kunnen identieke gedekte bereiken delen

Vertrouwde certificaatbestanden worden op inhoud gesleuteld, niet op pad. De operatielaag schrijft hetzelfde PEM-materiaal voor elk commando naar een verse tijdelijke bestandsnaam, dus een padgebaseerde sleutel matchte nooit en de cache sloeg nooit aan. De totale bronomvang moest deel van de sleutel uitmaken omdat de gemelde UnsignedTrailingBytes ervan afhangt: een basisrevisie en een latere incrementele update kunnen exact dezelfde gedekte inhoud delen terwijl hun achterste lengtes verschillen, en een cache alleen op de bereiken gesleuteld zou het oordeel van de basisrevisie aan het bijgewerkte bestand geven. De achtergrond van wat die achterste bytes betekenen staat in de HotPDF-gids voor het verifiëren van PDF-digitale handtekeningen in Delphi

De derde bug was een Pascal-taalvalkuil, geen ontwerpfout. Hash-routines nemen een ongetypeerde const-buffer, en een dynamische arrayvariabele aan een ongetypeerde parameter geven geeft het adres van de arrayreferentie, niet de arraydata

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
  // Fout: leest de arrayreferentie en wat er verderop op de stack volgt.
  // Een lezing van 16 KB vanaf daar loopt van de stackpagina af en gooit een access
  // violation, en Delphi en FPC falen op verschillende punten.
  Writeln(Checksum(CMS, Length(CMS)));

  // Goed: geef het eerste element mee, bewaakt voor de lege array
  if Length(CMS) > 0 then
    Writeln(Checksum(CMS[0], Length(CMS)));
end;

De cachelock is SyncObjs.TCriticalSection in plaats van de Windows-API InitializeCriticalSection, want dezelfde unit bouwt ook onder FPC op Linux, waar die API niet bestaat

Wat blijft met opzet traag

De R6-sleutelafleiding zelf wordt nooit over loads heen gecached, en dat is bewust. Externe bytes en een expliciet opgegeven wachtwoord gaan altijd door het echte authenticatiepad, dus elke wachtwoordlaad van een R6-document betaalt voor ten minste één Algorithm 2.B-hash, en zowel een hash aan de user-kant als een aan de owner-kant wanneer het owner-wachtwoord wordt geprobeerd. Het hierboven beschreven begrensde hergebruik haalt de duplicaat binnen één laad weg en niets verder

  • Het getal van 66 ms naar 16 ms geldt voor Delphi-doelen met de native AES-backend gelinkt. FPC-builds nemen het table-driven blok, en FPC-codegeneratie op Windows draaide onder dezelfde assertions 2 tot 4 keer trager dan Delphi
  • De verificatiecache bindt aan een tijdbak van één minuut, dus herhaalde controles van dezelfde bytes binnen een kort venster zijn goedkoop, terwijl een gecached oordeel nooit de minuut overleeft waarin het werd berekend
  • Een authority-cache voor gemergde revisies werd geïmplementeerd, gedebugd en daarna verwijderd. Hij sloot de FPC-kloof niet en maakte Delphi-securitycallbacks meetbaar trager, een goede herinnering om te meten voordat u een cache houdt
  • Handtekeningverificatie heeft de originele versleutelde bestandsbytes nodig, dus houd de bron beschikbaar. Snellere decryptie doet niets af aan die eis

FAQ

Verzwakt het hergebruik van de pre-gescande sleutel de wachtwoordcontrole?

Nee. HotPDF hergebruikt de herstelde bestandssleutel alleen binnen dezelfde wachtwoordoverload-aanroep die het wachtwoord met de volledige Algorithm 2.A-controles valideerde, en de scope-vlag wordt in een finally-blok gewist. Een standalone aanroep van DecryptLoadedDocument voert altijd de complete afleiding uit, dus een sleutel die van een eerdere opening is overgebleven kan niet met een verkeerd wachtwoord worden geleend

Waarom het R6-hashresultaat niet tussen loads cachen?

De hash tussen loads cachen zou betekenen dat u een wachtwoord accepteert zonder het te controleren tegen het bestand dat wordt geopend. HotPDF behandelt elke externe bytebron en elk expliciet wachtwoord als onbetrouwbare invoer die op eigen kracht moet authenticeren, dus de meetbare versnelling kwam van Algorithm 2.B sneller maken in plaats van hem over te slaan

Moet ik een TFileStream aan LoadFromStream meegeven?

Geef de voorkeur aan LoadFromFile voor bestanden op schijf, want die buffert al intern. Heeft u de streamoverload nodig, geef dan een TMemoryStream of een TBufferedFileStream mee, want HotPDF leest de stream die u aanlevert rechtstreeks tijdens de eerste structurele pas en een kale handle stream kost een systeemaanroep per kleine lezing

Wachtwoordbeveiligd laden, AES-256 R6-versleuteling, CMS-handtekeningverificatie en de rest van de loaded-document API worden samen geleverd in één native VCL-library voor Delphi en C++Builder. Featuredetails en ondersteunde IDE-versies staan op de HotPDF Component-productpagina