Technischer Artikel

Warum Validatoren PAdES-Signaturen ablehnen: PDFium in Delphi

Ein Validator lehnt eine PAdES-Signatur in fast jedem Fall, den wir mit der PDFium-Komponente in Delphi debuggt haben, aus einem von drei Gründen ab: Das Array /ByteRange enthält noch seine Null-Platzhalter, die Signierzeit /M ist keine wohlgeformte PDF-Datumszeichenfolge oder ein inkrementelles Update hat den Eintrag /Encrypt aus einem verschlüsselten Dokument entfernt. Alle drei führen zu Dateien, die sich problemlos öffnen und rendern lassen, jedoch in dem Moment fehlschlagen, in dem ein konformer Validator das Signatur-Dictionary liest

Das Szenario, das diesen Artikel motiviert, ist schmerzhaft spezifisch. Sie signieren einen Vertrag mit dem Workflow aus dem Artikel über PAdES-B-B-Signaturen, Ihr eigener Prüfcode meldet, dass die Signatur vorhanden und strukturell intakt ist, jeder lokale Test verläuft erfolgreich — und dann lädt die Gegenpartei die Datei auf ihre Validierungsplattform hoch und erhält ein rotes Kreuz. Nichts an dem Fehler ist in einem Viewer sichtbar, da keiner dieser three Mängel den Seiteninhalt betrifft. Sie befinden sich vollständig im Signatur-Dictionary und im Dateitrailer — also genau dort, wo Validatoren suchen und Viewers meist nicht

Warum ist der ByteRange meiner PDF-Signatur ungültig?

Eine Ablehnung von ByteRange bedeutet fast immer, dass die vier Felder nie ausgefüllt wurden, und nicht, dass die Bereiche geringfügig falsch sind. Das Array /ByteRange [A B C D] deklariert zwei Bereiche, Byte A bis A+B und Byte C bis C+D, und EN 319 142-1 §6.3 (Anforderung k) verlangt, dass diese zusammen die gesamte Datei mit Ausnahme der hexadezimal codierten Zeichenfolge /Contents abdecken. In Zahlen: A ist 0, C >= A+B, C+D entspricht der Dateilänge, und die Lücke zwischen B und C enthält genau die hexadezimale <...>-Zeichenfolge — zwei Klammer-Bytes plus zwei Hex-Zeichen pro Byte an CMS-Daten. Ein Validator, der [0 0 0 0] liest, kommt zu dem Schluss, dass die Signatur nichts abdeckt, und lehnt sie ab, unabhängig davon, wie korrekt die CMS-Struktur innerhalb von /Contents ist

Die Funktionsweise dieses Vorgangs ist wichtig zu verstehen, da dasselbe Muster in jedem Signatur-Stack existiert. Ein Signierer kann die endgültigen Offsets erst kennen, wenn das Dateilayout feststeht. Daher gibt der Writer das Array mit Platzhaltern fester Breite aus, die Nullen enthalten, und füllt sie nach dem Layout aus. In Versionen der PDFium-Komponente vor 2.14.1, dass dieses Ausfüllprozess ab der Position /Contents nach dem Platzhaltermuster suchte — da /ByteRange im Dictionary jedoch vor /Contents steht, fand die Suche nichts und alle drei Ersetzungen schlugen stillschweigend fehl. Der CMS-Digest wurde über die korrekten Bereiche berechnet, sodass die Kryptografie fehlerfrei war; die Deklaration dieser Bereiche verblieb jedoch bei Null, weshalb jeder konforme Validator die Datei ablehnte. Dokumenten-Zeitstempel für PAdES B-LTA, die dasselbe Dictionary-Layout verwenden, schlugen auf dieselbe Weise fehl — dies ist relevant, wenn Sie auf langfristigen B-LT- und B-LTA-Signaturen aufbauen. Version 2.14.1 füllt ab dem Start des Signaturobjekts aus, und der Fix wird durch einen Regressionstest abgedeckt, der die erzeugte Datei parst und die unten stehende Arithmetik überprüft

function SignedByteRangeCoversFile(const FileName: string): Boolean;
var
  Raw: TBytes;
  Text: AnsiString;
  P, N: Integer;
  F: array[0..3] of Int64;
