Technischer Artikel

/Contents in verschlüsselten PDFs bleibt im Klartext

Ein AES-verschlüsselter Vertrag, der an anderer Stelle signiert wurde, lädt mit einem Signaturwert, der zu Zufallsbytes dekodiert, oder unter AES-GCM mit einem Signatur-Dictionary, das sich nicht entschlüsseln lässt. Außerhalb des Trailer-/ID und des Encryption-Dictionary lässt ISO 32000-2 §7.6.2 nur die Signatur-/Contents unverschlüsselt, und PDFlibPas hält sie beim Lesen und Schreiben im Klartext, während /Reason und /M verschlüsselt bleiben

Die Ausnahme, die ISO 32000-2 in die Verschlüsselungsregeln aufgenommen hat

ISO 32000-2 §7.6.2 sagt, dass Verschlüsselung jeden String und Stream in der Datei abdeckt, mit vier Ausnahmen:

  • die Werte des Trailer-Eintrags /ID
  • Strings im /Encrypt-Dictionary
  • Strings in Streams, die als Ganzes verschlüsselt werden
  • jeder Hexadezimal-String, der der Wert von /Contents in einem Signatur-Dictionary ist

Die ersten drei sind alt. ISO 32000-1 §7.6.1 listet nur diese drei auf, also hat ein Decryptor, der gegen den Text von 2008 geschrieben ist, keine Anweisung, irgendetwas in einem Signatur-Dictionary zu überspringen. Signing-Tools haben den Wert trotzdem im Klartext geschrieben. Der Grund ist mechanisch — ein Signierer reserviert einen Hex-Platzhalter, schreibt die ganze Datei, hasht alles außer dem Platzhalter, baut das CMS und patcht seinen Hex-Wert in die Lücke. Dieser Digest deckt die Bytes ab, wie sie auf der Platte liegen, Ciphertext eingeschlossen. Den Signaturwert zu verschlüsseln würde nichts schützen, und es würde den Patch-Schritt zwingen, den File-Key zu kennen und ein IV plus Padding in einen Platzhalter zu pressen, dessen Größe feststand, bevor der Hash existierte. Der Rest des Dictionary folgt den normalen Regeln:

Eintrag im Signatur-DictionaryPDF-TypVerschlüsselt gespeichert?
/ContentsHexadezimal-StringNein, ausgenommen nach ISO 32000-2 §7.6.2
/Reason, /Location, /ContactInfo, /NameText-StringJa
/MDatums-StringJa
/ByteRangeArray von IntegersNein, Integers werden nie verschlüsselt
/Type, /Filter, /SubFilterNameNein, Namen werden nie verschlüsselt

Ein Reader, der die Ausnahme übersieht, setzt die Chiffre über blankes DER an. Was herauskommt, hängt vom Algorithmus ab. RC4 ist eine Stream-Chiffre, also behält der Output seine Länge und wird zu Rauschen; der CMS-Parser sieht ein erstes Byte, das nicht mehr 0x30 ist, und meldet eine malformed Signatur. AES-CBC behandelt die ersten 16 Bytes als Initialisierungsvektor und den Rest als Ciphertext, was in einem Padding-Fehler oder einem kürzeren Müll-Blob endet. AES-GCM, für PDF durch ISO/TS 32003 definiert, ist der härteste Fall. Jeder verschlüsselte String trägt seine eigene Nonce und seinen Authentication-Tag, und ein Klartext-Wert kann die Tag-Prüfung nicht bestehen. Ein Decryptor, der einen failed Tag als fatal für das ganze Dictionary behandelt, verliert dann /Reason und /M zusammen mit dem Signaturwert

PDFlibPas verschlüsseltes PDF-Signatur-Dictionary in Delphi mit hexadezimaler /Contents als Klartext nach ISO 32000-2 §7.6.2, während /Reason und /M verschlüsselt bleiben, plus RC4-, AES-CBC- und AES-GCM-Fehlermodi
Signatur-/Contents bleibt in einem verschlüsselten PDF lesbares DER, während /Reason und /M Ciphertext sind, und dieses DER trotzdem zu entschlüsseln scheitert unter jeder Chiffre anders

Wie unterscheiden Sie eine Signatur-/Contents von jedem anderen /Contents?

