Technischer Artikel

Wo endet eine PDF-Revision? Forgierte Updates nach Signatur

Ein Vertrag wird signiert, bekommt dann noch ein Update, und ein Audit-Skript schlägt dieses Update zur signierten Revision oder lässt die umgeschriebene Seite als Validierungs-Material durch. Beides passiert nicht mehr, sobald eine Revision dort endet, wo startxref einen Abschnitt nennt, den die /Prev-Kette erreicht hat, und ein umgeschriebenes Objekt seine frühere Klasse behält — die beiden Regeln hinter der PDFlibPas-Revision-Map

Warum %%EOF der falsche Ort ist, um eine Datei zu schneiden

Jedes inkrementelle Update wird von seinem eigenen End-of-File-Marker abgeschlossen (ISO 32000-1 §7.5.6), also sieht das Schneiden der Datei an jedem %%EOF wie der naheliegende Weg aus, um Revisionen zu finden. Der Marker sind aber nur fünf Bytes, und nichts hindert diese Bytes daran, in einem Content-Stream, einer eingebetteten Datei oder einem Font-Programm aufzutauchen. Linearisierte Dateien bringen ein zweites Problem mit: Anhang F (§F.3.4) lässt den First-Page-Trailer mit seinem eigenen startxref und %%EOF nahe am Anfang der Datei enden, weit vor der Haupt-Cross-Reference-Tabelle. An jedem Marker zu schneiden erfindet Revisionen, die kein Cross-Reference-Abschnitt stützt. Zu raten, welche Marker fake sind, ist schlimmer — dieses Raten ist es, das ein Fälscher formen darf. Die PDFlibPas-Revision-Map arbeitet andersherum. Sie folgt der /Prev-Kette, notiert das Byte-Offset jedes Cross-Reference-Abschnitts, den die Kette tatsächlich gelesen hat, und sucht erst danach nach startxref N … %%EOF-Sequenzen, wobei sie eine Grenze nur behält, wenn N eines dieser notierten Offsets ist. Ein verirrter Marker in einem Stream bräuchte einen startxref vor sich, der einen echten Abschnitt nennt, um überhaupt zu etwas zu zählen

PDFlibPas Revision-Map in Delphi folgt der /Prev-Kette und matcht startxref-Offsets, um inkrementelle Update-Revisionsgrenzen zu finden, und ignoriert verirrte %%EOF-Marker in Streams und linearisierten First-Page-Trailern
Nur startxref-Offsets, die die /Prev-Kette tatsächlich gelesen hat, markieren ein Revisionsende, sodass verirrte %%EOF-Bytes in Streams keinen Einfluss darauf haben, wo PDFlibPas die Datei schneidet

Die Bytes zwischen diesen drei Tokens sind weniger ordentlich, als §7.5.5 suggeriert. Die Norm zeichnet startxref, den Offset und den Marker auf aufeinanderfolgenden Zeilen, doch End-of-Line-Marker und die anderen White-Space-Zeichen aus §7.2.2 (Tabelle 1) dürfen sich wiederholen, und keine Regel begrenzt, wie viele. Verpassen Sie einen gepolsterten Marker, und zwei Revisionen verschmelzen. Die signierte scheint dann zu enden, nachdem Bytes kamen, die niemand signiert hat. Die PDFlibPas-Revision-Map folgt der Sequenz vom Keyword zum Operanden zum Marker, egal wie viel White Space sie trennt und wo dieses Padding in der Datei liegt, und sie zählt jedes Keyword nur einmal. Das Trailer-Keyword hat ein ähnlich loses Gelenk: << ist ein Delimiter (§7.2.2, Tabelle 2), und §7.5.5 verlangt nie einen Zeilenumbruch nach trailer, also ist trailer<< auf einer Zeile konforme Syntax. Zeile für Zeile gelesen sieht diese Zeile wie ein Cross-Reference-Subsection-Header aus — und die Kette stoppt auf halbem Weg

Die Revision-Map mit PDFlibPas durchgehen

Die Revision-Accessors sind auf TPDFlib deklariert und in der Small-Business-Edition implementiert: Erzeugen Sie eine TPDFlibSmallBusiness aus der Unit PDFlibSmallBusiness (Enterprise leitet davon ab und führt dieselben Aufrufe mit). Auf einem nackten TPDFlib geben sie ihren Fehlerwert zurück (0, 7 oder -1) und setzen LastErrorCode auf PDFLIB_ERROR_SMALLBUSINESS_REQUIRED. Revisionen werden ab 0 gezählt, dem Originaldokument. Jeder Aufruf nimmt einen Dateinamen und ein Passwort entgegen und baut die Map aus den Bytes auf der Platte, also beschreiben die Antworten die Datei, wie sie gespeichert ist, nie ein Dokument, das Sie zufällig geladen und bearbeitet haben. GetDocumentRevisionKind meldet die weitreichendste Änderung einer Revision. Ein Update, das eine OCSP-Response hinzufügt und zugleich eine Seite umschreibt, wird als Seitenumschreibung gemeldet:

