Technischer Artikel

PDF-Signature-Wrapping: ByteRange-Lücken, zweite Signatur

HotPDF, die Delphi-PDF-Komponente, weist Signature-Wrapping jetzt zurück: Seit v2.759.0 verlangen sowohl VerifyLoadedSignatureEx als auch der Batch-Validator, dass die Lücke zwischen den beiden /ByteRange-Segmenten exakt der /Contents-Hex-String ist, Trennzeichen eingeschlossen, und v2.761.0 bringt AddLoadedSignedSignatureField mit, sodass eine zweite Signatur an eine bereits signierte PDF als saubere inkrementelle Revision angehängt werden kann. Die beiden Änderungen gehören zusammen, denn eine korrekte zweite Signatur ist genau das Layout, das der strengere Verifizierer erwartet

Die Situation, die das Problem aufdeckte, ist alltäglich. Ein Vertrag wird vom Lieferanten signiert und dann an einen Freigeber weitergereicht, der gegenzeichnen muss, ohne die erste Signatur zu berühren. Die zweite Revision wird an die erste angehängt, ihr eigenes /ByteRange überspannt die ganze gewachsene Datei, und beide Signaturen sollten verifizieren. Das von Hand hinzubekommen hieß, eine inkrementelle Sektion selbst zu schreiben — und das Test-Fixture, das genau das tat, entpuppte sich als eine Lehrbuch-Struktur für Signature-Wrapping, die der alte Verifizierer gerne akzeptierte. Wenn Sie die Verifizierungs-API noch nicht kennen, behandelt die Anleitung zum Verifizieren digitaler PDF-Signaturen mit HotPDF die Grundlagen, auf denen dieser Artikel aufbaut

Was gehört genau in die ByteRange-Lücke?

Die Lücke muss den kompletten /Contents-Wert enthalten und nichts sonst: ISO 32000-1 §12.8.3.3 sagt, der Hexadezimalstring passt samt seiner <- und >-Trennzeichen exakt in den Platz zwischen den beiden Byte-Bereichen, und ISO 32000-2 §12.8.1 führt dieselbe Regel fort. Tabelle 252 und die PAdES-Dokumente sagen nur, dass der Digest den Contents-Wert ausschließt — leicht zu lesen als „schließt nur die Hex-Ziffern aus“. Frühere HotPDF-Releases haben es so gelesen: PreparePDFForSigning und die Streaming-CMS-Vorbereitung hash-ten auch die spitzen Klammern mit, mit einem Quellkommentar, der darauf bestand, die Klammern müssten abgedeckt sein. Validierer, die die Lücke mit dem Signaturwert vergleichen, flaggen das Layout als ungültigen Byte-Bereich, also schafft v2.759.0 beide Trennzeichen aus den signierten Bereichen heraus. Ein schneller unabhängiger Check auf jeder signierten Datei ist ein Blick auf zwei Bytes: Das Byte am Offset ByteRange[1] muss ein < sein, und das Byte am Offset ByteRange[2] - 1 muss ein > sein

Anatomie einer korrekt gefüllten PDF-Signatur-ByteRange in HotPDF: Der erste Bereich deckt die Datei ab Byte null ab, die Lücke hält den kompletten /Contents-Hex-String samt kleiner-als- und größer-als-Trennzeichen, der zweite Bereich deckt den Trailer bis zum Ende ab, und zwei Ein-Byte-Prüfungen an ByteRange[1] und ByteRange[2] - 1 bestätigen das Layout auf jeder signierten Datei
Seit v2.759.0 sitzen die Trennzeichen außerhalb der signierten Bereiche, der Digest deckt also nur die Ziffern ab, und die Lücke lässt sich Byte für Byte validieren

Warum verfehlt eine Nicht-leer-Prüfung der Lücke das Signature-Wrapping?

Eine Nicht-leer-Prüfung der Lücke beweist nur, dass etwas aus dem Digest ausgeschlossen wurde, nicht was — und genau das ist die ganze Angriffsfläche. Der /Contents-Platzhalter wird mit Tausenden Null-Ziffern reserviert, ein echtes CMS füllt ihn selten aus. Ein Angreifer kann den Hex-String mitten in dieser Null-Auffüllung mit einem > vorzeitig schließen, neue Objekte oder eine gefälschte Revision in den Rest des reservierten Platzes schreiben und die Byte-Bereiche unangetastet lassen. Die CMS-Signatur verifiziert weiterhin, weil jedes signierte Byte unverändert ist, die Bereiche weiterhin bei 0 beginnen und bei der Dateigröße enden, und der alte HotPDF-Verifizierer meldete svValid mit gesetztem CoversWholeDocument, also True. Ein PDF-Reader parst derweil, was immer in diesem unsignierten Loch sitzt

