Technischer Artikel

PDF-Änderungsanalyse nach der Signatur mit PDFium in Delphi

Um herauszufinden, was sich in einem PDF nach der Signatur geändert hat, bietet die PDFium Component für Delphi und Lazarus TPdf.AnalyzeSignatureRevisions, einen Post-Signature-Revision-Change-Analyzer, der jede inkrementelle Revision aus den originalen Datei-Bytes nachbaut, jede spätere Objektänderung an den DocMDP- und FieldMDP-Regeln dieser Signatur misst und Shadow-Objektdefinitionen als eigenes Risiko meldet. Die Situation, die es adressiert, kennt jeder, der mit Verträgen arbeitet: Ein zertifiziertes Formular geht raus, kommt mit zwei weiteren inkrementellen Saves zurück, und jede Signatur verifiziert weiterhin. Das ist zu erwarten, denn eine Signatur deckt nur die Bytes ihrer eigenen Revision. Die eigentliche Frage ist, ob diese späteren Saves erlaubt waren, und ein grüner Haken an der Signatur beantwortet sie nicht

Warum kann die PDFium-Signatur-API nicht zeigen, was sich nach dem Signieren geändert hat?

Die PDFium-Signatur-API kann Post-Signature-Änderungen nicht zeigen, weil sie nur das Signatur-Dictionary liest: /Contents, /ByteRange, /SubFilter und den DocMDP-Permission-Wert. PDFium hat keinen inkrementellen Revisionsgraphen, parst keine FieldMDP-Transform-Parameter und bietet keinen Objekt-Level-Diff zwischen Revisionen, also arbeitet der Analyzer in FPdfPades.pas direkt auf den Rohbytes. Das hat eine praktische Konsequenz, um die man sein Design herumbauen sollte. TPdf.AnalyzeSignatureRevisions liest die Bytes, die beim Laden des Dokuments zurückgehalten wurden, niemals eine von SaveAs erzeugte Kopie, denn eine neu geschriebene Datei hat genau die Revisionsstruktur verloren, die analysiert werden soll. Stammt das Dokument aus einer progressiven Quelle, deren Download nicht abgeschlossen ist, liefert der Report SourceStatus = pvssIncomplete und Status = prasIndeterminate, statt eine abgeschnittene Datei zu analysieren

Revisionsgrenzen über startxref, xref-Streams und /Prev nachbauen

Der Analyzer baut die Revisionsgrenzen nach, indem er jedem startxref zurückfolgt – durch klassische xref-Tabellen, Cross-Reference-Streams, Hybrid-Reference-/XRefStm-Einträge und die /Prev-Kette, wie es für inkrementelle Updates in ISO 32000-1 §7.5.6 und §7.5.8 definiert ist. Die überdeckte Länge jeder Signatur ist das Ende ihres zweiten ByteRange-Spans, und der Analyzer mappt diese Länge auf die Revision, in deren xref-Section sie fällt. Passt keine Revision, bekommt die Signatur prrCoveredRevisionNotFound und einen Indeterminate-Status. Der State jedes Objekts wird dann bis zur überdeckten Revision nachgespielt, und jeder spätere xref-Eintrag wird mit diesem State verglichen. Das ist wichtiger, als es klingt: Manche Writer schreiben bei jedem inkrementellen Save die komplette xref-Tabelle neu, und ein Eintrag, der weiterhin auf dasselbe unveränderte Objekt zeigt, wird übersprungen, statt als Änderung gemeldet zu werden. Ohne diesen Vergleich würde ein völlig legales Formularausfüllen in hunderten Fake-Änderungen ertrinken

Wie AnalyzeSignatureRevisions in Delphi inkrementelle Revisionen aus rohen PDF-Bytes nachbaut: Der Signatur-ByteRange endet innerhalb der überdeckten Revision, die xref-Prev-Kette läuft durch jeden späteren Save zurück, der Objekt-State wird bis zur überdeckten Revision nachgespielt, und unverändert wiederholte Einträge werden übersprungen, statt als Änderungen gemeldet
Eine Signatur deckt nur die Bytes ihrer eigenen Revision, also mappt der Analyzer den zweiten ByteRange-Span auf eine Revision und misst jeden späteren xref-Eintrag am nachgespielten Objekt-State

