Technischer Artikel

PDF-Schlüssel wiederholen sich unter FPC: PDFiumPas-RNG-Fix

Vor Version 3.114.8 erzeugte PDFiumPas das Schlüsselmaterial für die PDF-Verschlüsselung auf Nicht-Windows-Zielen mit der Random-Funktion der Runtime-Library, und weil nichts Randomize aufriefte, produzierte jeder Prozess dieselbe Bytefolge. Free-Pascal-Builds unter Linux und macOS schrieben daher Lauf für Lauf identische File-Encryption-Keys, Salts, CBC-IVs und AES-GCM-Nonce-Präfixe. Version 3.114.8 liest stattdessen /dev/urandom und wirft eine Exception, wenn das nicht geht

Der Defekt selbst ist eine Vier-Zeilen-Schleife. Die nützlichere Lektion ist, warum eine Testsuite, die hunderte Dokumente verschlüsselt und entschlüsselt, mit AESV3 und AESV4, mit und ohne PDF MAC, die ganze Zeit über grün blieb. Zufall, der pro Prozess konstant ist, ist für jeden Test unsichtbar, der innerhalb eines Prozesses läuft – und genau so schreibt man Verschlüsselungstests üblicherweise

Wo braucht PDFiumPas zufällige Bytes?

Jedes zufällige Byte im PDFiumPas-Verschlüsselungsstack stammt aus einer einzigen Prozedur, AesGenerateRandomBytes in der Unit FPdfAes, eine schlechte Quelle verseucht also alles. Der Standard-Security-Handler in ISO 32000-2 §7.6.4 und die AESV4-Erweiterung in ISO/TS 32003 verbrauchen diese Bytes an diesen Stellen:

  • Der 32-Byte-File-Encryption-Key, frisch von DeriveEncryptionKeys für jedes Dokument generiert und danach unter passwortabgeleiteten Keys in /UE und /OE verpackt
  • Zwei 16-Byte-Salts, einer in den letzten 16 Bytes von /U, einer in den letzten 16 Bytes von /O, jeweils aufgeteilt in einen 8-Byte-Validation-Salt und einen 8-Byte-Key-Salt
  • Die Bytes 12 bis 15 des Klartexts hinter /Perms, die ISO 32000-2 mit Zufallsdaten füllt, bevor der Block unter dem File-Key verschlüsselt wird
  • Ein 16-Byte-CBC-IV, der jedem verschlüsselten String und Stream in einem AESV3-Dokument vorangestellt wird
  • Ein 8-Byte-Nonce-Präfix für AESV4-Dokumente, gefolgt von einem 4-Byte-Zähler pro Objekt, der bei null startet
  • Der 32-Byte-/KDFSalt und der MAC-Key, wenn EnableIntegrityProtection gesetzt ist
Jedes zufällige Byte im PDFiumPas-Verschlüsselungsstack fließt von AesGenerateRandomBytes in FPdfAes in sechs Konsumenten: den 32-Byte-File-Encryption-Key, verpackt in /UE und /OE, die /U- und /O-Salts, die /Perms-Padding-Bytes, den AESV3-CBC-IV, das AESV4-GCM-Nonce-Präfix sowie den KDF-Salt und den MAC-Key
Ein gemeinsamer Generator bedeutet, dass eine schlechte Quelle das Schlüsselmaterial überall auf einmal verseucht, weshalb der Fix in einer einzigen Prozedur landete statt an jeder Aufrufstelle

Warum produzierte jeder Prozess denselben Key?

AesGenerateRandomBytes benutzte den Betriebssystem-Generator nur unter Windows; überall sonst füllte es den Puffer aus dem Pseudozufallsgenerator der RTL, und dieser startet mit RandSeed = 0, sofern das Programm nicht Randomize aufruft. Der Kommentar über der Schleife behauptete, der Generator werde aus GetTickCount64 gesät. Keine Zeile Code hat das je getan – der Kommentar war damit der einzige Ort, an dem der Seed existierte:

// Nicht-Windows-Zweig von AesGenerateRandomBytes vor 3.114.8
// (der Kommentar darüber versprach einen GetTickCount64-Seed, der nie angewendet wurde)
P := PByte(Buffer);
for I := 0 to Count - 1 do
  P[I] := Byte(Random(256));

