Technischer Artikel

Warum manche AES-256-R6-PDFs das richtige Passwort ablehnen

Wenn ein AES-256-Revision-6-PDF ein Passwort ablehnt, von dem Sie wissen, dass es korrekt ist, ist der übliche Täter der Rundenzähler im R6-Hardened-Hash (ISO 32000-2 Algorithm 2.B). Die PDFium Component for Delphi hatte genau diesen Bug vor v2.57.2: TPdf.SaveAsEncrypted zählte Runden von der falschen Basis aus, und rund 30% der von ihr erzeugten Dateien wurden von FPDF_LoadDocument mit FPDF_ERR_PASSWORD abgewiesen

Das Unangenehme ist nicht die Arithmetik. Das Unangenehme ist, dass der Defekt für jeden Test unsichtbar war, der eine Datei verschlüsselte und sie dann mit demselben Code prüfte, und dass ein einziger erfolgreicher Roundtrip nichts bewies. Dieser Post geht durch, was Algorithm 2.B tatsächlich verlangt, wo ein Ein-Runden-Verschieber sich versteckt, warum er nur manchmal failt, und wie man einen Regressionstest baut, der so einen Bug erwischt, bevor ein Kunde es tut

Was sagt die R6-Hash-Terminierungsregel tatsächlich?

Algorithm 2.B stoppt erst nach mindestens 64 Runden, und dann auch nur, wenn das letzte Byte des jüngsten AES-Ausgabewerts E kleiner oder gleich der Rundennummer minus 32 ist, wobei die Rundennummer die 1-basierte Zählung der bereits ausgeführten Runden ist. Nach der N-ten Runde vergleichen Sie gegen N. Nicht gegen N − 1, nicht gegen den Index der nächsten Runde. Diese eine Definition ist die gesamte Bug-Oberfläche

Der Rest des Algorithmus ist Mechanik. Starten Sie mit K als SHA-256 von Passwort, Salt und (beim Owner-Key) dem 48-Byte-/U-Wert. Jede Runde baut K1 als Passwort ‖ K ‖ UK, 64-mal wiederholt, verschlüsselt es mit AES-128-CBC ohne Padding, mit den ersten 16 Bytes von K als Key und den nächsten 16 als IV, nimmt die ersten 16 Bytes von E als Big-Endian-128-Bit-Integer mod 3 und wählt entsprechend SHA-256, SHA-384 oder SHA-512, um das nächste K zu erzeugen. Der finale Hash sind die ersten 32 Bytes von K. ISO 32000-2 §7.6.4.3 definiert das; die datenabhängige Schleifenlänge ist Absicht, denn sie macht die Arbeit pro Passwort-Guess für einen GPU-Cracker unvorhersagbar

Hier ist die korrekte Form der Schleife, als Illustration geschrieben, nicht als Drop-in-Implementierung. BuildK1, AesCbcNoPad128, FirstBlockMod3 und die Hash-Helper stehen für die Primitive, die Ihre Codebasis ohnehin hat

// Illustration: Algorithmus-2.B-Loop-Skelett (ISO 32000-2, Revision 6)
K := Sha256(Password + Salt + UK);
RoundNumber := 0;                 // bisher ausgeführte Runden
repeat
  Inc(RoundNumber);               // das ist Runde 1, 2, 3 ...
  K1 := BuildK1(Password, K, UK); // (Password || K || UK) 64-mal wiederholt
  E  := AesCbcNoPad128(Copy(K, 0, 16), Copy(K, 16, 16), K1);
  case FirstBlockMod3(E) of       // erste 16 Bytes von E, Big-Endian, mod 3
    0: K := Sha256(E);
    1: K := Sha384(E);
    2: K := Sha512(E);
  end;
until (RoundNumber >= 64) and (E[High(E)] <= RoundNumber - 32);
Result := Copy(K, 0, 32);

Lesen Sie die Terminierungszeile mit dem Zähler im Kopf. Das Inkrement sitzt am Kopf des Rumpfs, wenn der until-Test also läuft, hält RoundNumber exakt die Anzahl abgeschlossener Runden – und das meint der Standard mit der Rundennummer

Wo sich der Off-by-one in einem Delphi-Port versteckt

Der Bug der PDFium Component war ein Zähler, der bei −1 startete, mit dem Inkrement am Kopf der Schleife; der im until-Zweig getestete Wert war also stets die ausgeführte Rundenzahl minus eins. Zwei Dinge gehen gleichzeitig schief: Das 64-Runden-Minimum wird zu 65, und die Byte-Schwelle ist eine Stufe strenger, als der Standard sagt. Beides verschiebt den Ausstiegspunkt in den relevanten Fällen exakt um eine Runde