Shadow-Definitionen sind der Fall, der die meiste Aufmerksamkeit verdient. Ein Objekt-Body, der innerhalb des Byte-Bereichs einer späteren Revision auftaucht, aber von deren xref nicht referenziert wird, ist für einen normalen Viewer unsichtbar – und genau diese Art von Staging ist das Material von Shadow-Attacken: Versteckter Inhalt wird vor oder nach dem Signieren gepflanzt und später durch Umkippen einer Referenz aktiviert. AnalyzePadesSignatureRevisionsBytes verbucht ein solches Objekt als nicht-authoritative Änderung mit IsAuthoritative = False, stuft es unabhängig von der Permission-Stufe als prdSuspicious ein und nimmt prrUnreferencedObjectDefinition ins Risiko-Set auf. Zwei verwandte Risiken decken andere strukturelle Tricks ab: prrDuplicateObjectDefinition feuert, wenn eine xref-Section dasselbe Objekt mehr als einmal listet, und prrSignatureObjectRedefined feuert, wenn eine spätere Revision ein bestehendes Signaturobjekt neu definiert

Eine Shadow-Objektdefinition innerhalb des Byte-Bereichs einer späteren PDF-Revision: Der Objekt-Body existiert, aber kein xref-Eintrag referenziert ihn, Viewer zeigen ihn also nie, und AnalyzeSignatureRevisions in PDFium Component verbucht ihn als nicht-authoritativ, stuft ihn als prdSuspicious ein und wirft prrUnreferencedObjectDefinition neben den Duplicate- und Redefined-Signature-Risiken
Versteckter Inhalt wird vor oder nach dem Signieren gepflanzt und später durch Umkippen einer Referenz aktiviert, weshalb ein nicht referenzierter Body unabhängig von der DocMDP-Permission-Stufe als verdächtig eingestuft wird
uses
  SysUtils, TypInfo, PDFium, FPdfPades;

const
  ShadowTag: array[Boolean] of string = ('', ' (shadow)');
var
  Pdf: TPdf;
  Report: TPadesRevisionAnalysisReport;
  i, j: Integer;
begin
  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'contract-returned.pdf';
    Pdf.Active := True;
    Report := Pdf.AnalyzeSignatureRevisions;
    Writeln('Revisions: ', Report.RevisionCount,
      '  Signatures: ', Report.SignatureCount,
      '  Overall: ', GetEnumName(TypeInfo(TPadesRevisionAnalysisStatus),
        Ord(Report.Status)));
    for i := 0 to High(Report.Signatures) do
      with Report.Signatures[i] do
      begin
        Writeln(Format('Signature %d covers revision %d, %d later, P=%d, FieldMDP=%s',
          [SignatureIndex, CoveredRevisionIndex, LaterRevisionCount,
           DocMdpPermission,
           GetEnumName(TypeInfo(TPadesFieldMdpAction), Ord(FieldMdpAction))]));
        for j := 0 to High(Changes) do
          Writeln(Format('  rev %d  obj %d  %s -> %s%s',
            [Changes[j].RevisionIndex, Changes[j].ObjectNumber,
             GetEnumName(TypeInfo(TPadesRevisionChangeKind), Ord(Changes[j].Kind)),
             GetEnumName(TypeInfo(TPadesRevisionDecision), Ord(Changes[j].Decision)),
             ShadowTag[not Changes[j].IsAuthoritative]]));
      end;
  finally
    Pdf.Free;
  end;
end;

Wie werden DocMDP und FieldMDP pro Signatur durchgesetzt?

DocMDP und FieldMDP werden für jede Signatur separat durchgesetzt, an der von dieser Signatur überdeckten Revision, sodass eine Zertifizierungssignatur und eine spätere Freigabesignatur in derselben Datei über dieselbe Änderung zu verschiedenen Urteilen kommen können. Jedes spätere Objekt wird zunächst anhand seiner /Type-, /Subtype- und /FT-Einträge und seiner Rolle in den Page-, Form-, Annotation- und DSS-Graphen in einen TPadesRevisionChangeKind einsortiert. Alles, was /JavaScript, /JS, /Launch, /OpenAction, /AA, /RichMedia oder /EmbeddedFile trägt, wird zu prckActiveContent. Die Entscheidung folgt dann ISO 32000-1 §12.8.2.2: Bei P=1 ist alles außer Cross-Reference-Daten und Validierungsmaterial untersagt; P=2 erlaubt Formularausfüllen und weitere Signaturen, lehnt aber Annotationsänderungen ab; P=3 erlaubt auch Annotationen. Seiteninhalt, Dokumentstruktur, Metadaten, Active Content und gelöschte Objekte sind auf jeder DocMDP-Stufe untersagt und werden als prdSuspicious eingestuft, wenn die Signatur überhaupt kein DocMDP trägt, denn eine Freigabesignatur verbietet formal nichts, aber der Leser sieht nicht mehr, was signiert wurde