ArtBedeutungTypische Quelle
0Nur Housekeeping: Objekte, die nichts ändern, was ein Reader siehtCross-Reference-Stream, ein neuer Object-Stream-Container
1Validierungs-MaterialDocument Security Store, VRI, Zertifikate, CRLs, OCSP-Responses
2Dokument-ZeitstempelEine /DocTimeStamp-Signatur und das Feld, das sie trägt
3SignaturEin bestehendes Signaturfeld, das seinen Wert empfängt
4FormularfeldEin Feldwert oder seine Appearance
5AnnotationJede Annotation außer einem Widget
6DokumentinhaltAlles, was ändern kann, was das Dokument zeigt
7Konnte nicht bestimmt werdenEtwas in der Revision war nicht lesbar; behandeln Sie sie als unsicher
uses
  System.SysUtils, PDFlibSmallBusiness;

procedure ListRevisions(const FileName, Password: WideString);
const
  KindNames: array[0..7] of string = ('housekeeping', 'validation material',
    'document time stamp', 'signature', 'form field', 'annotation',
    'content', 'undetermined');
var
  Pdf: TPDFlibSmallBusiness;
  Count, I, J, Kind, Changes: Integer;
begin
  Pdf := TPDFlibSmallBusiness.Create;
  try
    Count := Pdf.GetDocumentRevisionCount(FileName, Password);
    if Count = 0 then
      raise Exception.Create('the cross-reference chain could not be followed');
    if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
      Writeln('WARNING: history incomplete, every figure below is a lower bound');
    for I := 0 to Count - 1 do
    begin
      Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
      if (Kind < 0) or (Kind > 7) then
        Kind := 7;
      Writeln(Format('revision %d: bytes %d..%d, %s', [I,
        Pdf.GetDocumentRevisionStart(FileName, Password, I),
        Pdf.GetDocumentRevisionEnd(FileName, Password, I),
        KindNames[Kind]]));
      // eine 'objnum,change,class,detail'-Zeile pro Objekt
      Changes := Pdf.GetDocumentRevisionChanges(FileName, Password, I);
      if Changes <> 0 then
      try
        for J := 1 to Pdf.GetStringListCount(Changes) do  // Einträge zählen ab 1
          Writeln('    ' + Pdf.GetStringListItem(Changes, J));
      finally
        Pdf.ReleaseStringList(Changes);
      end;
    end;
  finally
    Pdf.Free;
  end;
end;

GetDocumentRevisionsComplete ist der Aufruf, den man lesen sollte, bevor man den anderen vertraut. Er gibt 1 nur zurück, wenn die Kette bis zurück zum Original-Abschnitt verfolgt wurde; ein gebrochenes oder zyklisches /Prev oder eine Kette, tiefer als 256 Glieder, lässt die ältesten Updates ungesehen, und jede Zählung und jeder Index wird dann eine Untergrenze. Eine Tiefe von 256 lässt Raum für die Speicher-und-Signier-Historie echter Dateien, und eine Kette, die auf sich selbst zurückzeigt, endet trotzdem fast sofort. Achten Sie auf die Indexierung in der Schleife. GetStringListItem zählt ab 1, während Revisionen ab 0 zählen, und eine innere Schleife, die bei 0 beginnt, verliert das erste geänderte Objekt jeder Revision, ohne jeden Fehler

Wie ein gefälschtes Update sich als Validierungs-Material ausgibt

Die PAdES-Long-Term-Validierung hängt legitim Updates nach der letzten Signatur an: einen Document Security Store (/Type /DSS), /VRI-Dictionarys pro Signatur, Zertifikats- und Revocation-Streams, Dokument-Zeitstempel. Certification-Level lassen sie durch, was Validierungs-Material zum wertvollsten Label macht, das ein Update tragen kann. Es ist auch das billigste zu fälschen. /Type ist, wofür sich das Update entscheidet zu schreiben. Ein Viewer, der eine Seite zeichnet, sieht nie das /Type eines Content-Streams, eines Resource-Dictionary oder eines Fonts an, also kann ein Update den Content-Stream von Seite 3 umschreiben, seinem Dictionary /Type /DSS hinzufügen, und jeder Renderer malt trotzdem den neuen Text, während ein Classifier, der /Type glaubt, Housekeeping sieht. /VRI, /Sig und /DocTimeStamp funktionieren genauso