begin
  Raw := TFile.ReadAllBytes(FileName);
  SetString(Text, PAnsiChar(@Raw[0]), Length(Raw));
  P := Pos('/ByteRange', Text);          // first signature only
  Result := P > 0;
  if not Result then Exit;
  Inc(P, Length('/ByteRange'));
  for N := 0 to 3 do
  begin
    while (P <= Length(Text)) and not (Text[P] in ['0'..'9']) do Inc(P);
    F[N] := 0;
    while (P <= Length(Text)) and (Text[P] in ['0'..'9']) do
    begin
      F[N] := F[N] * 10 + Ord(Text[P]) - Ord('0');
      Inc(P);
    end;
  end;
  // EN 319 142-1 §6.3 req k: spans cover everything except /Contents
  Result := (F[0] = 0) and (F[2] >= F[0] + F[1]) and
            (F[2] + F[3] = Int64(Length(Raw)));
end;

Warum markieren Validatoren die Signierzeit /M als fehlerhaft?

Strikte Validatoren lehnen eine Signierzeit ab, die keine vollständige PDF-Datumszeichenfolge ist. Die beiden am häufigsten fehlenden Teile sind das Präfix D: und das UTC-Offset-Kennzeichen. ISO 32000-1 §7.9.4 definiert das Datumsformat als D:YYYYMMDDHHmmSS, gefolgt von einem Offset — Z für UTC oder einer vorzeichenbehafteten Beziehung +HH'mm' dazu. Ein reines 20260709143000 wird von einem toleranten Reader als Datum geparst, aber ein Validator, der die Grammatik wörtlich anwendet, sieht eine ungültige Zeichenfolge in einem Pflichtfeld und markiert die Signatur. Versionen der PDFium-Komponente vor 2.14.4 schrieben den Eintrag /M von TPdf.SignPades und SignPadesBytes genau in dieser nackten Form; seit 2.14.4 trägt der Eintrag das Präfix D: und das Kennzeichen Z, sodass die deklarierte Signierzeit D:20260709143000Z lautet

Zwei praktische Hinweise zu diesem Feld. Erstens: Schreiben Sie UTC und geben Sie dies an. Ein Zeitstempel ohne Offset-Kennzeichen zwingt den Validator, die Zeitzonenbeziehung zu erraten, und §7.9.4 behandelt das Offset als Teil des Formats und nicht als optionale Bequemlichkeit. Zweitens: Denken Sie daran, was /M ist — die vom Signierer angegebene Zeit, eine Behauptung und kein Beweis. Ein Validator prüft hier das Format, nicht den Wahrheitsgehalt; die nachweisbare Zeit stammt von einem RFC 3161-Zeitstempel bei PAdES B-T und höher. Ein Feld korrekt zu formatieren und ihm zu vertrauen, sind zwei verschiedene Entscheidungen, und Validatoren erzwingen nur Ersteres

Warum löst SignPades eine EPadesCrypto-Ausnahme bei einer verschlüsselten PDF aus?

Die PDFium-Komponente weigert sich, ein verschlüsseltes Dokument zu signieren, da die Alternative darin bestünde, eine Datei zu erzeugen, die konforme Reader beim Öffnen unbrauchbar machen würden. Eine PAdES-Signatur wird als inkrementelles Update angehängt, und ISO 32000-1 §7.5.6 verlangt, dass der neue Trailer eines Update-Bereichs jeden Eintrag des vorherigen Trailers mit Ausnahme von /Prev enthält — bei einem verschlüsselten Dokument schließt dies /Encrypt ein. Lässt man dies weg, deklariert der neueste Trailer die Datei als unverschlüsselt, sodass ein konformer Reader den verschlüsselten Hauptteil als Klartext parst und Datenmüll erhält. Schlimmer noch: Die Streams und Zeichenfolgen, die der Signierer anhängt, müssten selbst mit dem Dokumentenschlüssel verschlüsselt werden, um legal zu sein, was ein Klartext-Signaturinjektor nicht leisten kann. Es gibt keine Möglichkeit, eine gültige Klartextsignatur an eine verschlüsselte Datei anzuhängen. Daher lösen TPdf.SignPades, SignPadesBytes und InjectPadesDssMarkers seit Version 2.14.2 eine EPadesCrypto-Ausnahme aus, anstatt ein beschädigtes oder nicht verifizierbares Ergebnis auszugeben

try
  if not Pdf.SignPades('contract-signed.pdf', AThumbprint) then
    Writeln('Signing reported failure');
except
  on E: EPadesCrypto do
  begin
    // e.g. 'SignPadesBytes: the source document is encrypted;
    //       remove encryption before signing'
    Writeln('Cannot sign: ', E.Message);
  end;
end;