FieldMDP, aus ISO 32000-1 §12.8.2.4, verengt die Formularfeld-Entscheidung weiter. pfmaAll sperrt jedes Feld, pfmaInclude sperrt nur die gelisteten Felder, und pfmaExclude sperrt alles außer den gelisteten Feldern. Um Include oder Exclude anzuwenden, löst der Analyzer jedes geänderte Feld über die /Parent-Kette zu seinem vollqualifizierten Namen auf und vergleicht ihn per exaktem Match mit der Sperrliste – listen Sie also terminale Feldnamen auf, statt zu erwarten, dass ein Elternname seine Kinder abdeckt. Lässt sich ein Name nicht auflösen oder benutzt der Transform eine Aktion, die der Parser nicht kennt, wird die Änderung zu prdIndeterminate, und prrFieldMdpUnresolved wird geworfen. Die Entscheidungen pro Änderung rollen dann Worst-First auf, mit Suspicious über Disallowed, Disallowed über Indeterminate und Indeterminate über Allowed – ein einziges Shadow-Objekt wiegt also beliebig viele legitime Feldupdates auf

Die Grading-Pipeline, die AnalyzeSignatureRevisions in Delphi auf jede Post-Signature-Änderung anwendet: ein TPadesRevisionChangeKind aus Type- und Subtype-Einträgen, eine DocMDP-Entscheidung an der überdeckten Revision von P=1 bis P=3, eine FieldMDP-Sperrenprüfung auf vollqualifizierten Feldnamen und ein Worst-First-Roll-up von prdSuspicious hinab zu prdAllowed
Ein einziges Shadow-Objekt wiegt beliebig viele legitime Feldupdates auf, weil Suspicious über Disallowed, Indeterminate und Allowed rangiert, während manche Risiken neben dem Status vermerkt werden, ohne ihn abzuwerten

Warum kommen manche Änderungen als Indeterminate statt als sicher zurück?

Änderungen kommen als Indeterminate zurück, wann immer der Analyzer nicht beweisen kann, dass eine Änderung erlaubt ist, denn in einer Signaturprüfung darf ein Unbekanntes niemals als erlaubt gemeldet werden. Einen häufigen Fall behandelt es stattdessen präzise: Die Langzeitvalidierung fügt ein /DSS hinzu und schreibt den Catalog neu, was unter P=1 sonst als strukturelle Änderung zählen würde. Der Analyzer entfernt /DSS und /Extensions aus den alten und neuen Catalog-Dictionarys und vergleicht den Rest; unterscheidet sich sonst nichts, wird das Neuschreiben als Validierungsmaterial-Update behandelt und erlaubt, sodass B-LT- und B-LTA-Anreicherung eine Zertifizierungssignatur nicht bricht. Andere Lücken bleiben bewusst offen. Type-2-Einträge in einem Cross-Reference-Stream zeigen in komprimierte Object Streams, und der Analyzer expandiert Object Streams innerhalb dieser Security-Grenze nicht, sodass diese Änderungen als prckCompressedObject mit prrCompressedObjectUnresolved auftauchen – untersagt unter P=1, sonst Indeterminate. Harte Budgets von 1024 Revisionen, 1,000,000 Objektnummern und 2,000,000 gemeldeten Änderungen erzeugen prrResourceLimitExceeded, und eine gebrochene xref-Kette erzeugt prrMalformedRevisionChain; beide enden als Indeterminate, nie als bestanden

const
  StructuralRisks: TPadesRevisionRisks = [prrMalformedRevisionChain,
    prrDuplicateObjectDefinition, prrUnreferencedObjectDefinition,
    prrSignatureObjectRedefined, prrResourceLimitExceeded];

function RevisionVerdict(const R: TPadesRevisionAnalysisReport): string;
begin
  // Manche Risiken werden ohne Änderung von Status vermerkt, also zuerst testen
  if R.Risks * StructuralRisks <> [] then
    Exit('review: structural risk in the revision chain');
  case R.Status of
    prasNoLaterChanges: Result := 'accept: nothing was added after signing';
    prasAllowed:        Result := 'accept: every later change is permitted';
    prasDisallowed:     Result := 'reject: a change violates DocMDP or FieldMDP';
    prasSuspicious:     Result := 'reject: shadow or unconstrained content change';
    prasIndeterminate:  Result := 'review: the analyzer could not decide';
  else
    Result := 'not checked: no signatures or no original bytes';
  end;
end;

Die Reihenfolge in diesem Gate ist Absicht. prrDuplicateObjectDefinition wandert ins Risiko-Set, ohne für sich genommen Status abzuwerten, und ein nicht parsebarer FieldMDP-Transform wirkt sich erst auf den Status aus, wenn tatsächlich ein Formularfeld geändert wird – ein Gate, das nur auf Status schaut, kann also Beweise übersehen, die der Report bereits enthält. Halten Sie sich auch vor Augen, was der Report nicht behauptet. TPadesRevisionAnalysisReport sagt nichts darüber, ob die CMS-Signatur kryptografisch gültig ist oder ob das Signaturzertifikat zu einer Wurzel chainet, der Sie vertrauen. Die Revisionsanalyse beantwortet die Frage, was nach dem Signieren passiert ist, und sie gehört neben die strukturelle und die Trust-Validierung – nicht an ihre Stelle