Die PDFlibPas-Revision-Map ignoriert das Label, das ein Update auf ein von ihr umgeschriebenes Objekt schreibt. Ein umgeschriebenes Objekt zählt nur dann als Validierungs-Material, Zeitstempel oder Signatur, wenn die frühere Version desselben Objekts bereits eines war. Zertifikats-, CRL- und OCSP-Response-Streams tragen keinen eigenen Typ, also werden sie anhand des Document Security Store erkannt, der auf sie zeigt — und nur ein Store, der in der gerade beurteilten Revision oder einer älteren geschrieben wurde, zählt. Ein später hinzugefügter Store kann nicht zurückreichen und für ein Objekt bürgen, das die signierte Revision schrieb. Änderungen an der Dokumentstruktur zählen als Inhalt, sobald sie ändern, was ein Reader auflösen würde:

PDFlibPas GetDocumentRevisionKind meldet ein gefälschtes /Type /DSS-Update, das einen Seiten-Content-Stream umschreibt, als Dokumentinhalt, weil die Revision-Map ein umgeschriebenes Objekt nach seiner früheren Version einordnet
Ein umgeschriebenen Seiten-Content-Stream als /Type /DSS zu labeln ändert nichts für den Renderer, und PDFlibPas meldet das Update trotzdem als Dokumentinhalt, weil das Objekt zuvor Seiteninhalt war
  • das Ersetzen eines bestehenden Object Streams, weil jedes komprimierte Objekt darin nun zum Ersatz auflöst (ein brandneuer Container bleibt Housekeeping, da jedes Mitglied seinen eigenen Eintrag bekommt und dort beurteilt wird)
  • das Hinzufügen eines Katalogs oder einer Seite
  • ein Trailer, dessen /Root einen anderen Katalog nennt, selbst wenn das Update nichts neu definiert, was die Signatur abdeckte, denn der Tausch des Roots ersetzt das ganze Dokument, ohne ein einziges signiertes Objekt anzufassen
  • ein umgeschriebener Stream, dessen Dictionary byte-identisch mit dem alten ist: Ein indirektes /Length oder ein auf dieselbe Größe gepolsterter Ersatz-Body hält das Dictionary unverändert, während sich die Zeichnung ändert, also werden nur Objekte ohne Stream-Body auf diese Evidenz hin als unverändert behandelt

Object-Stream-Mitglieder haben keinen Header zum Abschälen

Seit PDF 1.5 kann ein Update umgeschriebene Objekte in einem Object Stream speichern, und §7.5.7 ist deutlich zum Format: Nur die Objektwerte werden gespeichert, und die Keywords obj und endobj dürfen nicht verwendet werden. Code, der ein Objekt an seiner Position liest und den Header entfernt, indem er nach dem String obj sucht, findet daher am Anfang eines Mitglieds nichts und sucht weiter in dessen Body hinein. Gibt man einem nach dem Signieren umgeschriebenen Seiten-Dictionary einen harmlos aussehenden String wie (obj<</Type/DSS>>), stoppt diese Suche innerhalb des Strings. Der Decoder erhält <</Type/DSS>> — ein Store-Dictionary, das die Revision nie schrieb. Die PDFlibPas-Revision-Map parst N G obj vom Anfang eines unkomprimierten Objekts, prüft, dass die Zahlen das erwartete Objekt benennen, und behandelt einen fehlenden oder falschen Header als unlesbar, statt weiter zu jagen

Cross-Reference-Streams tragen eine eigene Mehrdeutigkeit auf Feldebene. Nach §7.5.8.3 (Tabelle 18) hält nur ein Type-1-Eintrag ein Byte-Offset. Das zweite Feld eines Type-2-Eintrags ist die Objektnummer des umschließenden Object Streams und das dritte der Index darin, während das zweite Feld eines Type-0-Eintrags die nächste freie Objektnummer ist. Liest man diese Objektnummer als Offset, landet ein komprimierter Eintrag in der Revision, die diese kleinen Byte-Positionen überspannt — fast immer das Originaldokument, was die Umschreibung innerhalb der signierten Bytes versteckt. Die Revision-Map platziert jeden Eintrag nach dem Cross-Reference-Abschnitt, der ihn deklariert hat. Dieselbe Klausel sagt, dass ein Eintragstyp außer 0, 1 oder 2 eine Referenz auf das Null-Objekt ist, also fügen solche Einträge nichts hinzu und geben nichts frei und bleiben von der Map fern

Wann sollte ein Signatur-Audit die Antwort verweigern?

Immer wenn die Datei einen Teil ihrer eigenen Historie zurückhält. Die Revision-Map meldet das auf drei Arten: GetDocumentRevisionsComplete gibt 0 zurück, GetDocumentRevisionKind gibt 7 für irgendeine Revision nach der Signatur zurück, oder GetSignatureRevisionIndex gibt -1 zurück. Dieser letzte Wert deckt eine ByteRange ab, die nicht an einer Revisionsgrenze endet — Bytes liegen innerhalb der Range, die die Signatur abzudecken behauptet — und ebenso ein Feld, das nicht existiert, keinen Signaturwert trägt oder in einer Datei sitzt, die nicht gelesen werden konnte. Schicken Sie alle drei zur manuellen Prüfung, mit dem Revisionsindex und den GetDocumentRevisionChanges-Zeilen im Anhang, damit jeder, der die Datei öffnet, von Objektnummern aus startet