Derselbe Verschieber hat mehrere ebenso plausible Verkleidungen. Bei 0 starten und am Fuß des Rumpfs inkrementieren, nachdem der Test bereits gegen den Pre-Inkrement-Wert geschrieben wurde. Einen 0-basierten for-Schleifenindex benutzen und ihn im Vergleich wiederverwenden. Eine C-Referenz übersetzen, deren Schleifenvariable vor ++ geprüft wird. Jede dieser Varianten liest sich im Code-Review natürlich, und jede produziert einen Hash, der perfekt zufällig aussieht, weil er es ist: schlicht eine andere, gleich gültig aussehende SHA-Ausgabe, die kein anderer Reader berechnet. Das gefixte CalculateR6Hash in FPdfEncrypt.pas trägt jetzt einen Kommentar, der die 1-basierte Semantik festnagelt – genau weil die falsche Version nicht falsch aussieht

Warum failt ein AES-256-R6-PDF nur manchmal?

Das Versagen ist probabilistisch, denn die Extrarunde ändert das Ergebnis nur, wenn das Terminierungsbyte im Ein-Runden-Fenster zwischen der korrekten und der verschobenen Schwelle landet. In allen anderen Fällen steigen beide Versionen in derselben Runde aus und erzeugen dasselbe K, die Datei öffnet also sauber. In der PDFium-Component-Messung produzierten 18 von 60 Verschlüsselungen (etwa 30%) /U-, /O-, /UE- und /OE-Werte, die von einer Standard-Implementierung abwichen; die anderen 70% waren byte-weise korrekt

Frische Zufalls-Salze werden bei jedem Speichern gezogen, dasselbe Dokument mit demselben Passwort gelingt also beim einen Lauf und failt beim nächsten. Aus Sicht eines Support-Decks sieht das nach flakernder Speicherung aus, nach einem korrupten Upload oder einem User, der sich beim Passwort vertippt. Niemand denkt daran, einem Schleifenzähler die Schuld zu geben, wenn sieben von zehn Dateien öffnen. Sieht Ihr Produkt intermittierende „falsches Passwort“-Meldungen bei PDFs, die Ihr eigener Code verschlüsselt hat, und nur bei Revision 6, ist das hier die erste Anlaufstelle

Revision 5 ist immun, sie nutzt einen einzigen SHA-256-Durchlauf ohne Iteration. Der ISO/TS-32003-AESV4-Pfad ist es nicht: Der TPdfEncryptionRevision-Wert erR7 recycelt Algorithm 2.B für seine Passwort-Hashes, derselbe Fix deckt also auch die GCM-Dateien ab, die in AES-256-GCM-Verschlüsselung und PDF-MAC-Integrität mit ISO/TS 32003 beschrieben werden

Die Selbstkonsistenz-Falle: warum Roundtrip-Tests bestehen

Ein Roundtrip durch Ihr eigenes Verschlüsseln und Ihr eigenes Verifizieren gelingt immer, denn beide Seiten fahren dieselbe falsche Schleife und einigen sich auf denselben falschen Hash. Der Defekt existiert nur relativ zu der Implementierung von jemand anderem; der Richter muss also ein unabhängiger sein: die PDFium-Engine in pdfium.dll, Acrobat, qpdf oder irgendein Reader, den Sie nicht geschrieben haben

Das ist die unbequeme allgemeine Lektion für alle, die Crypto-adjazenten Dokumentcode schreiben. Unit-Tests, die einen Hash berechnen und ihn mit der Ausgabe der Funktion unter Test vergleichen, beweisen Determinismus, nicht Korrektheit. Known-Answer-Vektoren aus dem Standard oder einem Referenztool sind besser, und ein Cross-Implementierungs-Roundtrip ist das Nächste an Ground Truth, was Sie bekommen. Die PDFium Component ist hier in einer glücklichen Position: Verschlüsseln passiert in Pascal durch EncryptDocumentBytes, während die Entschlüsselung beim Laden durch PDFiums eigenen C++-Security-Handler läuft; die gespeicherte Datei mit TPdf wieder zu öffnen ist also eine echte unabhängige Prüfung, kein Spiegel

uses
  SysUtils, PDFium, FPdfEncrypt;