Seed Values und MDP-Sperren beim Signieren schreiben

Dieselben Regeln lassen sich beim Signieren über TPadesSignatureFieldOptions festlegen, das FieldOptions-Mitglied sowohl von TPadesSignOptions als auch von TPadesRemoteSignOptions. PDFium kann ein Widget anlegen, aber kein /SV, /Lock, einen FieldMDP- oder DocMDP-Transform oder das Catalog-/Perms-Dictionary schreiben, also erzeugt der eigene inkrementelle PAdES-Writer der Komponente diese Objekte innerhalb desselben xref-Updates wie die Signatur. FieldName setzt den Wurzelfeldnamen, RequiredSeedValues wird zu den /Ff-Bits des Seed-Value-Dictionarys aus ISO 32000-1 §12.7.4.5, Reasons, LegalAttestations und AcceptableCertificates beschränken, was ein späterer Unterzeichner wählen darf, LockAction mit LockFields schreibt ein indirektes /SigFieldLock, und CertificationPermission von 1 bis 3 macht aus der Signatur eine Zertifizierungssignatur. Die DocMDP- und FieldMDP-Transforms landen beide in einem /Reference-Array am Signaturwert, jeweils mit /Data auf den Catalog zeigend

var
  Options: TPadesSignOptions;
begin
  Options := TPadesSignOptions.Default;
  Options.CertificateThumbprint := 'A1B2C3D4E5F60718293A4B5C6D7E8F9012345678';
  Options.Reason := 'Approved for release';
  Options.FieldOptions.FieldName := 'Certification';
  Options.FieldOptions.CertificationPermission := 2;   // nur Formularausfüllen und Signieren
  Options.FieldOptions.RequiredSeedValues := [psvcSubFilter, psvcDigestMethod];
  Options.FieldOptions.LockAction := pfmaInclude;      // nur diese Felder sperren
  SetLength(Options.FieldOptions.LockFields, 2);
  Options.FieldOptions.LockFields[0] := 'Total';
  Options.FieldOptions.LockFields[1] := 'IBAN';
  if not Pdf.SignPades('contract-certified.pdf', Options) then
    Writeln('Signing failed');
end;

Ein paar Details gehen leicht schief, wenn Sie das von Hand bauen. Catalog /Perms /DocMDP muss auf das Signaturwert-Dictionary zeigen, nicht auf die Widget-Annotation, und genau deshalb hält der Writer den Signaturwert als eigenes indirektes Objekt. Ein vorhandenes /Perms-Dictionary kann bereits /UR3-Usage-Rights halten, also kopiert der Writer es und fügt /DocMDP ein, statt es zu ersetzen – dem Permissions-Dictionary aus ISO 32000-1 §12.8.4 folgend. Ein Dokument, das bereits /DocMDP trägt, verweigert eine zweite Zertifizierungssignatur mit EPadesCrypto, und dasselbe gilt für inkonsistente Optionen: eine Include- oder Exclude-Sperre ohne Feldnamen, eine All-Sperre mit Feldliste, eine Legal Attestation auf einer Nicht-Zertifizierungssignatur oder ein Punkt im Wurzelfeldnamen. Remote-Signieren fügt eine weitere Regel hinzu, weil das Signaturzertifikat unbekannt ist, wenn PreparePadesRemoteSignature läuft: CertificateRequired dort zu setzen verlangt eine explizite AcceptableCertificates-Liste, während lokales Signieren auf das aufgelöste Unterzeichnerzertifikat zurückfallen kann

Die Revisionsanalyse vervollständigt den Signatur-Werkzeugkasten, statt irgendeinen Teil zu ersetzen. Beginnen Sie mit PDF-Signaturen und PAdES-Level mit der PDFium Component inspizieren, um das Dictionary und das Baseline-Level zu lesen, werfen Sie einen Blick auf warum Validatoren PAdES-Signaturen zurückweisen für die strukturellen Fehler, die vor jeder Revisionsfrage kommen, und falten Sie das Urteil in ein breiteres PDF-Sicherheitsrisiko-Audit ein, neben JavaScript- und Embedded-File-Prüfungen. TPdf.AnalyzeSignatureRevisions, TPadesSignatureFieldOptions und der hier gezeigte inkrementelle PAdES-Writer kommen mit der PDFium Component für Delphi, C++Builder und Lazarus