// Prüft die Updates nach einer Signatur gegen eine DocMDP-artige Policy.
// Arten sind nach Schwere geordnet, also ist jedes Policy-Level eine Obergrenze:
// P 1 toleriert Housekeeping, Validierungs-Material und Zeitstempel (0..2),
// P 2 fügt Signieren und Formularausfüllen hinzu (..4), P 3 Annotationen (..5).
function UpdatesWithinPolicy(Pdf: TPDFlibSmallBusiness;
  const FileName, Password, FieldName: WideString; Level: Integer): Boolean;
const
  Ceiling: array[1..3] of Integer = (2, 4, 5);
var
  Signed, Count, I, Kind: Integer;
begin
  Result := False;
  if (Level < 1) or (Level > 3) then
    Exit;
  if Pdf.GetDocumentRevisionsComplete(FileName, Password) <> 1 then
    Exit;                        // Teil der Historie wurde nie gesehen
  Signed := Pdf.GetSignatureRevisionIndex(FileName, Password, FieldName);
  if Signed < 0 then
    Exit;                        // jenseits einer Revisionsgrenze, kein solches signiertes
                                 // Feld, oder die Datei war nicht lesbar
  Count := Pdf.GetDocumentRevisionCount(FileName, Password);
  for I := Signed + 1 to Count - 1 do
  begin
    Kind := Pdf.GetDocumentRevisionKind(FileName, Password, I);
    if (Kind = 7) or (Kind > Ceiling[Level]) then
      Exit;                      // 7 liegt über jeder Obergrenze; für den Leser angemerkt
  end;
  Result := True;
end;

Die Obergrenzen folgen ISO 32000-2 §12.8.2.2 (Tabelle 257): P 1 erlaubt keine Änderung, 2 erlaubt Formularausfüllen und Signieren, 3 fügt Annotationen hinzu, und inkrementelle Updates, die nur DSS-Daten oder Dokument-Zeitstempel hinzufügen, zählen auf keinem Level als Änderungen. ISO 32000-1 §12.8.2.2 definiert dieselben drei Werte ohne diese Ausnahme. Schreiben Sie die Schleife selbst, wenn die Policy Ihre ist und nicht die des Zertifizierers, zum Beispiel eine Eingangsregel, die annotierte Verträge zurückweist, ganz gleich, welches P die Datei trägt. Soll die Policy aus der Datei kommen, liest GetSignatureModificationVerdict derselben Klasse das Certification-Level, wendet die in Editierungen an FieldMDP-gesperrten Formularfeldern erkennen beschriebenen Field-Locks an und gibt 5 für den Fall jenseits der Grenze zurück. Beide beantworten eine andere Frage als Änderungen gegen eine Kopie des Dokuments zu klassifizieren, die Sie aufbewahrt haben: Die Revision-Map braucht nichts außer der Datei, die vor ihr liegt

PDFlibPas DocMDP-artiges Signatur-Audit in Delphi mit GetDocumentRevisionKind-Obergrenzen für P 1, P 2 und P 3, nachdem GetDocumentRevisionsComplete und GetSignatureRevisionIndex zuerst geprüft sind
Jedes DocMDP-Level wird zur Obergrenze der Revisionsart: P 1 toleriert Arten 0 bis 2, P 2 bis 4, P 3 bis 5, und eine unbestimmte Revision scheitert an jedem Level

Das Anhängen folgt derselben Kette. Der Trailer eines inkrementellen Updates muss einen /Prev-Eintrag tragen, der die Position des vorherigen Cross-Reference-Abschnitts angibt (§7.5.6), und ein Dokument, das sich nur öffnen ließ, indem man die Cross-Reference-Tabelle aus einem vollen Scan neu aufbaute, hat keine solche Position anzugeben. AppendToFile gibt dafür 0 zurück und lässt die Zieldatei unangetastet. Speichern Sie es vollständig und signieren Sie stattdessen die saubere Kopie — ein vollständiges Umschreiben überträgt bestehende Signaturen nicht

Für ein Archiv signierter Verträge: Führen Sie UpdatesWithinPolicy einmal pro Signaturfeld aus, bevor etwas Neues angehängt wird. Eine Datei, deren spätere Revision als Art 7 liest, sollte zuerst einen Menschen erreichen, vor jedem PDFlibPas-Signier- oder Long-Term-Validation-Schritt, der die ungelesene Revision unter einem weiteren Update begraben würde