Auf den Schlüsselnamen zu matchen ist der naheliegende erste Versuch. Er scheitert in beide Richtungen. Annotation-Dictionarys haben ebenfalls einen /Contents-Eintrag — einen Text-String mit dem Kommentar, den ein Prüfer getippt hat — und dieser String muss verschlüsselt werden. Überspringen Sie ihn, und jede Notiz in einer vertraulichen Datei ist ohne das Passwort lesbar. Auf /Type /Sig zu matchen scheitert andersrum. ISO 32000-2 Tabelle 255 macht /Type in einem Signatur-Dictionary optional, mit Sig als Default, und verlangt es nur für /DocTimeStamp, also darf ein konformer Producer es weglassen. PDFlibPas behandelt einen /Contents-String daher nur dann als Signaturwert, wenn dasselbe Dictionary ein /ByteRange-Array trägt und dazu entweder einen /Type von Sig oder DocTimeStamp, oder gar keinen /Type neben einem /Filter-Namen. /Filter ist in jedem Signatur-Dictionary Pflicht und /ByteRange in jeder Signatur, die zu einem Signaturfeld gehört; eine Annotation hat beides nicht. Die Bibliothek führt denselben Test aus, wenn sie beim Speichern verschlüsselt und wenn sie beim Laden entschlüsselt, also kommt /Contents aus einem Laden mit den Bytes heraus, die der Producer geschrieben hat

PDFlibPas Verschlüsselungstest in Delphi unterscheidet eine Signatur-/Contents von einer Annotation-/Contents über /ByteRange, /Type Sig oder DocTimeStamp und /Filter, bevor der String verschlüsselt oder entschlüsselt wird
Annotation-Kommentare bleiben verschlüsselt, weil PDFlibPas /Contents nur neben /ByteRange und einem passenden /Type als Signaturwert behandelt, oder einem /Filter-Namen, wenn /Type fehlt

Erst verschlüsseln, dann mit PDFlibPas signieren

Signieren Sie zuletzt. Der Signatur-Digest deckt gespeicherte Bytes ab, also bricht jede Operation, die die Datei nach dem Signieren umschreibt, jede Signatur darin. Verschlüsselung auf ein signiertes Dokument anzuwenden verschlüsselt jeden String und Stream neu; Verschlüsselung zu entfernen schreibt sie genauso gründlich um. Die Sequenz, die funktioniert: verschlüsseln, speichern, dann die verschlüsselte Datei signieren. Der Sign-Prozess hängt ein inkrementelles Update an, dessen neue Strings mit dem vorhandenen File-Key verschlüsselt werden und dessen /Contents-Platzhalter im Klartext bleibt. Öffnen Sie die Datei im Sign-Prozess mit dem Owner-Passwort; ein User-Passwort genügt nur, wenn die Permissions Bit 9 von ISO 32000-2 Tabelle 22 enthalten, das Signaturfelder explizit nennt. Encrypt und der Sign-Prozess sind in jeder PDFlibPas-Edition verfügbar

uses
  SysUtils, PDFlibrary;

const
  OwnerPwd = 'owner-secret';
  UserPwd  = 'reader-secret';

procedure EncryptCopy(const Source, Target: string);
var
  Lib: TPDFlib;
  Perms: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(Source, '') <> 1 then
      raise Exception.CreateFmt('Load failed (%d)', [Lib.LastErrorCode]);
    // Drucken, Felder füllen (Bit 9 deckt Signaturfelder ab), Accessibility-Kopie, Hochauflösung drucken
    Perms := Lib.EncodePermissions(1, 0, 0, 0, 1, 1, 0, 1);
    // Strength 4 = AES-256 Revision 6: File-Key wird direkt verwendet, /ID ist nicht Teil davon
    if Lib.Encrypt(OwnerPwd, UserPwd, 4, Perms) <> 1 then
      raise Exception.CreateFmt('Encrypt failed (%d)', [Lib.LastErrorCode]);
    if Lib.SaveToFile(Target) <> 1 then
      raise Exception.CreateFmt('Save failed (%d)', [Lib.LastErrorCode]);
  finally
    Lib.Free;
  end;
end;

procedure SignField(Lib: TPDFlib; const Source, Target, FieldName,
  PFXFile, PFXPassword: string);
