Technischer Artikel

SASLprep-AES-256-PDF-Passwörter in Delphi mit PDFlibPas

Eine mit AES-256 und einem Nicht-ASCII-Passwort verschlüsselte PDF-Datei öffnet in dem Programm, das sie geschrieben hat, und nirgends sonst. Die Ursache ist fast immer ein fehlender Vorbereitungsschritt: ISO 32000-2 §7.6.4.3.3 verlangt, dass das Passwort mit dem SASLprep-Profil von Stringprep verarbeitet wird, bevor es UTF-8-kodiert und gehasht wird. PDFlibPas, die PDF-Library für Delphi und C++Builder, führt diese Vorbereitung innerhalb von Encrypt, EncryptFile und DecryptFile durch

Das ist nicht die Falsches-Passwort-Geschichte und nicht die Berechtigungs-Bit-Geschichte. Tippen die eigenen Anwender ein Passwort ein, das man nie ausgestellt hat, ist die Retry-Mechanik in dem Artikel zum erneuten Versuchen verschlüsselter PDF-Passwörter das Richtige, und will man herausfinden, was eine bestehende Datei tatsächlich erzwingt, deckt das Verschlüsselungs- und Berechtigungs-Audit das ab. Dieser Beitrag ist enger und merkwürdiger: Das Passwort ist korrekt, der Anwender hat es korrekt eingetippt, und die Datei weigert sich trotzdem, sich anderswo zu öffnen

Warum öffnet ein Nicht-ASCII-Passwort in einem Reader, aber nicht in einem anderen?

Weil die beiden Programme unterschiedliche Byte-Folgen aus denselben Tastenanschlägen hashen. Die Schlüsselableitung für Revision 6 in ISO 32000-2 §7.6.4.3.3 nimmt das Passwort als UTF-8-Bytes, schneidet auf 127 Bytes ab, hängt ein Salt an und führt den gehärteten Hash aus; das Ergebnis wird gegen die Einträge /U und /O im Verschlüsselungs-Dictionary geprüft. Nichts in dieser Kette ist unscharf. Ein einziges abweichendes Byte irgendwo in der Eingabe erzeugt einen völlig anderen Digest, die Validierung schlägt fehl, und der Reader hat genau eine Sache zu sagen: falsches Passwort

Die Bytes weichen ab, weil Unicode mehrere Wege bietet, das zu tippen, was wie dasselbe Passwort aussieht. Ein chinesisches Passwort kann als vorkomponierte Zeichen von der einen Eingabemethode ankommen und als Kompatibilitätsformen von einer anderen. Ein deutsches oder französisches Passwort, aus einer Textverarbeitung kopiert, kann ein GESCHÜTZTES LEERZEICHEN (U+00A0) tragen, wo der Anwender ein gewöhnliches Leerzeichen vermutet, oder einen BEDINGTEN TRENNSTRICH (U+00AD), der als gar nichts gerendert wird. SASLprep existiert, um all das auf eine kanonische Form zu reduzieren, bevor irgendjemand irgendetwas hasht, sodass jede konforme Implementierung aus derselben Absicht denselben Schlüssel ableitet

Was ändert SASLprep tatsächlich an einem Passwort?

RFC 4013 definiert SASLprep als Profil des Stringprep-Frameworks aus RFC 3454, und es sind vier geordnete Schritte statt einer einzigen Transformation. Mapping kommt zuerst: RFC-3454-Tabelle C.1.2 (Nicht-ASCII-Leerzeichen) wird auf U+0020 abgebildet, und Tabelle B.1 (Zeichen, die üblicherweise auf nichts abgebildet werden) wird ganz gelöscht. Normalisierung auf Unicode NFKC folgt, das ist der Schritt, der Kompatibilitätszeichen und kombinierende Sequenzen faltet. Dann weist die Prüfung auf verbotene Ausgabe alles in den Tabellen C.2.1 bis C.9 zurück. Schließlich wird die bidirektionale Regel aus RFC 3454 Abschnitt 6 auf den normalisierten String angewendet

