Eine Workbench, die Compliance-Validierung mit digitaler Signierung verknüpft, muss vier Schritte in dieser Reihenfolge koordinieren und dabei denselben Byte-Satz durchgängig im Blick behalten. Sie führt ein PDF/A- oder PDF/UA-Preflight durch. Sie wendet die erforderlichen Korrekturen an und speichert eine bereinigte Revision. Sie signiert genau diese Revision. Dann liest sie die signierte Datei zurück und bestätigt, dass die Signatur sie tatsächlich abdeckt. Die Reihenfolge ist keine Äußerlichkeit. Wer das Zurücklesen überspringt, vertraut dem eigenen Schreibpfad; wer das Preflight gegen die falsche Revision ausführt, beschreibt in seinem Compliance-Bericht eine Datei, die nie ausgeliefert wurde.
Den Fehler, den die meisten selbst entwickelten Pipelines machen, liegt in der Nahtstelle zwischen Validierung und Signierung. Werden beide als zwei separate Werkzeuge mit einem Remediation-Schritt dazwischen ausgeführt, entstehen mindestens drei verschiedene Revisionen der Datei, jede mit eigenen Bytes. Der Preflight-Bericht, den man einem Prüfer übergibt, beschreibt eine davon. Die Signatur friert eine andere ein. Nichts in der Datei belegt, dass es sich um dieselbe Revision handelt, und oft ist es das nicht. PDFlibPas, die losLab PDF-Entwicklerbibliothek für Delphi und C++Builder, stellt Preflight und PAdES-Signierung hinter einer einzigen Fassadenklasse bereit, sodass die gesamte Sequenz in einem Prozess ablaufen kann, der nie den Überblick verliert, über welche Bytes er gerade spricht. Jeder unten aufgeführte Aufruf existiert heute in der Bibliothek, ebenso jede erwähnte Falle.
Drei Revisionen eines Dokuments, und wie die Lücke entsteht
Die Speichervorgänge zählen. Das Original kommt von vorgelagerten Systemen. Der Remediation-Schritt lädt es, aktiviert einen Compliance-Modus und schreibt eine bereinigte Revision. Der Signierungsschritt fügt eine Signatur als inkrementelles Update an, was ein dritter Schreibvorgang ist. Drei Speichervorgänge, drei Byte-Layouts, und ein Preflight-Bericht hat nur dann Bedeutung, wenn er angibt, welche der drei er abdeckt. Ein SHA-256-Hash der Datei, der neben jedem Preflight-Lauf und jeder Signatur aufgezeichnet wird, ist der preiswerte Anker, der es ermöglicht zu belegen, dass die validierte Revision auch die signierte Revision ist.
Ein Bibliotheksverhalten verschärft diese Disziplin noch weiter. Compliance-Korrekturen, die über SetPDFAMode oder SetPDFUAMode angefordert werden, treten nicht beim Aufruf in Kraft. Sie werden beim Speichern angewendet. Automatische Reparaturen, etwa das Erzwingen von Anmerkungsdruckflags oder das Zuweisen einer PDF/UA-Tab-Reihenfolge, landen in der Ausgabedatei und nirgendwo sonst, sodass eine Prüfung des gerade im Speicher „bereinigten" Dokuments nichts über die Bytes aussagt, die zum Signierer gelangen. Erst speichern, dann Preflight auf der gespeicherten Datei ausführen. Der In-Memory-Zustand ist ein Entwurf; nur die Datei auf der Festplatte ist real.
Preflight von der Festplatte, und die Null, die zwei Dinge bedeutet
Der flache Preflight-Einstiegspunkt ist CheckFileCompliance(FileName, Password, ComplianceTest, Options). Test 1 wählt PDF/A (ISO 19005), Test 2 wählt PDF/UA (ISO 14289). Er öffnet die Datei über den Streaming-Reader der Bibliothek, sodass kein vorheriges LoadFromFile erforderlich ist, und gibt einen String-Listen-Handle zurück, der einen Befund pro Eintrag enthält:
var
PDF: TPDFlib;
ListID, I: Integer;
begin
PDF := TPDFlib.Create;
try
ListID := PDF.CheckFileCompliance('invoice-fixed.pdf', '', 1, 0); // 1 = PDF/A
if ListID = 0 then
begin
if PDF.LastErrorCode <> 0 then
raise Exception.Create('Preflight konnte die Datei nicht lesen')
else
Writeln('Keine PDF/A-Befunde');
end
else
begin
for I := 0 to PDF.GetStringListCount(ListID) - 1 do
Writeln(PDF.GetStringListItem(ListID, I));
PDF.ReleaseStringList(ListID);
end;
finally
PDF.Free;
end;
end;
Die Falle liegt im Rückgabewert, und es ist die Art, die jeden Happy-Path-Test besteht. Null bedeutet „keine Befunde". Null bedeutet auch „die Datei konnte nicht geöffnet werden", weil die Implementierung 0 zurückgibt, wann immer die Ergebnisliste leer ist, einschließlich eines Lesefehlers. Eine Workbench, die 0 als grünes Licht liest, wird fröhlich eine Datei freigeben, die ein anderer Prozess gerade gesperrt hält. Die Kombination des Aufrufs mit LastErrorCode, wie oben gezeigt, trennt die beiden Fälle. Der Prüfer öffnet die Datei außerdem mit einem Deny-Write-Share-Modus; hält der Remediation-Schritt noch einen Schreib-Handle offen, schlägt das Preflight aus einem Grund fehl, der nichts mit der Compliance zu tun hat.
Wenn ein Mensch statt einer Pipeline die Befunde lesen soll, rendert CreatePreflightReport sie als lesbaren Bericht. ComparePreflightReports vergleicht zwei Läufe per Diff, was ein ordentlicher Weg ist zu zeigen, dass die Remediation die ursprünglichen Befunde behoben hat, ohne still neue einzuführen.
Die geprüfte Revision mit einem SignProcess signieren
Sobald die gespeicherte Revision das Preflight bestanden hat und ihr Hash vorliegt, wird genau diese Datei signiert und keine andere. Die SignProcess-API liest sich wie ein Builder. Einen Prozess-Handle öffnen, ihn Zeile für Zeile konfigurieren, abschließen, dann den Ergebniscode zurücklesen.
ProcessID := PDF.NewSignProcessFromFile('invoice-fixed.pdf', '');
if ProcessID = 0 then
raise Exception.Create('Quelle für Signierung kann nicht geöffnet werden');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Rechnungsfreigabe', 'Berlin', 'billing@example.com');
PDF.SetSignProcessCustomSubFilter(ProcessID, 'ETSI.CAdES.detached'); // PAdES-Baseline
PDF.SetSignProcessDigestAlgorithm(ProcessID, 2); // SHA-256
PDF.SetSignProcessReserveContentsBytes(ProcessID, 8192); // Platz für späteren Zeitstempel
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Signierung fehlgeschlagen, Code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
Zwei Zeilen in dieser Sequenz haben mehr Gewicht, als sie auf den ersten Blick vermuten lassen. SetSignProcessCustomSubFilter mit ETSI.CAdES.detached wählt eine PAdES-Signatur gemäß ETSI EN 319 142-1 statt der veralteten adbe.pkcs7.detached-Familie, was den Unterschied zwischen einer Signatur ausmacht, die ein europäischer Validator akzeptiert, und einer, die er beanstandet. SetSignProcessReserveContentsBytes füllt den /Contents-Platzhalter auf, und die hier gewählte Größe ist eine Entscheidung über die Zukunft: Soll je ein Signatur-Zeitstempel folgen, muss der erweiterte CMS in den jetzt reservierten Platz passen, denn der Platzhalter kann später nicht mehr wachsen, ohne das Ganze neu zu signieren. Zu großzügig reservieren verschwendet ein paar Kilobyte. Zu knapp reservieren und der Zeitstempel-Schritt schlägt Monate später mit einem Überlauf fehl.
GetSignProcessResult liefert einen Code, kein Boolean, und die Codes sind es wert, aufbewahrt zu werden. 1 ist Erfolg. 4 ist ein falsches PDF-Passwort, 7 ein falsches Zertifikatpasswort, 9 ein PFX ohne privaten Schlüssel, 11 ein Fehler beim Einbetten der Signatur. Diese Codes auf wahr/falsch zu reduzieren verwirft genau die Information, die einen Support-Fall mit falschem Passwort von einem mit fehlendem privaten Schlüssel unterscheidet. Den Integer protokollieren.
Zurücklesen: die soeben erzeugte Datei prüfen
Keine Workbench sollte dem Pfad vertrauen, der die Datei geschrieben hat, die sie gerade zertifizieren soll. Die Prüfklasse TPDFlibSignDoc öffnet die signierte Ausgabe erneut und liest die Einträge des Signaturwörterbuchs direkt von der Festplatte:
var
Doc: TPDFlibSignDoc;
Names: TStringList;
FS: TFileStream;
I: Integer;
SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
// Größe vor Open erfassen: das Prüfobjekt hält einen Share-Lock auf die Datei
FS := TFileStream.Create('invoice-signed.pdf', fmOpenRead or fmShareDenyNone);
SourceSize := FS.Size;
FS.Free;
Doc := TPDFlibSignDoc.Create;
Names := TStringList.Create;
try
if not Doc.Open('invoice-signed.pdf', '', False) then Exit;
Doc.GetSignatureFieldNames(Names);
for I := 0 to Names.Count - 1 do
if Doc.GetSignatureValueObjNum(Names[I]) > 0 then // > 0 bedeutet: Feld ist signiert
begin
RangeStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 11)));
GapStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 12)));
TailStart := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 13)));
TailLen := StrToInt64(string(Doc.GetSignatureValueByName(Names[I], 14)));
if (RangeStart = 0) and (TailStart + TailLen = SourceSize) then
Writeln(Names[I], ': Signatur deckt die Datei bis EOF ab')
else
Writeln(Names[I], ': frühere Revision oder ungewöhnliches ByteRange-Layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
Die ValueKey-Argumente bilden Wörterbuchweinträge ab. Schlüssel 0 gibt den rohen CMS aus /Contents zurück, Schlüssel 2 und 3 die /Filter- und /SubFilter-Namen, und 11 bis 14 die vier ByteRange-Zahlen. Textwerte werden stattdessen über GetSignatureTextValueByName abgerufen: Schlüssel 0 ist die angegebene Signaturzeit, und Schlüssel 5 unterscheidet ein gewöhnliches Sig von einem DocTimeStamp, was relevant ist, sobald ein Dokument beide trägt.
Die Erfassung der Dateigröße am Anfang des Beispiels ist nicht nur Aufräumarbeit, sondern wesentlich. TPDFlibSignDoc.Open hält die Datei für seine gesamte Lebensdauer unter einem restriktiven Share-Lock, sodass alles, was die rohen Bytes benötigt (Hashing des signierten Bereichs, erneute Berechnung des CMS-Digests), die Datei vor dem Open-Aufruf lesen muss. Wer die Reihenfolge umkehrt, scheitert intermittierend auf dem Rechner, der das Rennen verliert.
ByteRange-Arithmetik, die Abdeckung beweist
Eine gesunde Datei mit einer Signatur hat ein ByteRange der Form [0 a b c]: Die Abdeckung beginnt bei Offset 0, überspringt den Hex-/Contents-Platzhalter zwischen a und b und setzt sich durch Byte b+c fort. Wenn b+c gleich der Dateigröße ist, deckt die Signatur alles bis zum Dateiende ab, was das gewünschte Ergebnis ist. Wenn es darunter liegt, hat jemand nach dem Schreiben der Signatur ein inkrementelles Update angehängt. Das ist nach ISO 32000-1 §12.8 vollkommen legitim, da spätere Formulareingaben, eine zweite Signatur und ein DSS-Wörterbuch genau so ankommen. Es ist auch genau die Tatsache, die ein Prüfprotokoll zum Signierzeitpunkt festhalten sollte, statt sie bei einem Streit im Nachhinein zu rekonstruieren.
Beim Durchführen dieser Arithmetik auf die Integer-Breite achten. GetSignProcessByteRange der flachen API gibt einen 32-Bit-Integer zurück, aber die zugrundeliegenden Werte sind Int64; bei einer Datei über 2 GB schneidet der flache Accessor daher still ab. Stattdessen auf TPDFlibSigner.GetByteRange zurückgreifen, das Int64 zurückgibt, oder die Werte wie im Prüfcode oben über GetSignatureValueByName auslesen.
Was die Bibliothek dem Entwickler überlässt
Zwei Grenzen lernt man besser zum Entwurfszeitpunkt als in der finalen Sprint-Phase kennen. Die flache TPDFlib-API enthält keinen Signatur-Verifikations-Wrapper. Die kryptografische Verifikation liegt eine Ebene tiefer in TPDFlibSignatureVerifier, dessen VerifySignature gültig, ungültig oder unbekannt zurückgibt. Es gibt auch keinen eingebauten HTTP-Client für RFC 3161-Zeitstempel-Autoritäten. Die Bibliothek berechnet den Hash, der übermittelt werden soll, und bettet den erweiterten CMS nach Erhalt des Tokens zurück ein, aber der Netzwerk-Roundtrip zur TSA ist Sache des Aufrufers. Beide Punkte sind einfach zu kapseln und sehr unangenehm zu entdecken, wenn sie eine Woche vor einem Release fehlen.
Eine Frage zur Compliance lohnt eine klare Antwort, weil sie entscheidet, wo das letzte Gate liegt: Bricht das Hinzufügen einer Signatur PDF/A? Für sich genommen nicht. Die Signatur kommt als inkrementelles Update an, und ISO 19005-2 und höher erlaubt signierte Dokumente ausdrücklich. Der Haken ist die Signaturerscheinung, die denselben Regeln wie jeder andere Seiteninhalt unterliegt, eingebettete Schriften und keine geräteabhängige Farbe eingeschlossen. Das letzte Gate der Workbench ist daher ein weiterer Preflight-Lauf, diesmal gegen die signierte Ausgabe. CheckFileCompliance als schnelle Pipeline-Prüfung nutzen und Release-Kandidaten dennoch mit einem unabhängigen Werkzeug wie veraPDF verifizieren, da Validatoren überlappende, aber nicht identische Regelwerke implementieren; wenn beide nicht übereinstimmen, nennt der Befundtext gewöhnlich die Klausel, die man nachlesen sollte.
Ein Sequenzierungsaspekt ergibt sich aus all dem. Signierung und Zeitstempelung sind kein einziger Durchlauf: Die Basissignatur wird zuerst geschrieben, dann erweitert ein separater Zeitstempel-Prozess den CMS innerhalb des reservierten /Contents-Platzes, was genau der Grund ist, warum die Reserve-Bytes-Zeile weiter oben so viel Gewicht hatte. Für die Zeitstempel- und Langzeit-Validierungsschichten, die auf dieser Workbench aufbauen, führt der PAdES-Signier- und Validierungs-Leitfaden die Signatur von der Baseline bis B-LT, und die Preflight-Seite geht in der Anleitung zu PDF/A- und PDF/UA-Preflight tiefer. Vollständige API-Dokumentation und Testversionen stehen auf der PDFlibPas-Produktseite zur Verfügung.