var
  SP, R: Integer;
begin
  SP := Lib.NewSignProcessFromFile(Source, OwnerPwd);
  if SP = 0 then
    raise Exception.CreateFmt('Input not found: %s', [Source]);
  try
    Lib.SetSignProcessField(SP, FieldName);   // wird unsichtbar angelegt, wenn er fehlt
    Lib.SetSignProcessPFXFromFile(SP, PFXFile, PFXPassword);
    Lib.SetSignProcessInfo(SP, 'Approved for release', 'Head office', '');
    // Reserve im Platzhalter für einen später hinzugefügten Signatur-Zeitstempel
    Lib.SetSignProcessReserveContentsBytes(SP, 16384);
    Lib.EndSignProcessToFile(SP, Target);     // gibt immer 0 zurück
    R := Lib.GetSignProcessResult(SP);
    if R <> 1 then
      raise Exception.CreateFmt('Signing %s failed (result %d)', [FieldName, R]);
  finally
    Lib.ReleaseSignProcess(SP);
  end;
end;

Strength 4 wählt AES-256 Revision 6, und diese Wahl eliminiert zwei Fehlerquellen. Unter RC4 und AES-128 leitet Algorithmus 1 (ISO 32000-1 §7.6.2) für jedes Objekt einen eigenen Schlüssel aus Objektnummer und Generationsnummer ab. Algorithmus 2 (ISO 32000-1 §7.6.3.3, Schritt e) speist außerdem das erste /ID-Element in den File-Key ein. Ein Signier-Update schreibt das AcroForm-Dictionary und das /Annots-Array der Seite um, also muss jedes umgeschriebene Objekt seine ursprüngliche Generationsnummer behalten, und der Trailer des Updates muss dasselbe erste /ID-Element tragen. Sitzt die Generation falsch, entschlüsselt jenes Objekt nicht mehr. Sitzt die /ID falsch, entschlüsselt nichts mehr. Revision 6 verwendet den File-Key direkt (ISO 32000-2 §7.6.3.3, Algorithmus 1.A) und holt ihn, ohne /ID anzusehen (§7.6.4.3.3, Algorithmus 2.A). PDFlibPas behält Generationsnummern auf umgeschriebenen Objekten bei jeder Strength bei und leitet den Schlüssel aus der gespeicherten /ID ab, selbst wenn eine malformed Datei /ID 7 0 R trägt statt des direkten Arrays, das ISO 32000-2 Tabelle 15 verlangt; eine leere /ID [] fällt durch die Passwortprüfung. Der Pfad vom Passwort zum Key wird in verschlüsselte PDFs mit einem Raw-File-Key öffnen tiefer verfolgt

Ein Texteditor genügt, um das Ergebnis zu sehen. Ein Signatur-Dictionary liegt unkomprimiert in dem Update, das es hinzugefügt hat, weil der Signierer /ByteRange und /Contents per File-Offset patcht. Suchen Sie nach /ByteRange, und Sie landen darauf. Der /Contents-Hex beginnt typischerweise mit 3082, dem DER-Header der CMS SignedData SEQUENCE, und endet in einer langen Nullen-Folge, wo der Platzhalter größer war als die Signatur. /Reason ist Ciphertext, der von einem Signierlauf zum nächsten wechselt, selbst wenn der Text sich nicht ändert, weil AES pro String einen frischen IV zieht. Erwartet mit einem aktuellen qpdf: qpdf --password=owner-secret --show-object=14 signed.pdf gibt das entschlüsselte /Reason neben einem unveränderten /Contents aus. Nehmen Sie für diese Prüfung eine Strength-4-Datei. Erwartete Gestalt in der Rohdatei, mit variierenden Offsets und Bytes:

14 0 obj
<</Type /Sig /Filter /Adobe.PPKLite
  /ByteRange [0 48211 81093 2304]
  /Contents <308240A206092A864886F70D010702A0...000000000000>
  /Reason <7F21C9E04B...>
  /M <D35A0F98...>
  ...>>
endobj

Welche Signaturen brechen, wenn ein Zeitstempel eingepatcht wird?