PDFlibPas implementiert das gesamte Profil in der Unit PDFlibSASLprep, die einen einzigen Einstiegspunkt offenlegt. PLSASLprepPassword nimmt das rohe Passwort, schreibt die vorbereitete Form in einen var-Parameter und liefert False zurück, wenn das Passwort abgelehnt werden muss. Die Funktion ist auf dem Happy Path bewusst vollständig identisch: Ein rein aus ASCII bestehendes Passwort kommt byte-identisch zurück, sodass sich an bestehenden Deployments nichts ändert

uses
  PDFlibSASLprep;

var
  Prepared: WideString;
begin
  // RFC 4013: mapping, then NFKC, then prohibited output, then the bidi rule
  PLSASLprepPassword('I' + WideChar($00AD) + 'X', Prepared);  // -> 'IX'   B.1 deletes SOFT HYPHEN
  PLSASLprepPassword('a' + WideChar($00A0) + 'b', Prepared);  // -> 'a b'  C.1.2 maps NBSP to U+0020
  PLSASLprepPassword(WideString(WideChar($00AA)), Prepared);  // -> 'a'    NFKC folds ORDINAL INDICATOR
  PLSASLprepPassword(WideString(WideChar($2168)), Prepared);  // -> 'IX'   NFKC folds ROMAN NUMERAL NINE
  PLSASLprepPassword('user', Prepared);                       // -> 'user' ASCII is never touched
end;

Die U+200B-Mehrdeutigkeit, die die Tabellen nicht auflösen

Ein Codepunkt landet in zwei RFC-3454-Tabellen gleichzeitig, und die beiden Tabellen widersprechen sich. ZERO WIDTH SPACE (U+200B) fällt in den C.1.2-Bereich U+2000 bis U+200B, wo die Regel sagt, es auf U+0020 abzubilden, und es fällt auch in den B.1-Bereich U+200B bis U+200D, wo die Regel sagt, es zu löschen. Liest man den Mapping-Schritt in der einen oder anderen Reihenfolge, erhält man unterschiedliche Bytes aus demselben Passwort: a+U+200B+b bereitet sich unter C.1.2 zu a b auf und unter B.1 zu ab. RFC 4013 nennt beide Tabellen und sagt nicht, welche gewinnt, sodass das eine echte Mehrdeutigkeit in der Spezifikation ist, kein Lesefehler. PDFlibPas testet zuerst die C.1.2-Zugehörigkeit und bildet U+200B daher auf ein Leerzeichen ab, was das Verhalten ist, auf das sich andere weit verbreitete Stringprep-Implementierungen geeinigt haben; mit ihnen übereinzustimmen, ist hier das Einzige, was zählt, denn das Ziel ist Byte-Übereinstimmung mit welchem Reader auch immer der Kunde gerade verwendet

Alte Dateien lesen: erst vorbereitet, dann roh

Die Lösung schafft ihr eigenes Kompatibilitätsproblem. Jede vor der Änderung geschriebene AES-256-Datei hat das rohe UTF-8-Passwort gehasht, sodass ein streng konformer Reader Kunden aus ihren eigenen Archiven aussperren würde. PDFlibPas löst das auf der Leseseite, indem es zwei Kandidaten in Reihenfolge probiert. TPDFDocument.SetPassword baut eine Kandidatenliste auf, die mit der vorbereiteten Form beginnt und auf die rohe Form zurückfällt, und fügt den vorbereiteten Eintrag nur hinzu, wenn das Dokument tatsächlich AES-256 ist und sich die beiden Formen unterscheiden. Bei einem ASCII-Passwort sind die Formen identisch, die Liste enthält einen Eintrag, und die Kosten des gesamten Mechanismus sind ein einzelner String-Vergleich. DecryptFile tut dasselbe auf seinem direkten AES-256-Umschreibpfad und ruft PLDirectDecryptFileAES256 zuerst mit dem vorbereiteten Passwort auf

var
  Lib: TPDFlib;
  Bytes: AnsiString;