HotPDF behandelt die Lücke jetzt als Daten, die Byte für Byte zu validieren sind. Der Verifizierer liest die Lücke, streift die Trennzeichen ab, akzeptiert nur Hex-Ziffern plus PDF-Whitespace (Tab, Line Feed, Form Feed, Carriage Return, Leerzeichen), dekodiert die Ziffern und verlangt, dass das Ergebnis exakt dem /Contents des Signatur-Dictionarys entspricht. Alles andere stuft das Ergebnis auf svInvalidByteRange herab. Der Check läuft sowohl im Einzel-Signatur-Pfad als auch in ValidateLoadedSignatureBatch, das seine eigene Abdeckungslogik mitbrachte und denselben Fix brauchte. Dateien, die HotPDF vor v2.759.0 erzeugt hat und deren Lücke nur Ziffern hielt, während die Klammern knapp innerhalb der Bereiche saßen, verifizieren weiterhin — archivierte Dokumente werden also nicht plötzlich rot

Wie Signature-Wrapping eine lax geprüfte PDF-ByteRange in Delphi ausnutzt: Der Angreifer schließt den Hex-String vorzeitig innerhalb Tausender reservierter Null-Ziffern, schreibt eine gefälschte Revision in die unsignierte Lücke, ohne ein abgedecktes Byte anzufassen, und der alte HotPDF-Check meldete svValid mit CoversWholeDocument true, bis v2.759.0 begann, die Lücke Byte für Byte zu validieren
Eine nicht leere Lücke beweist nur, dass etwas aus dem Digest ausgeschlossen wurde, nicht was — das gepolsterte Loch ist die ganze Angriffsfläche
var
  Pdf: THotPDF;
  Info: THPDFSignatureInfo;
  Status: THPDFSignatureVerifyStatus;
  I: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('SignedTwice.pdf');
    for I := 0 to Pdf.GetLoadedSignatureFieldCount - 1 do
    begin
      Status := Pdf.VerifyLoadedSignatureEx(I, Info);
      case Status of
        svValid:
          if Info.CoversWholeDocument then
            Writeln(Info.FieldName, ': valid, covers the whole file')
          else
            Writeln(Info.FieldName, ': valid, ',
              Info.UnsignedTrailingBytes, ' bytes appended later');
        svInvalidByteRange:
          Writeln(Info.FieldName, ': ByteRange gap rejected (wrapping?)');
      else
        Writeln(Info.FieldName, ': failed, status ', Ord(Status));
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

Wie fügen Sie einer bereits signierten PDF eine zweite Signatur hinzu?

Öffnen Sie die signierte Datei mit BeginIncrementalUpdate, rufen Sie AddLoadedSignedSignatureField auf, speichern Sie mit SaveIncrementalUpdate, und signieren Sie dann die vorbereitete Datei mit der Klassenfunktion THotPDF.SignPDFWithPFX. Vor v2.761.0 konnte das dokumentierte Rezept, nach BeginIncrementalUpdate ein THPDFPage.AddSignedSignatureField aufzurufen, nicht funktionieren, denn CurrentPage ist im inkrementellen Modus nil, und nichts konnte einen /V-Platzhalter an ein Feld auf einem geladenen Dokument hängen. Die neue Methode erzeugt das Widget auf der geladenen Seite und hängt dasselbe Platzhalter-Dictionary, das der Pfad für neue Dokumente benutzt, unter /V an, beide Signier-Routinen teilen sich also eine Serialisierung. Für die erste Signatur selbst geht der Artikel zum Erstellen digitaler PAdES-Signaturen in Delphi die PFX-Pipeline durch

var
  Pdf: THotPDF;
  FieldIndex: Integer;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.BeginIncrementalUpdate('Signed.pdf');
    // Seite 0, Widget-Rechteck in Punkt, 8192 Bytes für das CMS reserviert
    FieldIndex := Pdf.AddLoadedSignedSignatureField(0, 320, 60, 520, 120,
      'ApproverSignature', 8192);
    if FieldIndex < 0 then
      raise Exception.Create('Page index out of range');
    Pdf.SaveIncrementalUpdate('Prepared.pdf');
  finally
    Pdf.Free;
  end;

  if not THotPDF.SignPDFWithPFX('Prepared.pdf', 'SignedTwice.pdf',
    'approver.pfx', 'pfx-password') then
    raise Exception.Create('Second signature failed');
end;

AddLoadedSignedSignatureField ist absichtlich leiser als seine Geschwister. Die anderen AddLoaded*-Feld-Erzeuger setzen /NeedAppearances true aufs AcroForm, was einen Viewer auffordert, Feld-Appearances neu zu erzeugen; auf einem signierten Dokument kann diese Neuerzeugung signierten Inhalt umschreiben, also entfernt die neue Methode das Flag wieder, sofern die Quelle es nicht schon hatte. /SigFlags behält seinen ursprünglichen Wert OR 3 (SignaturesExist plus AppendOnly, ISO 32000-1, Tabelle 219). Sie müssen auch kein MarkDirty auf der Seite aufrufen: Das Anfügen an /Annots und /Fields pflanzt das Dirty-Flag auf das besitzende indirekte Objekt fort, und eine explizite Seitenmarkierung würde nur ein unverändertes Seiten-Dictionary in die neue Revision ziehen, das die Revisionsanalyse dann als Seitenmodifikation meldete. Schließlich schreibt der Platzhalter /ByteRange vor /Contents, denn der Patcher ortet den /ByteRange-Sentinel zuerst und sucht von dort vorwärts nach dem passenden Hex-String