Jede Signatur, die nach der gestempelten hinzukommt. Ein PAdES-Signatur-Zeitstempel ist ein RFC 3161 Token, gespeichert als unsigned Attribute des Signierers (RFC 5652 §5.3, unsignedAttrs), also lebt er im CMS innerhalb von /Contents. Einen hinzuzufügen heißt, diesen Hex an Ort und Stelle umzuschreiben. In einer verschlüsselten Datei funktioniert dieses Umschreiben nur, weil /Contents Klartext ist: keine Neuverschlüsselung, kein neues Objekt — der Token muss nur in die Reserve passen, die beim Signieren angelegt wurde. Die besitzende Signatur überlebt, weil ihre /ByteRange diese Bytes überspringt. Spätere Signaturen bekommen keine solche Ausnahme. ISO 32000-2 §12.8.1 lässt die Byte-Range jeder Signatur vom %PDF--Header bis zum Ende des inkrementellen Updates laufen, das sie hinzugefügt hat. Eine zweite Signatur oder ein Dokument-Zeitstempel hasht daher die /Contents der ersten Signatur zusammen mit allem anderen mit. Die Lücke gehört einer einzigen Signatur. Patchen Sie sie, nachdem ein zweiter Signierer unterschrieben hat, passt der Digest der zweiten Signatur nicht mehr. Die Token-Anfrage selbst ist Teil von PAdES-Signierung und -Validierung aus Delphi; dieser Abschnitt handelt nur davon, welche Bytes sich bewegen

PDFlibPas prüft die Überlappung, bevor irgendetwas gehasht wird. Der Signatur-Zeitstempel-Prozess liest die /ByteRange jeder anderen Signatur im Dokument und vergleicht jedes Spannen-Paar mit der /Contents-Lücke des Ziels. Jede Überlappung stoppt die Vorbereitung mit Prozess-Ergebnis 17, und keine Ausgabedatei wird geschrieben. Der Prozess gehört zur Enterprise-Edition, die Sie als TPDFlibEnterprise aus der Unit PDFlibEnterprise instanziieren; andere Editionen behalten die Methoden, die 0 zurückgeben und LastErrorCode auf 900 setzen. Behalten Sie die Zahl 17 in Ihren Logs im Auge. Von GetPAdESSignatureTimeStampProcessResult heißt sie, dass eine spätere Signatur diese überdeckt; von GetSignProcessResult heißt sie, dass ein Certification-Level oder ein Field-Lock das Signieren verbietet. Ordnen Sie die beiden getrennt zu. Der Helper unten verwendet SignField und OwnerPwd aus dem vorigen Listing

PDFlibPas PAdES-Signatur-Zeitstempel-Reihenfolge in Delphi: eine spätere Signatur's /ByteRange überdeckt eine frühere /Contents-Lücke, GetPAdESSignatureTimeStampProcessResult gibt 17 zurück, keine Datei wird geschrieben
Sobald Approver signiert hat, deckt seine Byte-Range die Author-/Contents-Lücke ab, und PDFlibPas verweigert den späten Author-Zeitstempel mit Prozess-Ergebnis 17, statt die spätere Signatur zu brechen
uses
  SysUtils, PDFlibrary, PDFlibEnterprise;

const
  STS_COVERED_BY_LATER_SIGNATURE = 17;

function StampSignature(Lib: TPDFlibEnterprise; const Source, Target,
  FieldName: string): Integer;
var
  STS: Integer;
begin
  Result := 0;
  STS := Lib.NewPAdESSignatureTimeStampProcessFromFile(Source, OwnerPwd);
  if STS = 0 then
    Exit;
  try
    Lib.SetPAdESSignatureTimeStampField(STS, FieldName);
    if Lib.ApplyPAdESSignatureTimeStamp(STS, 0) <> 1 then
    begin
      Result := Lib.GetPAdESSignatureTimeStampProcessResult(STS);
      if Result = 1 then
        Result := -1;  // Wert gehasht, TSA-Austausch gescheitert: siehe GetTimeStampError
      Exit;
    end;
    Result := Lib.EndPAdESSignatureTimeStampProcessToFile(STS, Target);
  finally
    Lib.ReleasePAdESSignatureTimeStampProcess(STS);
  end;
end;

procedure SignAndStampInOrder(const EncryptedFile: string);
var
  Lib: TPDFlibEnterprise;
  R: Integer;