begin
  Lib := TPDFlib.Create;
  try
    Lib.SetOrigin(1);
    Lib.DrawText(100, 100, 'saslprep roundtrip');
    // Strength 3 and 4 are the two AES-256 values; both are prepared before hashing
    Lib.Encrypt('ow' + WideChar($00AD) + 'ner', 'pa' + WideChar($00AD) + 'ss', 4,
      Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1));
    Bytes := Lib.SaveToString;
  finally
    Lib.Free;
  end;

  Lib := TPDFlib.Create;
  try
    // 'pass' is what SASLprep produced and what any conforming reader computes,
    // so the plain ASCII form opens a file created with the soft-hyphen form
    if Lib.LoadFromString(Bytes, 'pass') = 1 then
      Caption := IntToStr(Lib.PageCount);
  finally
    Lib.Free;
  end;
end;

Der Fallback trägt eine Absicherung, die es wert ist, übernommen zu werden. Der zweite Versuch in DecryptFile läuft nur, wenn sich vorbereitete und rohe Form unterscheiden und der erste Versuch keinen harten Fehlercode gemeldet hat. Ein struktureller Fehlschlag bedeutet, dass die Eingabe beschädigt ist oder nicht die angenommene Verschlüsselungsrevision hat, und eine defekte Datei mit einem anderen Passwort erneut zu versuchen, verbrennt nur einen zweiten vollständigen Parse-Durchlauf über feindliche Eingaben; die Überlegung hinter diesem Reflex wird in der Notiz zum sicheren Parsen nicht vertrauenswürdiger PDF-Dateien dargelegt. Zu beachten ist auch, dass es auf der Schreibseite keinen Fallback gibt, und diese Asymmetrie ist beabsichtigt. Lesen toleriert Geschichte, Schreiben tut das nicht: Jede neue AES-256-Datei erhält die konformen Bytes

Welche Passwörter werden rundweg abgelehnt, und was ist Fehler 604?

SASLprep kann ein Passwort vollständig ablehnen, und tut es das, muss die Verschlüsselung laut fehlschlagen, statt still etwas anderes einzusetzen. Encrypt und EncryptFile bereiten sowohl Owner- als auch User-Passwort vor, sobald Strength 3 oder 4 ist, liefern bei Ablehnung 0 zurück und setzen LastErrorCode auf PDFLIB_ERROR_PASSWORD_SASLPREP, das ist 604. Zwei Familien von Eingaben lösen das aus. Die Tabellen für verbotene Ausgabe weisen Steuerzeichen zurück (C.2.1 und C.2.2), Private-Use-Codepunkte (C.3), Nicht-Zeichen (C.4), einsame Surrogate (C.5), U+FFFD (C.6), ideografische Beschreibungszeichen (C.7) sowie die Anzeigesteuerungs- und Tagging-Bereiche (C.8 und C.9). Separat weist die Bidi-Regel aus RFC 3454 Abschnitt 6 jeden String zurück, der ein RandALCat-Zeichen aus Tabelle D.1 enthält, es sei denn, der String beginnt und endet mit einem solchen und enthält überhaupt keine linksnachrechts-Buchstaben

var
  Lib: TPDFlib;
begin
  Lib := TPDFlib.Create;
  try
    // U+0007 is a C.2.1 control character, so preparation refuses the password
    if Lib.Encrypt('owner', 'bad' + WideChar($0007), 4,
         Lib.EncodePermissions(1, 0, 0, 0, 0, 0, 0, 1)) = 0 then
    begin
      if Lib.LastErrorCode = PDFLIB_ERROR_PASSWORD_SASLPREP then  // 604
        ShowMessage('The password contains characters that PDF encryption does not permit.');
    end;
  finally
    Lib.Free;
  end;
end;

Diese Bidi-Regel ist die, die den eigenen Support überraschen wird. Ein arabisches oder hebräisches Passwort, das auf eine westliche Ziffer endet, oder eines mit einem vereinzelten lateinischen Buchstaben in der Mitte, wird von der Spezifikation abgelehnt, obwohl es im Eingabefeld völlig vernünftig aussieht. Man sollte 604 als Meldung über die Passwortzeichen ausgeben, nicht als generischen Verschlüsselungsfehler, sonst verbringt jemand einen Nachmittag damit, nach einem Bug in der eigenen Schlüsselableitung zu suchen