Der HotPDF-Delphi-Ablauf zum Gegenzeichnen einer bereits signierten PDF: BeginIncrementalUpdate öffnet die Datei, AddLoadedSignedSignatureField erzeugt das Widget und reserviert den /Contents-Platzhalter, SaveIncrementalUpdate hängt eine zweite Revision an, und SignPDFWithPFX füllt sie — die erste Signatur bleibt gültig mit UnsignedTrailingBytes, während die neue ByteRange die ganze gewachsene Datei überspannt
Ein Platzhalter pro Revision, vorbereitet und gepatcht von derselben Serialisierung auf beiden Signier-Routinen — das saubere Layout, das der strengere Verifizierer erwartet

Was ändert sich, wenn ein externer Signer oder ein HSM das CMS liefert?

Am Ablauf ändert sich nichts, aber die Offsets bedeuten jetzt, was die Spezifikation sagt. PreparePDFForSigning liefert zwei nullbasierte Bereiche, deren Lücke der ganze /Contents-String ist, und ContentsHexStart ist der einbasierte Index der ersten Hex-Ziffer im AnsiString. Ein kürzeres CMS wird am Ende mit 0 aufgefüllt, vor dem schließenden >. Weil PreparePDFForSigning den ersten ungepatchten Sentinel patcht, den es findet, bereiten Sie genau einen Platzhalter pro Revision vor, und ziehen Sie InsertSignatureHexAt mit den zurückgegebenen Offsets dem suchbasierten InsertSignatureHex vor, wenn sich bereits frühere Signaturen in der Datei befinden

var
  Bytes, ToSign, CmsHex: AnsiString;
  R1Start, R1Len, R2Start, R2Len, HexStart, HexLen: Integer;
begin
  Bytes := LoadFileAsAnsiString('Prepared.pdf');   // Ihr Helfer
  if not THotPDF.PreparePDFForSigning(Bytes, R1Start, R1Len,
    R2Start, R2Len, HexStart, HexLen) then
    raise Exception.Create('No signature placeholder found');

  // Die Lücke ist der ganze Hex-String: '<' beendet Bereich 1, '>' geht Bereich 2 voraus
  Assert(Bytes[R1Start + R1Len + 1] = '<');
  Assert(Bytes[R2Start] = '>');

  ToSign := Copy(Bytes, R1Start + 1, R1Len) + Copy(Bytes, R2Start + 1, R2Len);
  CmsHex := SignDetachedWithHsm(ToSign);           // Ihr CMS-Signer, Hex-DER
  if not THotPDF.InsertSignatureHexAt(Bytes, HexStart, HexLen, CmsHex) then
    raise Exception.Create('CMS does not fit the reserved space');
  SaveAnsiStringToFile(Bytes, 'SignedTwice.pdf');  // Ihr Helfer
end;

Wo liegen die Grenzen der neuen Prüfungen?

Der Lücken-Check schließt ein bestimmtes Loch und sollte nicht überverkauft werden. svValid bedeutet weiterhin Byte-Integrität plus einen Schlüssel, der zum eingebetteten Zertifikat passt; das Vertrauen in dieses Zertifikat ist eine eigene Entscheidung. Die Lücke wird nur validiert, wenn der Verifizierer Quell-Bytes hat, die VerifyLoadedSignatureEx aus der geladenen Datei liest und die TStream-Overloads von Ihnen bekommen. Für die erste Signatur in einer gegenzeichneten Datei ist CoversWholeDocument korrekt False, und ob die angehängte Revision nur eine Signatur hinzugefügt oder auch Seiten verändert hat, ist eine Frage für DocMDP, FieldMDP und Revisionsanalyse in HotPDF. Beachten Sie auch, dass der angehängte PDF-MAC-Check Offsets gegen die <- und >-Positionen vergleicht und beide Layouts akzeptiert; jedes eigene Tool, das die Offsets vor v2.759.0 fest verdrahtet, scheitert zuerst, sobald es auf eine frisch signierte Datei trifft

Signiert, gegenzeichnet oder auditiert Ihre Delphi- oder C++Builder-Anwendung PDFs, ist der sicherste Weg, eine Bibliothek dasselbe Layout erzeugen und verifizieren zu lassen. HotPDF, die native Delphi PDF Component schifft die strengere Lückenvalidierung, inkrementelle zweite Signaturen und die externen Signer-Hooks von oben in einer einzigen Komponente