HotPDF lädt signierte, AES-256-verschlüsselte PDFs in Delphi deutlich schneller als früher, weil drei getrennte Kosten aus dem Ladepfad entfernt wurden: das ungepufferte Token-Parsing des Quellstreams, ein zweiter Durchlauf der ISO-32000-2-Algorithmus-2.A-Passwortprüfung bei der Entschlüsselung und eine langsame innere AES-128-CBC-Schleife innerhalb des R6-Hardened-Hash. Auf Delphi-Zielen senkte der R6-Fix allein ein Dokument-Laden von rund 66 ms auf 16 ms
Keine davon zeigte sich auf den kleinen Testdateien, die die meisten Suiten verwenden. Eine unsignierte, unverschlüsselte Probe von ein, zwei Kilobyte öffnet sich in wenigen Hundertstelsekunden, was auch immer der Parser tut. Der Ärger beginnt mit den Dokumenten, die Kunden tatsächlich schicken: ein Vertrag mit eingebetteter CMS-Signatur, rund 35 KB, geschützt mit einem Revision-6-Passwort. Diese Kombination schaltet jeden teuren Codepfad gleichzeitig ein, und jeder hatte seinen eigenen Grund, langsamer zu sein als nötig. Dieser Beitrag geht sie in der Reihenfolge durch, in der sie gefunden wurden, zusammen mit den Cache-Key-Fehlern, die auftauchten, als Signaturverifikationsergebnisse memoisiert zu werden begannen
Warum braucht ein 34-KB-signiertes PDF 170 ms zum Laden?
Ein 34-KB-signiertes PDF brauchte 170 ms zum Laden, weil der Parser direkt aus einem nackten THandleStream las und jedes Token-Byte seinen eigenen ReadFile-Systemaufruf wurde. Ein PDF-Tokenizer rückt ein Byte nach dem anderen vor, während er nach Delimitern sucht, also verwandelt ein ungepufferte Handle-Stream eine billige In-Memory-Prüfung in Tausende Kernel-Übergänge
Die Details erklären, warum der Bug überlebte. THotPDF.LoadFromFile hat seine Dateiquelle seit Langem in einen internen Lesepuffer gehüllt, und die AutoDecrypt-Sonde war nach demselben Grund bereits repariert worden, nachdem sie 1,4 s damit verbrachte, die Größe einer 56-MB-Datei erneut abzufragen. Signierte und verschlüsselte Dokumente nehmen allerdings eine andere Route. Um sicherzustellen, dass Lazy-Object-Lesevorgänge und Signaturinspektion stets dieselbe unveränderliche Revision sehen, selbst nachdem der Aufrufer seinen eigenen Stream freigegeben hat, nimmt THotPDF.LoadFromStream einen Snapshot der Quelle und parst ihn ein zweites Mal. Dieser zweite Parse liest — wie auch das Reuse-Load für authentifizierte Revisionen — das Snapshot-Handle ohne Puffer davor. Beide laufen jetzt durch denselben internen gepufferten Reader, den der Dateipfad verwendet
Dieser Puffer ist intern, Sie erzeugen ihn also nie selbst. Was Sie kontrollieren, ist der Stream, den Sie an LoadFromStream übergeben, und HotPDF liest diesen Stream während des ersten strukturellen Durchlaufs, wie er ist. Liegen die Daten bereits im Speicher, übergeben Sie einen TMemoryStream. Liegen sie auf der Platte, rufen Sie LoadFromFile auf, oder geben Sie dem Loader ab Delphi 10.1 Berlin einen TBufferedFileStream statt eines nackten 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); // ein Bulk-Read statt Lesen pro Token
Mem.Position := 0;
// LoadFromStream gibt die Seitenzahl zurück, 0 wenn das Dokument nicht lud
if Result.LoadFromStream(Mem, Password) < 1 then
raise Exception.Create('Could not load ' + FileName);
except
Result.Free;
Mem.Free;
raise;
end;
Mem.Free;
end;
Den Memory-Stream direkt nach dem Laden freizugeben ist bei signierter und verschlüsselter Eingabe sicher, denn die Komponente hält bereits ihren eigenen Snapshot der Bytes. Bewahren Sie die Originaldatei oder den Puffer trotzdem auf, wenn Sie hinterher Signaturen verifizieren wollen, denn die Verifikation hasht die rohen /ByteRange-Segmente der Datei, nicht das entschlüsselte Objektmodell
Laden und Verifikation als getrennte Phasen messen
Ladezeit und Signaturverifikationszeit sollten getrennt gemessen werden, denn sie scheitern auf unterschiedliche Weise und werden an unterschiedlichen Stellen behoben. Die Ladephase umfasst Parsing, Passwortprüfung und Entschlüsselung. Die Verifikationsphase hasht die signierten Bytebereiche neu und prüft die CMS-Signatur. Falten Sie beides in eine Zahl, verbirgt sich eine Regression der einen in der anderen
Der RTL-TStopwatch aus System.Diagnostics genügt dafür. Das Geschirr unten lädt mit der Passwort-Überladung von THotPDF.LoadFromFile und verifiziert dann die erste Signatur über die TStream-Überladung von VerifyLoadedSignature. Die Stream-Überladung ist die richtige Wahl für eine verschlüsselte Datei, denn der Passwort-Ladepfad behält keinen Quelldateinamen, den die parameterlosen Überladungen wiederöffnen könnten
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;
Führen Sie es mehrmals aus und ignorieren Sie den ersten Lauf. Der erste Aufruf zahlt Einmal-Setup-Kosten, darunter den Aufbau der AES-Tabellen und ihren Selbsttest, und das Disk-Caching verfälscht ebenfalls die erste Lesung. Gesucht ist die Zahl im Beharrungszustand für eine echte Kundendatei, gemessen gegen eine unsignierte, unverschlüsselte Kopie desselben Dokuments
Warum wurde der AES-256-Dateischlüssel zweimal abgeleitet?
HotPDF leitete den AES-256-Dateischlüssel zweimal ab, weil zwei Stufen desselben Ladens jeweils die volle Passwortprüfung auf eigene Faust fuhren. Vor dem strukturellen Parse unternimmt RecoverReloadKeysFromStreamRanges einen Vorabscan der rohen Bytes, damit verschlüsselte Objekt-Stream-Container entschlüsselt werden können, während die Filter dekodiert werden. Dieser Vorabscan führt bereits die ISO-32000-2 §7.6.4.3.3 Algorithmus-2.A-Prüfungen durch, validiert das Passwort gegen die User- und Owner-Salts und gewinnt den Dateiverschlüsselungsschlüssel. DecryptLoadedDocument ignorierte dieses Ergebnis und fuhr die gesamte Ableitung erneut
Für Revision 4 und älter kostete das Duplikat kaum mehr als eine MD5-Schleife. Für Revision 6 ruft Algorithmus 2.A den Algorithmus-2.B-Hardened-Hash auf, also verdoppelte das Duplikat den teuersten Teil des Öffnens der Datei. Den gewonnenen Schlüssel wiederzuverwenden ist einfach. Ihn sicher wiederzuverwenden ist der schwerere Teil, denn ein zwischengespeicherter Schlüssel, der seine eigene Passwortprüfung überlebt, wird zu einem Authentifizierungs-Bypass
Der Fix begrenzt die Wiederverwendung mit einem Scope-Flag, das nur zwischen dem Vorabscan und dem Decrypt-Aufruf innerhalb der Passwort-Überladungen von LoadFromFile und LoadFromStream wahr ist und in einem finally-Block gelöscht wird. Das Flag wird nur gesetzt, wenn der Vorabscan das Passwort tatsächlich validiert hat. Ein direkter Aufruf von DecryptLoadedDocument nach einem einfachen Laden fährt stets die volle Ableitung, denn dieses Passwort wurde nie vorab gescannt. Ohne diese Beschränkung könnte ein veralteter Reload-Schlüssel aus einer früheren erfolgreichen Öffnung dem Dokument still für einen späteren Aufrufer entschlüsseln, der das falsche Passwort lieferte — und das ist ein weit schlimmerer Bug als ein langsames Laden
Wo verbringt der R6-Hardened-Hash seine Zeit?
Der R6-Hardened-Hash verbringt fast seine gesamte Zeit mit AES-128-CBC-Verschlüsselung. ISO 32000-2 §7.6.4.3.4 Algorithmus 2.B wiederholt eine Runde mindestens 64-mal. Jede Runde baut einen Puffer aus 64 Kopien des Passworts, des aktuellen Hashs und auf der Owner-Seite des /U-Strings, verschlüsselt diesen Puffer mit AES-128-CBC unter Verwendung der ersten 16 Bytes des Hashs als Schlüssel und der nächsten 16 als IV, und wählt SHA-256, SHA-384 oder SHA-512 für den nächsten Hash aus den ersten 16 Bytes des Chiffretexts, gelesen als Zahl modulo 3. Die Hashs sind kurz. Die verschlüsselten Puffer sind mehrere Kilobyte pro Runde
HotPDFs Implementierung dieser Schleife, PDF20HashDanceR6, verschlüsselte den Puffer früher Block für Block durch einen byte-orientierten Referenz-AES. Sie versucht jetzt zuerst eine native CBC-Routine ohne Padding, die auf Delphi-Zielen das gelinkte C-AES-Backend erreicht. Mit dieser Änderung sank ein volles Dokument-Laden von rund 66 ms auf 16 ms. Ist die native Routine nicht verfügbar oder gibt eine nicht passende Länge zurück, fällt der Code auf die historische Blockschleife zurück, und diese Schleife kann ihrerseits einen tabellengetriebenen AES-128-Block verwenden, dessen T-Tabellen aus der bestehenden S-Box erzeugt werden. Die Chiffre ist in jedem Fall dieselbe, der abgeleitete Schlüssel ist also byte-identisch, ganz gleich, welcher Pfad ihn berechnet hat
Dieser Beitrag handelt von Geschwindigkeit. Die Algorithmus-2.B-Terminierungsregel korrekt zu treffen ist ein separates Korrektheitsproblem, und ein Off-by-one dort weist korrekte Passwörter nur manchmal ab. Die Konfigurationsseite von R5 gegenüber R6 behandelt der Leitfaden zur AES-256-PDF-Verschlüsselungs-Einrichtung mit HotPDF
Vier Arten, wie ein schneller AES-Block Sie belügen kann
Ein tabellengetriebener AES-Block kann falschen Chiffretext produzieren und trotzdem seinen eigenen Selbsttest bestehen. Jede der vier Fallen unten tauchte beim Bau des schnellen Pfads auf, und zwei von ihnen kamen am Selbsttest vorbei, wie er zuerst entworfen war
- Selbsttest-Reentranz. Die Bereitschaftsroutine setzt ihr Initialisiert-Flag, bevor sie den Selbsttest fährt, also nimmt ein re-entranter Aufruf während des Tests den Referenzpfad. Die erste Version des Tests verglich daher die Referenzimplementierung mit sich selbst. Der Fix ist ein ungeschützter roher schneller Block, den der Selbsttest direkt aufruft, sodass der Tabellenpfad der Code ist, der tatsächlich getestet wird
- Vertauschte Tabellenlayouts. Die Bytereihenfolge zweier der vier Rundentabellen (Te1 und Te3) zu tauschen produziert falschen Chiffretext, den der re-entrante Selbsttest auf keine Weise bemerken konnte. Erwischt hat es ein eigenständiges Probe-Programm, das den FIPS-197-Anhang-C.1-Chiffretext
69c4e0d86a7b0430d8cdb78070b4c55afür Schlüssel000102…0fund Klartext00112233…ffdruckte. Der Selbsttest verlangt jetzt sowohl diesen Vektor als auch Byte-Gleichheit mit dem Referenzblock, bevor er dem schnellen Pfad vertraut - FPC-Range- und Overflow-Checks. Unter Free Pascal mit
-Crund-Cowerfen Delphi-typische expliziteByte()-Konvertierungen und Byteweite-Shift-Arithmetik Range- oder Overflow-Exceptions. Tabellenindizes und die GF(2^8)-Verdopplung werden jetzt in nativer Ganzzahlbreite berechnet und mitand $FFmaskiert - FPC-Inliner-Miskompilierung. Kleine Big-Endian-Load- und Store-Helfer mit
PByte-Parametern wurden zu Access Violations kompiliert, wenn sie unter FPC alsinlinemarkiert waren. Das Entfernen voninlinebehob sie, und die Kosten des Aufrufs sind neben den Tabellenzugriffen vernachlässigbar
Hinter der finalen Verdrahtung steckt dieselbe Erfahrung. Jeden AES-128-Block in der Bibliothek über die schnellen Tabellen zu führen, verursachte während eines Publication-Kandidaten-Ladens eine deterministische FPC-Access-Violation, also wurde diese Änderung zurückgerollt. Das ausgelieferte Design ist konservativ: natives CBC zuerst, ein expliziter Schnelltabellen-Zweig innerhalb der R6-Schleife, und der Referenzblock überall sonst. Die allgemeine Lehre: Neue kryptografische Code, der unter mehreren Compilern bauen muss, braucht eine unabhängige Known-Answer-Sonde, die auf jedem Zielcompiler läuft. Ein DLL-level-Testfehler kann Ihnen nicht sagen, ob Sie eine falsche Tabelle oder einen Crash haben. Mehr zur Free-Pascal-Seite in den Notizen zu HotPDF-Deflate- und AES-Backends unter Free Pascal
Wie cache-t man Signaturverifikation, ohne die falsche Antwort zu cachen?
Ein Signaturverifikations-Cache ist nur sicher, wenn sein Schlüssel jede Eingabe abdeckt, die das Urteil ändern kann, eingeschlossen Eingaben, die außerhalb der signierten Bytebereiche leben. Die Headless-Dokumentschicht von HotPDF memoisiert CMS-Verifikationsergebnisse, indem sie die CMS-Bytes, die Bytes des Signature-Dictionarys in der /Contents-Lücke, die Inhalte der abgedeckten Bereiche, einen Policy-Fingerprint, die Verifikationsbudgets, die Gesamtquellgröße und einen Ein-Minuten-Zeiteimer hasht. Drei dieser Eingaben wurden erst ergänzt, nachdem ein Bug zeigte, dass sie fehlten
Vertrauenswürdige Zertifikatsdateien werden nach Inhalt verschlüsselt, nicht nach Pfad. Die Operationsschicht schreibt dasselbe PEM-Material für jeden Befehl in einen frischen temporären Dateinamen, also passte ein pfadbasierter Schlüssel nie, und der Cache traf nie. Die Gesamtquellgröße musste Teil des Schlüssels sein, weil die gemeldeten UnsignedTrailingBytes von ihr abhängen: Eine Basisrevision und ein späteres Incremental Update können exakt dieselben abgedeckten Inhalte teilen, während sie unterschiedliche nachlaufende Längen haben, und ein nur auf den Bereichen basierender Cache würde der aktualisierten Datei das Urteil der Basisrevision reichen. Der Hintergrund zu diesen nachlaufenden Bytes steckt in dem HotPDF-Leitfaden zum Verifizieren digitaler PDF-Signaturen in Delphi
Der dritte Bug war eine Pascal-Sprachfalle, kein Designfehler. Hash-Routinen nehmen einen untypisierten const-Puffer, und eine dynamische Array-Variable an einen untypisierten Parameter zu übergeben übergibt die Adresse der Array-Referenz, nicht die Array-Daten
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
// Falsch: liest die Array-Referenz und alles, was darauf auf dem Stack folgt.
// Ein 16-KB-Read von dort läuft über die Stack-Seite hinaus und wirft eine
// Access Violation, und Delphi und FPC scheitern an unterschiedlichen Stellen.
Writeln(Checksum(CMS, Length(CMS)));
// Richtig: das erste Element übergeben, gegen das leere Array abgesichert
if Length(CMS) > 0 then
Writeln(Checksum(CMS[0], Length(CMS)));
end;
Das Cache-Lock ist SyncObjs.TCriticalSection statt der Windows-InitializeCriticalSection-API, denn dieselbe Unit baut auch unter FPC auf Linux, wo es diese API nicht gibt
Was bleibt absichtlich langsam
Die R6-Schlüsselableitung selbst wird niemals über Ladungen hinweg gecacht, und das ist absichtlich. Externe Bytes und ein explizit geliefertes Passwort gehen stets durch den echten Authentifizierungspfad, also zahlt jedes Passwort-Laden eines R6-Dokuments mindestens einen Algorithmus-2.B-Hash, und bei Versuch des Owner-Passworts einen Hash auf User- und einen auf Owner-Seite. Die oben beschriebene begrenzte Wiederverwendung entfernt das Duplikat innerhalb eines einzelnen Ladens und nichts darüber hinaus
- Die 66-ms-auf-16-ms-Zahl gilt für Delphi-Ziele mit gelinktem nativem AES-Backend. FPC-Builds nehmen den tabellengetriebenen Block, und die FPC-Codegenerierung unter Windows lief unter denselben Assertions 2- bis 4-mal langsamer als Delphi
- Der Verifikationscache bindet an einen Ein-Minuten-Zeiteimer, also sind wiederholte Prüfungen derselben Bytes in einem kurzen Fenster billig, während ein gecachtes Urteil niemals die Minute überlebt, in der es berechnet wurde
- Ein Authority-Cache für zusammengeführte Revisionen wurde implementiert, debuggt und dann wieder entfernt. Er schloss die FPC-Lücke nicht und machte Delphi-Sicherheits-Callbacks messbar langsamer — eine gute Erinnerung, vor dem Behalten eines Caches zu messen
- Signaturverifikation braucht die ursprünglichen verschlüsselten Dateibytes, halten Sie die Quelle also verfügbar. Schnellere Entschlüsselung ändert nichts an dieser Anforderung
FAQ
Schwächt die Wiederverwendung des vorab gescannten Schlüssels die Passwortprüfung?
Nein. HotPDF verwendet den gewonnenen Dateischlüssel nur innerhalb desselben Passwort-Überladungs-Aufrufs wieder, der das Passwort mit den vollen Algorithmus-2.A-Prüfungen validiert hat, und das Scope-Flag wird in einem finally-Block gelöscht. Ein eigenständiger DecryptLoadedDocument-Aufruf fährt stets die komplette Ableitung, also kann ein Schlüssel aus einer früheren Öffnung nicht mit falschem Passwort ausgeliehen werden
Warum das R6-Hash-Ergebnis nicht zwischen Ladungen cachen?
Das Hash-Ergebnis zwischen Ladungen zu cachen hieße, ein Passwort zu akzeptieren, ohne es gegen die geöffnete Datei zu prüfen. HotPDF behandelt jede externe Bytequelle und jedes explizite Passwort als nicht vertrauenswürdige Eingabe, die sich selbst authentifizieren muss — der messbare Geschwindigkeitssprung kam also daher, Algorithmus 2.B schneller zu machen, nicht davon, ihn zu überspringen
Sollte ich einen TFileStream an LoadFromStream übergeben?
Ziehen Sie LoadFromFile für Dateien auf der Platte vor, denn er puffert bereits intern. Wenn Sie die Stream-Überladung brauchen, übergeben Sie einen TMemoryStream oder einen TBufferedFileStream, denn HotPDF liest den gelieferten Stream während des ersten strukturellen Durchlaufs direkt, und ein nackter Handle-Stream kostet einen Systemaufruf pro kleiner Lesung
Passwortgeschütztes Laden, AES-256-R6-Verschlüsselung, CMS-Signaturverifikation und der Rest der geladenen-Dokument-API kommen zusammen in einer nativen VCL-Bibliothek für Delphi und C++Builder. Feature-Details und unterstützte IDE-Versionen finden Sie auf der HotPDF Component-Produktseite