Eine Workbench, die Compliance-Validierung mit digitaler Signatur verkettet, muss vier Schritte in genau dieser Reihenfolge koordinieren und sie durchgehend an einen einzigen Bytesatz binden. Sie führt einen PDF/A- oder PDF/UA-Preflight aus. Sie wendet die Korrekturen an, die die Befunde verlangen, und speichert eine korrigierte Revision. Sie signiert genau diese Revision. Anschließend liest sie die signierte Datei zurück und bestätigt, dass die Signatur sie wirklich abdeckt. Die Reihenfolge ist keine Formsache. Lassen Sie das Zurücklesen weg, vertrauen Sie Ihrem eigenen Schreibpfad; läuft der Preflight gegen die falsche Revision, beschreibt Ihr Compliance-Bericht eine Datei, die Sie nie ausgeliefert haben
Der Teil, den die meisten selbstgebauten Pipelines falsch machen, ist die Nahtstelle zwischen Validierung und Signierung. Führt man beides als zwei getrennte Werkzeuge mit einem Korrekturdurchlauf dazwischen aus, entstehen mindestens drei unterschiedliche Revisionen der Datei, jede mit eigenen Bytes. Der Preflight-Bericht, den Sie einem Auditor übergeben, beschreibt eine davon. Die Signatur friert eine andere ein. Nichts in der Datei besagt, dass es sich um dieselbe Revision handelt, und oft ist es das auch nicht. PDF Library for Delphi, die losLab PDF Developer Library für Delphi und C++Builder, stellt Preflight und PAdES-Signierung hinter eine einzige Fassadenklasse, sodass die gesamte Abfolge in einem Prozess laufen kann, der nie aus den Augen verliert, von welchen Bytes gerade die Rede ist. Jeder Aufruf unten existiert heute in der Bibliothek, ebenso jede daneben vermerkte Falle
Drei Revisionen eines Dokuments, und wie die Lücke entsteht
Zählen Sie die Speichervorgänge. Das Original kommt aus dem vorgelagerten System. Der Korrekturdurchlauf lädt es, schaltet einen Compliance-Modus ein und schreibt eine korrigierte Revision. Der Signaturdurchlauf hängt eine Signatur als inkrementelles Update an, ein dritter Schreibvorgang. Drei Speicherungen, drei Byte-Layouts, und ein Preflight-Bericht bedeutet nichts, solange er nicht benennt, welche der drei er abdeckt. Ein SHA-256 der Datei, neben jedem Preflight-Lauf und jeder Signatur festgehalten, ist der günstige Anker, mit dem Sie nachweisen können, dass die validierte Revision die signierte Revision ist
Ein Verhalten der Bibliothek verschärft diese Disziplin noch. Über SetPDFAMode oder SetPDFUAMode angeforderte Compliance-Korrekturen greifen nicht beim Aufruf. Sie werden beim Speichern angewendet. Automatische Reparaturen wie das Erzwingen von Annotations-Druckflags oder das Zuweisen einer PDF/UA-Tab-Reihenfolge landen in der Ausgabedatei und nirgendwo sonst, sodass eine Prüfung gegen das Dokument, das Sie gerade im Speicher „repariert“ haben, nichts über die Bytes aussagt, die zum Signierer unterwegs sind. Erst speichern, dann die gespeicherte Datei prüfen. Der Zustand im Speicher ist ein Entwurf; nur die Datei auf der Festplatte ist real
Preflight von der Festplatte, und die Null, die zweierlei 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). Die Datei wird über den Streaming-Reader der Bibliothek geöffnet, ein vorheriges LoadFromFile ist also nicht nötig, und zurück kommt ein String-Listen-Handle mit einem Befund pro Eintrag:
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 could not read the file')
else
Writeln('No PDF/A findings');
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 steckt im Rückgabewert, und zwar von der Sorte, die jeden Happy-Path-Test besteht. Null bedeutet „keine Befunde“. Null bedeutet aber auch „die Datei konnte nicht geöffnet werden“, denn die Implementierung liefert 0, sobald die Ergebnisliste leer zurückkommt, Lesefehler eingeschlossen. Eine Workbench, die 0 als grünes Licht liest, winkt fröhlich eine Datei durch, die ein anderer Prozess gerade gesperrt hat. Den Aufruf wie oben mit LastErrorCode zu koppeln, ist das, was die beiden Fälle trennt. Der Prüfer öffnet die Datei zudem im Share-Modus „deny write“; hält Ihr Korrekturschritt also noch ein Writer-Handle, scheitert der Preflight aus einem Grund, der nichts mit Compliance und alles mit einem Stream zu tun hat, den Sie freizugeben vergessen haben
Wenn ein Mensch statt einer Pipeline die Befunde lesen soll, rendert CreatePreflightReport sie als lesbaren Bericht. ComparePreflightReports vergleicht zwei Läufe, ein sauberer Weg zu zeigen, dass die Korrektur die ursprünglichen Befunde beseitigt hat, ohne stillschweigend neue einzuführen
Die geprüfte Revision mit einem SignProcess signieren
Sobald die gespeicherte Revision den Preflight besteht und ihr Hash protokolliert ist, signieren Sie genau diese Datei und keine andere. Die SignProcess-API liest sich wie ein Builder. Ein Prozess-Handle öffnen, 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('Cannot open source for signing');
PDF.SetSignProcessField(ProcessID, 'ApprovalSig');
PDF.SetSignProcessPFXFromFile(ProcessID, 'company.pfx', PfxPassword);
PDF.SetSignProcessInfo(ProcessID, 'Invoice approval', '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 einen späteren Zeitstempel
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);
Zwei Zeilen in dieser Abfolge tragen mehr Gewicht, als es scheint. SetSignProcessCustomSubFilter mit ETSI.CAdES.detached wählt eine PAdES-Signatur nach dem Profil in ETSI EN 319 142-1 statt der veralteten adbe.pkcs7.detached-Familie, und das ist der Unterschied zwischen einer Signatur, die ein europäischer Validator akzeptiert, und einer, die er beanstandet. SetSignProcessReserveContentsBytes polstert den /Contents-Platzhalter, und die Größe, die Sie hier wählen, ist eine Entscheidung über die Zukunft: Soll jemals ein Signaturzeitstempel folgen, muss das vergrößerte CMS in den jetzt reservierten Platz passen, denn der Platzhalter kann später nicht wachsen, ohne das Ganze neu zu signieren. Reservieren Sie großzügig, verschwenden Sie ein paar Kilobyte. Reservieren Sie zu knapp, scheitert der Zeitstempelschritt Monate später mit einem Überlauf, den Sie nur mühsam auf diese eine Zeile zurückführen
GetSignProcessResult antwortet mit einem Code, nicht mit einem Boolean, und die Codes lohnt es sich aufzubewahren. 1 ist Erfolg. 4 ist ein falsches PDF-Passwort, 7 ein falsches Zertifikatspasswort, 9 ein PFX ohne privaten Schlüssel, 11 ein Fehler beim Anbringen der Signatur. Reduzieren Sie das auf true/false, werfen Sie genau die Information weg, die einen Supportfall mit falschem Passwort von einem mit Schlüssel ohne privaten Teil unterscheidet. Protokollieren Sie den Integer
Zurücklesen: die gerade erzeugte Datei auditieren
Keine Workbench sollte dem Pfad trauen, der die Datei geschrieben hat, die sie gleich zertifizieren will. Die Audit-Klasse TPDFlibSignDoc öffnet die signierte Ausgabe erneut und liest die Einträge des Signatur-Dictionarys 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 Audit-Objekt hält eine Share-Sperre auf der 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, das 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], ': signature covers the file to EOF')
else
Writeln(Names[I], ': earlier revision, or unusual ByteRange layout');
end;
Doc.Close;
finally
Names.Free;
Doc.Free;
end;
end;
Die ValueKey-Argumente bilden auf Dictionary-Einträge ab. Schlüssel 0 liefert das rohe CMS aus /Contents, die Schlüssel 2 und 3 die Namen von /Filter und /SubFilter, und 11 bis 14 die vier ByteRange-Zahlen. Textwerte kommen stattdessen über GetSignatureTextValueByName zurück: Schlüssel 0 ist die behauptete Signierzeit, und Schlüssel 5 unterscheidet ein gewöhnliches Sig von einem DocTimeStamp, was zählt, sobald ein Dokument beides trägt
Die Erfassung der Dateigröße am Anfang dieses Beispiels ist tragend, keine Aufräumarbeit. TPDFlibSignDoc.Open hält die Datei für seine gesamte Lebensdauer unter einer restriktiven Share-Sperre, sodass alles, was die rohen Bytes braucht (das Hashen des signierten Bereichs, das Neuberechnen des CMS-Digests), die Datei lesen muss, bevor Open aufgerufen wird. Die bibliothekseigene SigningWorkbench-Demo liest aus genau diesem Grund zuerst die ganze Datei in den Speicher, und eine Workbench, die diese Reihenfolge ignoriert, scheitert sporadisch, auf welcher Maschine auch immer gerade das Rennen verliert
ByteRange-Arithmetik, die die Abdeckung beweist
Eine gesunde Datei mit einer einzigen Signatur hat eine ByteRange der Form [0 a b c]: Die Abdeckung beginnt bei Offset 0, überspringt den hexadezimalen /Contents-Platzhalter zwischen a und b und setzt dann bis Byte b+c fort. Ist b+c gleich der Dateigröße, deckt die Signatur alles bis zum Dateiende ab, und das ist das gewünschte Ergebnis. Bleibt der Wert dahinter zurück, hat jemand nach dem Schreiben der Signatur ein inkrementelles Update angehängt. Das ist nach ISO 32000-1 §12.8 völlig legitim, denn spätere Formularausfüllungen, eine zweite Signatur und ein DSS-Dictionary kommen alle genau auf diesem Weg. Es ist auch exakt der Sachverhalt, den ein Audit-Trail zum Signierzeitpunkt festhalten sollte, statt ihn unter Druck während eines Streitfalls zu rekonstruieren
Achten Sie bei dieser Arithmetik auf die Integer-Breite. GetSignProcessByteRange der flachen API liefert einen 32-Bit-Integer, die zugrunde liegenden Werte sind jedoch Int64, sodass der flache Zugriff bei einer Datei jenseits von 2 GB stillschweigend abschneidet. Greifen Sie zur Klassenschicht TPDFlibSigner.GetByteRange, die Int64 zurückgibt, oder parsen Sie die Werte aus GetSignatureValueByName, wie es der Audit-Code oben tut
Was die Bibliothek Ihnen überlässt
Zwei Grenzen lernt man besser zur Entwurfszeit als im letzten Sprint. Die flache TPDFlib-API enthält überhaupt keinen Wrapper für die Signaturprüfung. Die kryptografische Verifikation liegt eine Ebene tiefer, in TPDFlibSignatureVerifier, dessen VerifySignature mit gültig, ungültig oder unbekannt antwortet. Es gibt auch keinen eingebauten HTTP-Client für RFC-3161-Zeitstempeldienste. Die Bibliothek berechnet den einzureichenden Hash und bettet das erweiterte CMS wieder ein, sobald ein Token zurückkommt, aber den Netzwerk-Roundtrip zur TSA müssen Sie selbst schreiben. Beides lässt sich leicht kapseln und ist ausgesprochen unangenehm, wenn man es eine Woche vor dem Release vermisst, planen Sie es also von der ersten Skizze an ein
Eine Frage zur Compliance sollte man klar beantworten, weil sie entscheidet, wo das letzte Tor steht: Bricht das Hinzufügen einer Signatur PDF/A? Für sich genommen nicht. Die Signatur kommt als inkrementelles Update, und ISO 19005-2 und spätere Teile erlauben signierte Dokumente ausdrücklich. Der Haken ist das Signatur-Erscheinungsbild, das denselben Regeln unterliegt wie jeder andere Seiteninhalt, eingebettete Schriften und keine geräteabhängigen Farben eingeschlossen. Das letzte Tor in der Workbench ist daher ein weiterer Preflight-Lauf, diesmal gegen die signierte Ausgabe. Behandeln Sie CheckFileCompliance als schnelle Prüfung innerhalb der Pipeline und verifizieren Sie Release-Kandidaten trotzdem mit einem unabhängigen Werkzeug wie veraPDF, da Validatoren überlappende, aber nicht identische Regelsätze implementieren; wenn beide sich widersprechen, nennt der Befundtext meist die Klausel, die man nachlesen sollte
Ein Punkt zur Reihenfolge ergibt sich aus alledem. Signieren und Zeitstempeln sind kein einzelner Durchlauf: Zuerst wird die Basissignatur geschrieben, dann erweitert ein separater Zeitstempelprozess das CMS innerhalb des reservierten /Contents-Platzes, und genau deshalb trug die Reserve-Bytes-Zeile weiter oben so viel Gewicht. Für die Zeitstempel- und Langzeitvalidierungsschichten, die auf dieser Workbench aufbauen, führt die Anleitung zu PAdES-Signierung und -Validierung die Signatur von Baseline bis B-LT, und die Preflight-Hälfte vertieft der Leitfaden zum PDF/A- und PDF/UA-Preflight. Die vollständige API-Dokumentation und Testversionen finden Sie auf der Produktseite von PDF Library for Delphi