Die Ausnahme ist das korrekte Ergebnis, richten Sie also den Workflow darum herum ein, anstatt ihn abzufangen und erneut zu versuchen. Entschlüsseln Sie zuerst mit Eigentümerrechten, signieren Sie die Klartextkopie und akzeptieren Sie, falls der Vertriebskanal eine Verschlüsselung erfordert, dass Verschlüsseln-dann-Signieren und Signieren-dann-Verschlüsseln und differente Artefakte mit unterschiedlichen Validierungsszenarien erzeugen. Eine über Klartext-Bytes berechnete Signatur kann die erneute Verschlüsselung dieser Bytes nicht überstehen. Die ehrlichen Optionen sind daher eine signierte Klartextdatei oder eine neben dem Code dokumentierte Richtlinienentscheidung

Wie sich zwei Fehler gegenseitig bestätigen können

Der ByteRange-Fehler blieb so lange unentdeckt, weil unser eigener Validator auf komplementäre Weise falsch war — und das ist die wichtigste Lektion dieses Artikels. Die Abdeckungsprüfung in ValidatePadesCompliance verlangte, dass der zweite Bereich genau bei A+B beginnt — also eine Lücke von null. Dies klassifiziert das Standardlayout falsch, bei dem die Lücke die Hex-Zeichenfolge /Contents enthält. Der hauseigene Validator lehnte also genau das konforme Layout ab und der hauseigene Generator erzeugte nie ein solches, und die End-to-End-Pipeline aus Signieren und Validieren blieb zufällig fehlerfrei. Jeder Fehler verhinderte, dass die Testsuite das Gegenbeispiel fand, das den jeweils anderen Fehler aufgedeckt hätte. Version 2.14.1 behob beide Seiten im selben Release: Der Generator füllt alle vier Felder aus und der Validator akzeptiert C >= A+B, wobei C+D der Dateilänge entspricht

Die methodische Behebung ist die Kreuzvalidierung gegen eine Implementierung, die Sie nicht selbst geschrieben haben. Ein Generator und ein Validator, die sich eine Codebasis, einen Autor oder auch nur ein mentales Modell des Formats teilen, können sich unendlich lange auf ein gemeinsames Missverständnis einigen; ein unabhängiger Validator bricht die Symmetrie. Senden Sie Ihre signierte Ausgabe vor einem Release durch mindestens einen externen Konformitätsprüfer und behalten Sie eine strukturelle Selbstprüfung als schnelle erste Verteidigungslinie im Build — TPdf.ValidatePades, beschrieben in dem Artikel über die Signaturprüfung, meldet den Abdeckungsfehler als benanntes Problem

var
  R: TPadesValidationResult;
begin
  Pdf.FileName := 'contract-signed.pdf';
  Pdf.Active := True;
  R := Pdf.ValidatePades;
  if ppeiByteRangeNotCoveringFile in R.Issues then
    Writeln('ByteRange does not cover the file');
  if not R.IsCompliant then
    Writeln('Structural issues present: do not ship this file');
end;

Eine Checkliste zur Ablehnung für signierte Ausgaben

Wenn eine Plattform Ihre PAdES-Signatur ablehnt, prüfen Sie die einfachen strukturellen Ursachen, bevor Sie Zertifikate oder Vertrauensketten verdächtigen. Lesen Sie das Array /ByteRange und überprüfen Sie die drei Gleichungen: erstes Feld Null, zweiter Bereich beginnt am oder nach dem Ende des ersten, zweiter Bereich endet genau am Dateiende. Lesen Sie den Eintrag /M und verifizieren Sie, dass es sich um eine vollständige Datumszeichenfolge nach §7.9.4 mit dem Präfix D: und einem Offset-Kennzeichen handelt. Stellen Sie sicher, dass das Quelldokument nicht verschlüsselt war, als die Signatur angehängt wurde. Und falls Ihr Stack sie trotzdem ohne Beanstandung signiert hat, betrachten Sie dieses Schweigen als Fehler im Stack. Alle drei Prüfungen laufen auf rohen Bytes in Millisekunden ab und erklären unserer Erfahrung nach die Ablehnung weitaus häufiger als jede kryptografische Ursache

Die hier verwendeten Signier-, Inspektions- und Validierungsaufrufe — SignPades, ValidatePades und die PAdES-Konformitätsprüfungen mit ihrer korrigierten ByteRange-Arithmetik, Datumsformatierung und Verschlüsselungsschranke — werden mit der PDFium-Komponente für Delphi, C++Builder und Lazarus ausgeliefert