Ehrliche Grenzen: NFKC, ein angenähertes LCat, und eine Delphi-Falle

Zwei Teile der Implementierung sind Näherungen, und beide verdienen es, klar benannt statt vergraben zu werden. Die NFKC-Normalisierung wird von der Windows-API NormalizeString ausgeführt, dynamisch aus Normaliz.dll geladen. Ist diese Bibliothek nicht verfügbar, wird der abgebildete String unnormalisiert verwendet, was bedeutet, dass Mapping- und Verbotsschritte weiterhin laufen, aber die Kompatibilitätsfaltung nicht. In der Praxis wird die DLL seit jeder Windows-Version seit Vista mitgeliefert, sodass der degradierte Pfad ein Vor-Vista- und Nicht-Windows-Anliegen ist statt ein akutes, aber ein Passwort, das sich auf NFKC-Faltung verlässt, würde dort andere Bytes erzeugen, und das ist eine echte, wenn auch entfernte, Abweichung. Die Bidi-Prüfung ist die zweite Näherung: Die Erkennung von LCat-Zeichen verwendet die gängigen Buchstabenbereiche statt der vollständigen RFC-3454-Tabelle D.2, und die Richtung dieses Fehlers ist es, die ihn akzeptabel macht. Ein übersehenes LCat-Zeichen kann nur bewirken, dass die Bidi-Regel dort besteht, wo die Spezifikation abgelehnt hätte, nie umgekehrt, und es berührt nie die Mapping- oder Normalisierungsschritte, sodass die vorbereitete Byte-Folge eines akzeptierten Passworts unverändert bleibt. Das verbleibende Risiko ist daher eine Policy-Abweichung, keine Byte-Abweichung: ein Passwort mit exotischer Schrift, das eine strengere Implementierung ganz ablehnen würde. Jedes von beiden Seiten akzeptierte Passwort hasht identisch, und das ist die Eigenschaft, von der Interoperabilität tatsächlich abhängt

Zum Schluss eine Delphi-Syntaxfalle, die eine Stunde kostet, wenn man noch nicht darauf gestoßen ist. Wenn eine Funktion einen prozeduralen Typ zurückgibt, ruft eine Zuweisung ohne Klammern sie nicht auf. Der Compiler liest Proc := GetNormalizeProc; als Adresse von GetNormalizeProc selbst, und meldet dann E2009 mit der wenig hilfreichen Klage, die Aufrufkonventionen würden sich unterscheiden, weil der Accessor die Standardkonvention verwendet, während der importierte API-Typ stdcall ist. Die leeren Klammern sind zwingend

type
  TNormalizeString = function(NormForm: Integer; SrcString: PWideChar; SrcLength: Integer;
    DstString: PWideChar; DstLength: Integer): Integer; stdcall;

function GetNormalizeProc: TNormalizeString;   // loads Normaliz.dll on first use
...
var
  Proc: TNormalizeString;
begin
  // Proc := GetNormalizeProc;   // E2009: reads as @GetNormalizeProc, conventions differ
  Proc := GetNormalizeProc();    // correct: calls the accessor and assigns its result
  if not Assigned(Proc) then
    Exit;                        // no NFKC available, mapped string is used as-is
end;

Passwortvorbereitung ist eines dieser Details, das nie in einer Feature-Liste auftaucht und trotzdem entscheidet, ob ein verschlüsseltes Dokument den Kontakt mit einem Kunden in einer anderen Locale übersteht. Die hier beschriebenen Einstiegspunkte Encrypt, EncryptFile, DecryptFile und SetPassword sind Teil der losLab PDF Developer Library Pascal Edition für Delphi und C++Builder, deren Produktseite die vollständige Verschlüsselungsreferenz und die komplette Fehlercode-Tabelle trägt