Ein 2 GB großes PDF zu verschlüsseln klingt nach einem Streaming-Problem: Datei öffnen, zwei Gigabyte durch AES-256 schieben, das Ergebnis schreiben. Dieses mentale Modell ist auf eine Weise falsch, die über das gesamte Performance-Budget entscheidet. ISO 32000-1 §7.6 legt die Granularität der PDF-Verschlüsselung auf das einzelne Objekt fest — jeder Stream und jeder String wird separat verschlüsselt, jeder mit seinem eigenen Initialisierungsvektor und seinem eigenen Padding. Ein 2 GB großes gescanntes Archiv mit 500.000 Objekten bedeutet 500.000 kleine CBC-Operationen, nicht einen langen Durchlauf, und in dieser Größenordnung fallen die Fixkosten rund um jede Operation stärker ins Gewicht als die AES-Arithmetik in ihrem Inneren
Dieser Artikel handelt von diesen Fixkosten: wohin die Zeit geht, wenn Delphi-Code AES-256 auf sehr große Dokumente anwendet, und wie man sie zurückgewinnt. Für die Einrichtungsseite — Passwörter, Berechtigungs-Flags, die Entscheidung zur Kompatibilität zwischen Revision 5 und 6 — lesen Sie bitte den Begleitartikel zur Konfiguration der AES-256-Verschlüsselung in HotPDF; davon wird hier nichts wiederholt
Eine halbe Million CBC-Operationen, nicht ein Durchlauf
Das Skelett der Datei bleibt im Klartext. Querverweistabellen, Objektnummern, Wörterbuch-Schlüssel, der Seitenbaum: nichts davon ist verschlüsselt, wodurch ein Reader Objekte lokalisieren kann, noch bevor er ein Passwort validiert hat. Was der Standard verschlüsselt, ist Inhalt — Stream-Daten wie Seitenbeschreibungen, Bilder, Schriftarten und Anhänge, sowie Strings wie Metadatenwerte und Anmerkungstexte. Unter dem AES-256 Crypt-Filter wird jedes Element für sich verarbeitet: ein frischer, zufälliger 16-Byte-IV (Initialisierungsvektor), CBC über die Bytes, Block-Padding auf eine 16-Byte-Grenze, und der IV wird im Klartext vor dem Chiffretext (Ciphertext) geschrieben
Daraus folgen zwei Konsequenzen. Erstens ist der Chiffretext immer länger als der Klartext: Der IV fügt 16 Bytes hinzu und das Padding weitere 1 bis 16 Bytes, sodass ein 100-Byte-String auf der Festplatte 128 Bytes belegt und ein leerer Stream immer noch 32 erzeugt. Code, der den Ausgabepuffer auf die Eingabelänge dimensioniert oder nur so viele Bytes zurückschreibt, wie er gelesen hat, erzeugt Dateien, die beim Entschlüsseln beim letzten Block jedes Objekts fehlschlagen. Zweitens folgen die Kosten der Objektanzahl, nicht nur der Byteanzahl. Ein gescanntes Archiv konzentriert seine Bytes auf einige wenige große Bild-Streams, enthält aber Hunderttausende kurzer Streams und kleiner Strings, bei denen der Overhead pro Operation, nicht AES, die Rechnung in die Höhe treibt
Die einzige Gnade im AES-256-Design ist die Schlüsselhandhabung. Security Handler bis zur Revision 4 leiteten für jedes Objekt einen separaten Schlüssel ab, indem sie den Dateischlüssel zusammen mit der Objekt- und Generationsnummer hashten, was jedes Mal einen neuen Schlüssel-Zeitplan (Key Schedule) erzwang. Die /V 5-Schemata ließen die objektbezogene Ableitung fallen: Ein einzelner zufälliger 256-Bit-Dateischlüssel verschlüsselt jedes Objekt im Dokument. Diese Tatsache legitimiert jede der folgenden Optimierungen — der teure kryptografische Zustand kann einmal pro Datei aufgebaut werden, nicht einmal pro Objekt
Das R6 /Encrypt-Wörterbuch: ein langsames Öffnen, günstige Objekte
Ein Dokument der Revision 6 deklariert sein Schema im /Encrypt-Wörterbuch des Trailers, und die wesentlichen Einträge passen in wenige Zeilen:
/Filter /Standard
/V 5 /R 6 /Length 256
/CF << /StdCF << /CFM /AESV3 /Length 32 /AuthEvent /DocOpen >> >>
/StmF /StdCF /StrF /StdCF
/O ...48 bytes... /U ...48 bytes...
/OE ...32 bytes... /UE ...32 bytes...
/Perms ...16 bytes... /P -3904 /EncryptMetadata true
/V 5 wählt die 256-Bit-Schlüsselarchitektur und /R 6 den gehärteten ISO 32000-2 Handshake. /CF definiert den benannten Crypt-Filter — /AESV3 bedeutet AES-256 im CBC-Modus mit vorangestelltem IV — und /StmF sowie /StrF weisen diesen Filter jeweils den Streams und Strings zu. /O, /U, /OE und /UE enthalten das Material zur Passwortüberprüfung und Schlüsselverpackung (Key-Wrapping), und /Perms trägt eine AES-verschlüsselte Kopie der Berechtigungs-Bits, damit ein feindseliger Editor nicht stillschweigend /P umkippen kann
Die Kostenstruktur verbirgt sich in /OE und /UE. Das Entpacken des Dateischlüssels daraus führt den Algorithmus 2.B aus, eine iterierte Schlüsselableitungsfunktion (KDF), die SHA-256-, SHA-384- und SHA-512-Runden aneinanderreiht — mindestens 64 davon, mit einer datenabhängigen Stoppregel — die absichtlich langsam gestaltet ist, damit das Erraten von Passwörtern teuer bleibt. Dieser Preis wird einmal bezahlt, wenn der Writer die Datei erzeugt, und einmal, wenn ein Reader sie öffnet, jeweils im einstelligen Millisekundenbereich. Bei einer Datei mit einer halben Million Objekten ist die KDF vernachlässigbar (Rauschen), und wenn das Speichern langsam ist, ist nicht Algorithmus 2.B der Verdächtige; die Schleife pro Objekt ist es
Wiederverwendung des Schlüssel-Handles, Wiederverwendung des Scratch-Puffers
Die naive Implementierung ist eine aufgeräumte Hilfsfunktion: Ein EncryptAes256Cbc-Helfer, der den Windows-CNG-Provider öffnet, CBC auswählt, das Schlüsselobjekt generiert, einen Puffer verschlüsselt und alles wieder abreißt. Korrekt, per Unit-Test überprüfbar und katastrophal innerhalb einer Schleife mit 500.000 Iterationen. Die Microsoft-Dokumentation kennzeichnet BCryptOpenAlgorithmProvider als teuer und empfiehlt das Cachen des Handles, und BCryptGenerateSymmetricKey führt den vollständigen AES-Schlüssel-Zeitplan aus und weist den Provider-Zustand zu — reine Verschwendung, wenn sich der Schlüssel im gesamten Dokument nie ändert
Die Delphi-RTL liefert keine bcrypt-Import-Unit mit, deklarieren Sie also die Einstiegspunkte direkt. Die folgende Klasse baut den gesamten kryptografischen Zustand einmal auf und verschlüsselt dann eine beliebige Anzahl von Objekten ohne allokation im stationären Zustand:
uses
Winapi.Windows, System.SysUtils, System.Classes;
const
BCRYPT_AES_ALGORITHM = 'AES';
BCRYPT_CHAINING_MODE = 'ChainingMode';
BCRYPT_CHAIN_MODE_CBC = 'ChainingModeCBC';
BCRYPT_OBJECT_LENGTH = 'ObjectLength';
BCRYPT_BLOCK_PADDING = $00000001;
BCRYPT_USE_SYSTEM_PREFERRED_RNG = $00000002;
type
NTSTATUS = Integer;
BCRYPT_HANDLE = Pointer;
function BCryptOpenAlgorithmProvider(out hAlg: BCRYPT_HANDLE; AlgId,
Impl: PWideChar; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptCloseAlgorithmProvider(hAlg: BCRYPT_HANDLE;
Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptSetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Input: PByte;
cbInput, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGetProperty(hObj: BCRYPT_HANDLE; Prop: PWideChar; Output: PByte;
cbOutput: ULONG; out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptGenerateSymmetricKey(hAlg: BCRYPT_HANDLE;
out hKey: BCRYPT_HANDLE; KeyObj: PByte; cbKeyObj: ULONG; Secret: PByte;
cbSecret: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptDestroyKey(hKey: BCRYPT_HANDLE): NTSTATUS; stdcall;
external 'bcrypt.dll';
function BCryptEncrypt(hKey: BCRYPT_HANDLE; Input: PByte; cbInput: ULONG;
Padding: Pointer; IV: PByte; cbIV: ULONG; Output: PByte; cbOutput: ULONG;
out cbResult: ULONG; Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
function BCryptGenRandom(hAlg: BCRYPT_HANDLE; Buffer: PByte;
cbBuffer, Flags: ULONG): NTSTATUS; stdcall; external 'bcrypt.dll';
procedure CngCheck(Status: NTSTATUS; const Api: string);
begin
if Status <> 0 then
raise Exception.CreateFmt('%s failed, NTSTATUS 0x%.8x',
[Api, Cardinal(Status)]);
end;
type
TPdfObjectEncryptor = class
private
FAlg: BCRYPT_HANDLE;
FKey: BCRYPT_HANDLE;
FKeyObject: TBytes; // CNG key-object workspace, allocated once
FScratch: TBytes; // ciphertext scratch, grows and then stays
public
constructor Create(const FileKey: TBytes);
destructor Destroy; override;
procedure EncryptObject(const Plain: TBytes; Dest: TStream);
end;
constructor TPdfObjectEncryptor.Create(const FileKey: TBytes);
var
Mode: string;
ObjLen, Got: ULONG;
begin
inherited Create;
if Length(FileKey) <> 32 then
raise Exception.Create('AES-256 file key must be 32 bytes');
CngCheck(BCryptOpenAlgorithmProvider(FAlg, BCRYPT_AES_ALGORITHM, nil, 0),
'BCryptOpenAlgorithmProvider');
Mode := BCRYPT_CHAIN_MODE_CBC;
CngCheck(BCryptSetProperty(FAlg, BCRYPT_CHAINING_MODE,
PByte(PWideChar(Mode)), (Length(Mode) + 1) * SizeOf(WideChar), 0),
'BCryptSetProperty');
CngCheck(BCryptGetProperty(FAlg, BCRYPT_OBJECT_LENGTH, PByte(@ObjLen),
SizeOf(ObjLen), Got, 0), 'BCryptGetProperty');
SetLength(FKeyObject, ObjLen);
// The AES key schedule is built once here and reused for every object
CngCheck(BCryptGenerateSymmetricKey(FAlg, FKey, PByte(FKeyObject), ObjLen,
PByte(FileKey), 32, 0), 'BCryptGenerateSymmetricKey');
end;
destructor TPdfObjectEncryptor.Destroy;
begin
if FKey <> nil then
BCryptDestroyKey(FKey);
if FAlg <> nil then
BCryptCloseAlgorithmProvider(FAlg, 0);
inherited;
end;
procedure TPdfObjectEncryptor.EncryptObject(const Plain: TBytes; Dest: TStream);
var
IV, IVWork: array[0..15] of Byte;
Need, Written: ULONG;
Src: PByte;
begin
// Fresh random IV per object; it travels in the clear ahead of the data
CngCheck(BCryptGenRandom(nil, @IV[0], 16, BCRYPT_USE_SYSTEM_PREFERRED_RNG),
'BCryptGenRandom');
Src := PByte(Plain); // nil for an empty input is valid: padding-only block
// Size query: CBC padding always adds 1..16 bytes, so Need > Length(Plain)
IVWork := IV; // BCryptEncrypt advances the IV buffer while it chains
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
nil, 0, Need, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt(size)');
if ULONG(Length(FScratch)) < Need then
SetLength(FScratch, Need); // grows a handful of times, then stays put
IVWork := IV;
CngCheck(BCryptEncrypt(FKey, Src, Length(Plain), nil, @IVWork[0], 16,
PByte(FScratch), Need, Written, BCRYPT_BLOCK_PADDING), 'BCryptEncrypt');
// AESV3 layout: the 16-byte IV, then the padded ciphertext
Dest.WriteBuffer(IV[0], 16);
Dest.WriteBuffer(FScratch[0], Written);
end;
Drei Details sind tragend. Die Größenabfrage — der erste Aufruf von BCryptEncrypt mit einem nil-Ausgabepuffer — liefert die gepaddete Chiffretextlänge zurück, die niemals gleich der Eingabelänge ist; das Padding ist deterministisch, Sie könnten also ((Len div 16) + 1) * 16 selbst berechnen und die Anzahl der Aufrufe halbieren, aber die Abfrage ist der dokumentierte Vertrag. Zweitens rückt BCryptEncrypt den IV-Puffer beim Verketten (Chaining) an Ort und Stelle (in place) vor, weshalb in jeden Aufruf eine Arbeitskopie eingeht und der unberührte IV in der Ausgabe landet. Drittens wächst FScratch nur an, bis zum größten Objekt in der Datei, wonach die Schleife nichts mehr allokiert
Was die Wiederverwendung von Handles wert ist, gemessen
Die Datei, die diese Übung erzwang, war ein 1,8 GB großes gescanntes Kreditarchiv: 412.000 verschlüsselte Objekte mit 1.710 MB Nutzdaten (Payload), sobald die Klartextstruktur abgezogen ist. Dieselbe Maschine, dieselbe Datei, NVMe-Speicher, ein Thread:
- Setup pro Aufruf (Provider geöffnet und Schlüssel innerhalb des Helfers generiert): Verschlüsselungsphase 71,3 s — 1.710 MB ÷ 71,3 s ≈ 24 MB/s
- Zustand herausgehoben (die Klasse oben): 9,6 s — 1.710 MB ÷ 9,6 s ≈ 178 MB/s
Der Unterschied beträgt 61,7 s verteilt auf 412.000 Aufrufe, oder etwa 150 µs pro Aufruf, die für das Öffnen eines Providers, das Einstellen eines Chaining-Modus und das Neuaufbauen eines Schlüssel-Zeitplans für einen sich nie ändernden Schlüssel aufgewendet wurden. Nichts davon war Kryptografie. Mit AES-NI läuft die CBC-Verschlüsselung großer Puffer bei fast 1,4 GB/s auf einem Kern. Die eigentliche AES-Arithmetik macht also etwa 1,2 s der 9,6 s aus; das meiste vom Rest sind die beiden User-Mode BCryptEncrypt-Übergänge pro Objekt plus die IV-Generierung pro Objekt. Das Bündeln der IVs — ein Aufruf von BCryptGenRandom füllt 4.096 davon — reduzierte den Durchlauf auf 8,9 s. Jenseits davon befindet man sich an der API-Untergrenze pro Objekt, und der verbleibende Hebel ist Parallelität: /V 5-Objekte sind unter dem gemeinsamen Dateischlüssel unabhängig, also brachten vier Worker-Threads mit jeweils einem Schlüsselobjekt die Phase auf 3,1 s, bevor der Output-Writer zum Serialisierungspunkt wurde
Vollständiges Neuschreiben versus inkrementelles Speichern
Die Granularität entscheidet auch darüber, was das Speichern kostet. Das Hinzufügen von Verschlüsselung zu einem bestehenden Klartextdokument schreibt per Definition jedes Objekt neu: Jeder Stream und String ändert sowohl Inhalt als auch Länge, jeder Querverweis-Offset verschiebt sich, und es gibt keinen inkrementellen Pfad. Planen Sie es als ein vollständiges sequenzielles Neuschreiben ein und schreiben Sie in eine temporäre Datei, die über das Ziel umbenannt wird, denn ein Absturz mitten in der Verschlüsselung hinterlässt andernfalls eine halb verschlüsselte Datei, die kein Passwort mehr öffnet
Die umgekehrte Richtung ist die günstige. Sobald eine Datei verschlüsselt ist, hängt ein inkrementelles Update neue Objekte an, die mit demselben Dateischlüssel verschlüsselt wurden, und lässt jedes ursprüngliche Byte unberührt. Das Aufstempeln einer Genehmigungsanmerkung auf ein 2 GB großes, verschlüsseltes Archiv kostet Kilobytes an angehängter Ausgabe, nicht ein 2 GB großes Neuschreiben. Die Schlussfolgerung für die Pipeline: Verschlüsseln Sie einmal als letzten Schritt des Jobs, und lassen Sie nachfolgende Anpassungen über inkrementelles Speichern laufen. Eine Passwort-Rotation, die auch den Dateischlüssel rotiert, ist wiederum ein vollständiges Neuschreiben — planen Sie es als solches ein
Durchsatz messen, ohne sich selbst zu täuschen
Durchsatzangaben bei Verschlüsselung neigen dazu, im Zähler, im Nenner oder in beidem falsch zu sein. Der Zähler sollte die Payload-Bytes sein: die Summe der Stream- und String-Längen, die tatsächlich durch AES geschoben werden, nach der Komprimierung, was der Writer fortlaufend summieren kann. Die Dateigröße übertreibt dies — das obige Archiv ist auf der Festplatte 1,8 GB groß, aber nur 1.710 MB davon berühren jemals die Chiffre. Der Nenner sollte die Verschlüsselungsphase allein sein, eingeklammert mit TStopwatch aus System.Diagnostics, wobei Parsing, Deflate und Festplatten-E/A außerhalb der Klammern bleiben. Bezieht man diese mit ein, wird derselbe Verschlüsselungscode bei einer Datei, die sich einfach schlechter komprimieren lässt, um ein Vielfaches langsamer gemessen. Die obigen Zahlen sind genau deshalb vergleichbar, weil beide Seiten der Division rein auf Verschlüsselung basieren
Nichts davon muss Code sein, den Sie selbst schreiben. HotPDF packt dasselbe Engineering hinter Komponenten-Eigenschaften — ActivateProtection, CryptKeyLength, UseAES256R6 — auf der richtigen Abstraktionsebene für interaktive VCL-Anwendungen, wobei die Fallstricke bei der Zuweisungsreihenfolge im HotPDF AES-256-Artikel behandelt werden. Für unbeaufsichtigte Pipelines wendet PDFlibPas die AES-256-Revision 6 auf bestehende Dateien in einem einzigen EncryptFile-Aufruf mit Stärke 4 an und verifiziert danach, was auf der Festplatte gelandet ist. Dieser Workflow wird im PDFlibPas-Verschlüsselungs-Audit-Artikel durchgespielt
Die hier beschriebenen Verschlüsselungspfade sind in der HotPDF-Komponente für Delphi und C++Builder sowie in der PDFlibPas-Bibliothek enthalten; beide Produktseiten führen die komplette Verschlüsselungsreferenz auf