begin
  Lib := TPDFlibEnterprise.Create;
  try
    Lib.SetTimeStampServer('https://tsa.example.com/tsa', '', '');

    SignField(Lib, EncryptedFile, 'step1.pdf', 'Author', 'author.pfx', 'pfx-1');
    R := StampSignature(Lib, 'step1.pdf', 'step1-ts.pdf', 'Author');
    if R <> 1 then
      raise Exception.CreateFmt('Author time stamp failed (%d)', [R]);

    SignField(Lib, 'step1-ts.pdf', 'step2.pdf', 'Approver', 'approver.pfx', 'pfx-2');
    R := StampSignature(Lib, 'step2.pdf', 'final.pdf', 'Approver');
    if R <> 1 then
      raise Exception.CreateFmt('Approver time stamp failed (%d)', [R]);

    // Erwartet: 17. Approvers Byte-Range überdeckt Authors /Contents in
    // step2.pdf, also verweigert der Prozess, und restamped.pdf wird nie geschrieben
    R := StampSignature(Lib, 'step2.pdf', 'restamped.pdf', 'Author');
    if R <> STS_COVERED_BY_LATER_SIGNATURE then
      raise Exception.CreateFmt('Unexpected result %d', [R]);
  finally
    Lib.Free;
  end;
end;

Stempeln Sie jede Signatur, bevor der nächste Signierer die Datei anfasst. Es kostet einen TSA-Roundtrip pro Signierer und gibt jeder Signatur ihre eigene vertrauenswürdige Zeit. Eine Datei, die mit mehreren Signaturen schon ankommt, ist ein anderer Fall: Die früheren können keinen Signatur-Zeitstempel mehr bekommen. Hängen Sie stattdessen einen Dokument-Zeitstempel an — eine /Type /DocTimeStamp-Signatur mit /SubFilter /ETSI.RFC3161 in einer neuen Revision, deren Byte-Range alle früheren Signaturen abdeckt und belegt, dass sie in diesem Moment existierten. ApplyPAdESLongTermArchiveValidation fügt einen hinzu, nachdem es die Validierungs-Materialien angehängt hat. Reservieren Sie beim Signieren Platz für einen Signatur-Zeitstempel, über SetSignProcessReserveContentsBytes wie im ersten Listing. Ein Token, das die TSA-Zertifikatskette einbettet, läuft leicht auf mehrere Kilobytes, und ein Platzhalter ohne diese Reserve lässt die spätere Augmentation mit Ergebnis 13 scheitern

Eine signierte Datei entschlüsseln und was ein Verifizierer hasht

Die signierten Ranges einer verschlüsselten Datei sind Ciphertext. Ein Verifizierer vergleicht einen Digest des gespeicherten Ciphertexts mit dem Digest im CMS: /ByteRange sind Integers, /Contents ist Klartext, und die Integritätsprüfung braucht keinen Key — nur der Feldname und /Reason brauchen einen. Dasselbe Faktum erklärt, warum eine signierte Datei zu entschlüsseln und plain zu speichern jede Signatur darin ungültig macht, obwohl kein einziges sichtbares Pixel sich änderte. Bewahren Sie das verschlüsselte, signierte Original als Referenzkopie auf. Cross-Reference-Streams werden ebenfalls nie verschlüsselt (ISO 32000-2 §7.5.8.2), also behält ein Update, das mit einem xref-Stream geschrieben wurde, seine Offsets lesbar, bevor irgendein Key abgeleitet ist. Das Layout beschreibt Object Streams und Cross-Reference Streams in Delphi. Ist eine signierte Range zu groß, um sie zum Vergleichen in den Speicher zu lesen, meldet PDFlibPas die Integritätsprüfung als unbekannt statt als ungültig, denn Bytes, die nie verglichen wurden, sind nicht als unterschiedlich nachgewiesen

Der Signaturwert einer verschlüsselten Datei bleibt nach dem Signieren schreibbar, bis eine andere Signatur oder ein Dokument-Zeitstempel ihn überdeckt. Legen Sie den Zeitstempel-Schritt in dieses Fenster, und die Encrypt-then-Sign-Sequenz aus dem ersten Listing überträgt sich unverändert auf Multi-Signierer-Dateien in PDF Library for Delphi