Die Folge startet mit jedem Prozess neu und schreitet in ihm fort, also teilt das erste Dokument, das irgendein Prozess verschlüsselt, seinen File-Key mit dem ersten Dokument jedes anderen Prozesses desselben Builds, das zweite mit dem zweiten und so weiter. Der File-Key in R5, R6 und R7 hängt überhaupt nicht vom Passwort ab, da das Passwort ihn nur verpackt – wer die Folge reproduzieren kann, hält damit den Key, ohne ein Passwort zu kennen. AESV4 fügt einen zweiten Fehler hinzu: Derselbe Key mit demselben 8-Byte-Präfix und einem bei null neu startenden Zähler wiederholt GCM-Nonces, was NIST SP 800-38D §8 kategorisch verbietet. Eine wiederholte GCM-Nonce unter einem Key offenbart das XOR der beiden Klartexte und legt den Authentication-Subkey offen, sodass die Tags, auf die sich AESV4-GCM-Verschlüsselung und das PDF-MAC-Token verlassen, aufhören, irgendetwas zu bedeuten. Vertraulichkeit und Integrität gehen gleichzeitig flöten

Ungesäter RTL-Generator in PDFiumPas auf Nicht-Windows-FPC-Builds: Mit RandSeed 0 erzeugt jeder Prozess dieselbe Folge, sodass Dokument eins in Prozess A denselben File-Key trägt wie Dokument eins in Prozess B, und AESV4 wiederholt GCM-Nonces, weil derselbe Key auf dasselbe Präfix trifft, während der Zähler bei null neu startet
Weil der File-Key nie vom Passwort abhängt, hält jeder, der die Folge reproduzieren kann, den Key vollständig in der Hand, und wiederholte GCM-Nonces zerstören Vertraulichkeit und Integrität gemeinsam

Der Umfang ist enger, als jener Absatz vermuten lässt. Windows-Builds waren nie betroffen, denn der Windows-Zweig hat immer CryptGenRandom über advapi32 mit CRYPT_VERIFYCONTEXT aufgerufen und im Fehlerfall eine Exception geworfen. Exponiert war die Ausgabe von Nicht-Windows-Builds vor 3.114.8, in der Praxis also Lazarus- und Free-Pascal-Anwendungen unter Linux und macOS – ein Eintrag mehr in der Liste der Delphi-gegen-FPC-Fallstricke in PDFium-Builds

Warum war Randomize nie die richtige Lösung?

Randomize aufzurufen hätte das Symptom versteckt, ohne die Quelle zu reparieren, denn RandSeed ist ein 32-Bit-Wert, den Randomize aus der Uhr ableitet. Das begrenzt die Zahl möglicher Key-Streams auf 2^32, und wenn man ungefähr weiß, wann eine Datei geschrieben wurde, schrumpft die Suche weit darunter – nichts neben einem 256-Bit-AES-Key. Schlüsselmaterial muss aus dem Kernel-Entropie-Pool kommen, daher liest AesGenerateRandomBytes in 3.114.8 /dev/urandom, kommt über kurze Reads hinweg und wirft, wenn der Pool nicht jedes angeforderte Byte liefern kann:

Handle := FileOpen('/dev/urandom', fmOpenRead or fmShareDenyNone);
if Handle <> THandle(-1) then
try
  Remaining := Count;
  while Remaining > 0 do
  begin
    Got := FileRead(Handle, P^, Remaining);
    if Got <= 0 then
      Break;               // Fehler oder unerwartetes Stream-Ende
    Inc(P, Got);
    Dec(Remaining, Got);
  end;
  if Remaining = 0 then
    Exit;
finally
  FileClose(Handle);
end;
raise Exception.Create('FPdfAes: /dev/urandom unavailable; refusing to emit predictable key/IV');

Die Verweigerung ist Absicht und deckt sich mit dem, was der Windows-Zweig immer getan hat, wenn CryptGenRandom nicht verfügbar ist. Ein fehlgeschlagenes verschlüsseltes Speichern ist ein Vorfall, den man am selben Tag bemerkt; ein erfolgreiches Speichern mit vorhersagbaren Keys ist einer, von dem man von jemand anderem erfährt. Zwei praktische Konsequenzen folgen daraus. Ein minimaler Container oder eine Chroot ohne bevölkertes /dev schlägt jetzt bei der Verschlüsselung fehl, statt still zu degradieren – also mounten Sie es. Und weil die Exception aus TPdf.SaveAsEncrypted heraus propagiert, nachdem die Zieldatei mit fmCreate geöffnet wurde, bleibt eine leere Ausgabedatei zurück, die Ihr Fehlerhandler löschen darf

Warum haben Round-Trip-Tests das nie gefangen?