procedure SaveR6(const Source, Target: string);
var
  Pdf: TPdf;
  Opts: TPdfEncryptOptions;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := Source;
    Pdf.Active := True;
    Opts := TPdfEncryptOptions.Default;
    Opts.UserPassword := 'userpw';
    Opts.OwnerPassword := 'ownerpw';
    Opts.Revision := erR6;          // AESV3, Algorithm-2.B-Passwort-Hash
    if not Pdf.SaveAsEncrypted(Target, Opts) then
      raise Exception.Create('SaveAsEncrypted failed');
  finally
    Pdf.Free;
  end;
end;

TPdfEncryptOptions.Default startet bei Revision 5 mit allen erlaubten Berechtigungen und verschlüsselten Metadaten; setzen Sie Revision also explizit, wenn Sie R6 wollen. TPdf.SaveAsEncryptedToStream nimmt denselben Options-Record, wenn die Ausgabe in einen TStream statt in eine Datei geht. Beachten Sie, dass die Passwörter im Options-Record AnsiString sind, was zählt, wenn Sie Nicht-ASCII-Credentials durchreichen; normalisieren und kodieren Sie sie, bevor sie den Record erreichen

Wie regressionstestet man einen probabilistischen Verschlüsselungs-Bug?

Wiederholen Sie den Cross-Implementierungs-Roundtrip oft genug, dass eine 30%-Fehlerrate nicht durchschlüpfen kann. Ein einzelner Lauf verfehlt den PDFium-Component-Bug in etwa 70% der Fälle. Zwanzig Läufe mit je frischen Salzen drücken die Verfehlungsrate auf etwa 0.08% (0.7 hoch 20) – deshalb loopt der ausgelieferte Regressionstest 20-mal statt einmal

procedure TEncryptTests.R6_EncryptedDocumentReopensWithUserPassword;
var
  Pdf: TPdf;
  I: Integer;
  Target: string;
begin
  Target := TempOutputPath('encrypt_r6_roundtrip.pdf');
  for I := 1 to 20 do
  begin
    SaveR6(SamplePdf, Target);       // frische Salze bei jedem Speichern
    Pdf := TPdf.Create(nil);
    try
      Pdf.Password := 'userpw';
      Pdf.FileName := Target;
      Pdf.Active := True;            // PDFium verifiziert /U unabhängig
      AssertTrue('Run ' + IntToStr(I) + ' must reopen', Pdf.Active);
    finally
      Pdf.Free;
    end;
  end;
end;

Koppeln Sie den Negativfall an, ein falsches Passwort, das failen muss, damit ein kaputter Loader den Positivtest nicht leer durchgehen lässt. Und wenn Sie mit Lazarus bauen, bauen Sie das Bibliotheks-Package neu, bevor Sie nach einer Änderung an Lib\*.pas das Testprojekt laufen lassen; der Test-Build linkt die vorkompilierten .ppu-Units des Packages, und gegen veralteten Objektcode zu laufen gibt Ihnen ein beruhigendes Grün auf einem Bug, den Sie gar nicht gefixt haben. Seeding ist eine verwandte Falle, die sich im selben Durchgang anzuschauen lohnt: FPC Random, das konstante Verschlüsselungs-Keys produziert ist ein weiterer Fall, in dem die Ausgabe zufällig aussieht und still falsch ist

FAQ

Betrifft der R6-Rundenzähler-Bug Dateien, die mit Revision 5 verschlüsselt wurden?

Nein. Revision 5 hasht das Passwort mit einem einzigen SHA-256-Durchlauf und hat keine Iterationszählung, es gibt also keinen Rundenzähler, den man falsch machen könnte. Betroffen sind nur Revision 6 und die ISO/TS-32003-AESV4-Revision, die beide Algorithm 2.B nutzen

Lässt sich eine von der Bug-Version geschriebene Datei noch öffnen?

Etwa 70% öffnen normal, weil ihr Hash zufällig passte. Der Rest trägt /U- und /O-Werte, die kein Standard-Reader reproduziert, die Passwortprüfung failt also überall; der praktische Fix ist, aus der Klartext-Quelle mit v2.57.2 oder neuer neu zu verschlüsseln

Warum gegen PDFium testen statt gegen den eigenen Entschlüsselungscode?

Ihr eigener Entschlüssler teilt dieselbe Algorithm-2.B-Implementierung mit Ihrem Verschlüssler, eine falsche Rundenzahl kürzt sich also heraus, und jeder Roundtrip besteht. Eine unabhängige Implementierung wie PDFiums Security-Handler ist die einzige Prüfung, die mit Ihnen uneins sein kann

Verschlüsselung, PDF-MAC-Validierung und der Rest der Save-Pipeline kommen in der PDFium Component for Delphi and C++Builder, mit Source und Regressionstests für Delphi und Lazarus