Ein Round-Trip-Test kann konstanten Zufall nicht sehen, weil die Entschlüsselung jeden File-Key zurückgewinnt, den die Verschlüsselung gewählt hat. Der Test verschlüsselt ein Dokument, öffnet es wieder mit dem Passwort, wickelt den Key aus /UE aus und entschlüsselt jedes Objekt; ein vorhersagbarer Key lässt sich genauso gut auswickeln und entschlüsseln wie ein zufälliger, und die GCM-Tags verifizieren, weil sie mit ebendiesem Key berechnet wurden. Sogar ein Test, der zweimal verschlüsselt und verlangt, dass sich die beiden Ausgaben unterscheiden, läuft durch, denn der zweite Aufruf im selben Prozess zieht die nächsten Bytes der Folge. Die Eigenschaft, die zählt – ein anderer Key in jedem Prozess –, ist nur durch Vergleichen der Ausgabe über Prozesse hinweg beobachtbar. Immer wenn derselbe Code einen Wert sowohl erzeugt als auch konsumiert, sind die Tests blind für ganze Klassen von Defekten, und Zufall ist das reinste Beispiel

Wie testet man Key-Zufälligkeit über Prozesse hinweg?

Führen Sie eine kleine Sonde zweimal als getrennte Prozesse auf der Zielplattform aus und vergleichen Sie die Ausgabe. Die Sonde unten ruft DeriveEncryptionKeys auf und druckt den Salt aus, der in den Bytes 32 bis 47 des /U-Eintrags liegt. Dieser Wert steht in jeder verschlüsselten Datei offen sichtbar, sein Druck in CI-Logs offenbart also nichts, und er stammt doch aus demselben Generator wie der File-Key:

Cross-Process-Zufallstest für PDFiumPas: Das Programm SaltProbe ruft DeriveEncryptionKeys auf und druckt den Hex-Wert der /U-Bytes 32 bis 47, der Job führt es zweimal als getrennte Prozesse aus und schlägt fehl, wenn die Zeilen übereinstimmen, und ausgelieferte PDFs werden an den letzten 16 Bytes ihrer /U-Strings verglichen
Konstanter Zufall ist innerhalb eines Prozesses unsichtbar, weil die Entschlüsselung jeden Key zurückgewinnt, den die Verschlüsselung gewählt hat, die entscheidende Eigenschaft ist also nur durch Vergleichen der Ausgabe über Prozesse hinweg beobachtbar
program SaltProbe;
{$mode delphi}
uses
  SysUtils, FPdfEncrypt;
var
  Opts: TPdfEncryptOptions;
  Keys: TPdfEncryptionKeys;
  I: Integer;
  Hex: string;
begin
  Opts := TPdfEncryptOptions.Default;
  Opts.UserPassword := 'probe';
  Opts.Revision := erR6;
  DeriveEncryptionKeys(Opts, Keys);
  Hex := '';
  for I := 32 to 47 do          // /U = 32-Byte-Hash + 16-Byte-Salt
    Hex := Hex + IntToHex(Keys.UEntry[I], 2);
  WriteLn(Hex);                 // muss bei jedem Lauf anders sein
end.

Hängen Sie die Sonde in den Build für jedes Nicht-Windows-Ziel ein: zweimal ausführen, den Job failen lassen, wenn die beiden Zeilen übereinstimmen. Derselbe Vergleich funktioniert bei Dateien, die schon im Umlauf sind. Nehmen Sie zwei verschlüsselte PDFs aus unterschiedlichen Läufen derselben Anwendung, lesen Sie die /U-Strings aus ihren Encrypt-Dictionaries und vergleichen Sie die letzten 16 Bytes; identische Salts identifizieren einen betroffenen Build, und die Dokumente sollten aus ihrem Klartext mit 3.114.8 oder neuer neu verschlüsselt werden, damit jedes einen frischen File-Key bekommt. Die allgemeine Gewohnheit sollte sein, Nicht-Windows-Codepfade auf der Plattform selbst zu üben, statt auf den Windows-Lauf zu vertrauen – dieselbe Überlegung, die hinter dem libcurl-Timestamp-Backend für Nicht-Windows-Builds steckt

PDFiumPas ist eine Delphi- und Lazarus-PDF-Komponente auf Basis der PDFium-Engine, mit AES-256, AES-GCM und dem PDF-MAC-Token nativ in Pascal implementiert und Schlüsselmaterial aus dem Betriebssystem-Generator auf jeder Plattform. Details und Downloads gibt es auf der PDFium-Delphi-Komponentenseite