Technischer Artikel

Eine Compliance- und Signatur-Workbench in Delphi mit PDFlibPas bauen

Die Frage, an der schwache Signatur-Pipelines brechen, ist selten kryptografisch. Ein Auditor fragt: „Ihr Preflight-Bericht sagt, dieser Rechnungsstapel sei PDF/A-konform — wurde das vor oder nach dem Anbringen der Signatur geprüft?“ Wenn Validierung und Signierung als zwei getrennte Werkzeuge laufen und dazwischen noch eine Reparaturphase liegt, existieren mindestens drei Revisionen der Datei, und der Bericht beschreibt nur eine davon. PDFlibPas, die losLab PDF Developer Library für Delphi und C++Builder, stellt Preflight und PAdES-Signierung hinter einer Fassadeklasse bereit. Damit lässt sich eine Workbench bauen, in der diese Frage beweisbar beantwortet werden kann.

Dieser Artikel geht das Workbench-Muster Ende zu Ende durch: Preflight auf exakt den Bytes, die signiert werden, eine Signatur über die SignProcess-API und ein Read-back-Audit, das bestätigt, dass die ByteRange wirklich die Datei abdeckt. Jeder hier gezeigte Aufruf existiert heute in der Bibliothek, und jede Falle ebenfalls.

Drei Revisionen eines Dokuments und wie die Lücke entsteht

Ein Compliance-dann-signieren-Workflow berührt die Datei mindestens dreimal. Das Original kommt aus einem vorgelagerten System. Eine Reparaturphase lädt es, aktiviert einen Compliance-Modus und speichert eine korrigierte Revision. Die Signaturphase hängt danach eine Signatur als inkrementelles Update an. Jede dieser Speicherungen verändert Bytes, also ist ein Preflight-Bericht nur dann aussagekräftig, wenn er festhält, welche Revision er beschreibt. Der billigste Anker dafür ist ein SHA-256-Hash der Datei, der neben jedem Preflight-Lauf und jeder Signatur protokolliert wird.

Ein Bibliotheksverhalten macht diese Verankerung strenger, als man zunächst erwartet: Compliance-Korrekturen, die über SetPDFAMode oder SetPDFUAMode angefordert werden, werden beim Speichern angewendet, nicht im Moment des Aufrufs. Automatische Reparaturen wie das Erzwingen von Print-Flags für Annotationen oder das Setzen einer PDF/UA-Tab-Reihenfolge landen nur in der Ausgabedatei. Den Checker gegen das Dokument auszuführen, das Sie gerade im Speicher „repariert“ haben, beweist nichts über die Bytes, die Sie signieren werden — führen Sie Preflight immer erneut gegen die gespeicherte Datei aus.

Preflight von der Platte und die Null mit zwei Bedeutungen

Der flache Preflight-Einstieg ist CheckFileCompliance(FileName, Password, ComplianceTest, Options), wobei Test 1 PDF/A (ISO 19005) und Test 2 PDF/UA (ISO 14289) auswählt. Er öffnet die Datei über den Streaming-Reader der Bibliothek — ein vorheriges LoadFromFile ist nicht nötig — und gibt ein Stringlisten-Handle mit einem Befund pro Eintrag zurück:

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. Null bedeutet „keine Befunde“, aber auch „die Datei konnte nicht geöffnet werden“ — die Implementierung gibt 0 zurück, wenn die Ergebnisliste leer bleibt, auch bei einem Lesefehler. Eine Workbench, die 0 als grünes Licht behandelt, genehmigt eine Datei, die von einem anderen Prozess gesperrt ist. Kombinieren Sie den Aufruf deshalb wie oben mit LastErrorCode. Beachten Sie außerdem, dass der Checker die Datei mit einem Deny-Write-Share-Modus öffnet; wenn Ihr Reparaturschritt noch ein Writer-Handle hält, schlägt Preflight aus einem Grund fehl, der nichts mit Compliance zu tun hat.

Für die menschliche Prüfung rendert CreatePreflightReport dieselben Befunde als lesbaren Bericht, und ComparePreflightReports vergleicht zwei Läufe — ein bequemer Weg zu belegen, dass eine Reparatur Befunde entfernt hat, ohne neue einzuführen.

Die geprüfte Revision mit einem SignProcess signieren

Sobald die gespeicherte Revision Preflight besteht und ihr Hash protokolliert ist, signieren Sie exakt diese Datei. Die SignProcess-API ist ein Builder: Prozess-Handle öffnen, konfigurieren, abschließen, Ergebniscode lesen.

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);              // room for a later timestamp
PDF.EndSignProcessToFile(ProcessID, 'invoice-signed.pdf');
if PDF.GetSignProcessResult(ProcessID) <> 1 then
  Writeln('Sign failed, code ', PDF.GetSignProcessResult(ProcessID));
PDF.ReleaseSignProcess(ProcessID);

Zwei Konfigurationszeilen verdienen besondere Aufmerksamkeit. SetSignProcessCustomSubFilter mit ETSI.CAdES.detached wählt eine PAdES-Signatur nach ETSI EN 319 142-1 statt der älteren adbe.pkcs7.detached-Familie. Und SetSignProcessReserveContentsBytes polstert den /Contents-Platzhalter auf: Wenn Sie später einen Signaturzeitstempel hinzufügen wollen, muss das vergrößerte CMS in den jetzt reservierten Raum passen, weil der Platzhalter danach nicht mehr wachsen kann, ohne neu zu signieren.

GetSignProcessResult liefert codierte Ergebnisse: 1 steht für Erfolg, 4 für ein falsches PDF-Passwort, 7 für ein falsches Zertifikatspasswort, 9 für eine PFX ohne privaten Schlüssel, 11 für einen Fehler beim Anwenden der Signatur. Loggen Sie den Code statt eines Booleans — ein großer Teil der Signatur-Supportfälle sind vertauschte Zugangsdaten, die nur diese Werte sauber unterscheiden.

Read-back: Die gerade erzeugte Datei auditieren

Eine Workbench sollte dem eigenen Schreibpfad nie blind vertrauen. Die Auditklasse TPDFlibSignDoc öffnet die signierte Ausgabe erneut und legt die Einträge des Signaturwörterbuchs direkt offen:

var
  Doc: TPDFlibSignDoc;
  Names: TStringList;
  FS: TFileStream;
  I: Integer;
  SourceSize, RangeStart, GapStart, TailStart, TailLen: Int64;
begin
  // Capture the size before Open: the audit object holds a share lock on the file
  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 means the field is signed
      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 Wörterbucheinträge ab: 0 gibt das rohe CMS aus /Contents zurück, 2 und 3 die Namen /Filter und /SubFilter, und 11 bis 14 die vier ByteRange-Zahlen. Textwerte laufen über GetSignatureTextValueByName — Schlüssel 0 ist die behauptete Signierzeit, Schlüssel 5 unterscheidet ein gewöhnliches Sig von einem DocTimeStamp.

Die Dateigröße am Anfang zu erfassen ist keine Dekoration. TPDFlibSignDoc.Open hält die Datei für seine gesamte Lebensdauer mit einem restriktiven Share-Lock. Alles, was rohe Bytes braucht — Hashing des signierten Bereichs, erneutes Berechnen des CMS-Digests — muss die Datei vor Open lesen. Die bibliothekseigene SigningWorkbench-Demo liest aus genau diesem Grund zuerst die ganze Datei in den Speicher.

ByteRange-Arithmetik, die Abdeckung beweist

Eine gesunde Einzelsignaturdatei besitzt eine ByteRange der Form [0 a b c]: Die Abdeckung beginnt bei Offset 0, überspringt den hexadezimalen /Contents-Platzhalter zwischen a und b und läuft dann durch Byte b+c weiter. Wenn b+c der Dateigröße entspricht, deckt die Signatur alles bis zum Dateiende ab. Wenn nicht, hat jemand nach dem Signieren ein inkrementelles Update angehängt. Das ist nach ISO 32000-1 §12.8 legitim — spätere Formularfüllungen, eine zweite Signatur, ein DSS-Wörterbuch kommen alle so hinzu — aber genau diese Tatsache muss ein Audit-Trail vorneweg festhalten, statt sie erst im Streitfall zu entdecken.

Achten Sie beim Prüfen auf die Integer-Breite. Das flache API-GetSignProcessByteRange gibt einen 32-Bit-Integer zurück, während die zugrunde liegenden Werte Int64 sind; bei Dateien jenseits von 2 GB wird der flache Zugriff abgeschnitten. Verwenden Sie die Klassenebene TPDFlibSigner.GetByteRange, die Int64 zurückgibt, oder parsen Sie die Werte wie im Audit-Code oben aus GetSignatureValueByName.

Was in Ihrer Verantwortung bleibt

Seien Sie klar über die Grenzen. Die flache TPDFlib-API besitzt überhaupt keinen Wrapper zur Signaturverifikation; kryptografische Prüfung liegt in der Klassenebene als TPDFlibSignatureVerifier, dessen VerifySignature gültig, ungültig oder unbekannt zurückmeldet. Ebenso gibt es keinen eingebauten HTTP-Client für RFC-3161-Zeitstempelstellen — die Bibliothek berechnet den einzureichenden Hash und bettet das erweiterte CMS wieder ein, aber die Netzwerk-Runde gehört Ihrem Code. Planen Sie beides von Anfang an in das Workbench-Design ein; beides ist leicht zu kapseln und elend erst im letzten Sprint zu entdecken.

Häufige Fragen

Bricht das Hinzufügen einer Signatur die PDF/A-Compliance? Für sich genommen nicht. Die Signatur kommt als inkrementelles Update hinzu, und ISO 19005-2 und neuer erlauben signierte Dokumente ausdrücklich. Das Signaturbild folgt jedoch denselben Regeln wie jeder Seiteninhalt — eingebettete Schriften, keine geräteabhängige Farbe — daher sollte das letzte Gate der Workbench noch ein Preflight-Lauf auf der signierten Ausgabe sein.

Warum besteht meine Datei hier, fällt aber in einem externen Validator durch? Validatoren implementieren überlappende, aber nicht identische Regelsätze. Behandeln Sie CheckFileCompliance als schnelles Gate innerhalb der Pipeline und prüfen Sie Release-Kandidaten mit einem unabhängigen Werkzeug wie veraPDF. Wenn beide widersprechen, nennt der Befundtext meist die Klausel, die zu lesen ist.

Kann ich in einem Durchlauf signieren und zeitstempeln? Nein — die Basissignatur wird zuerst geschrieben, danach erweitert ein separater Zeitstempelprozess das CMS innerhalb des reservierten /Contents-Raums. Deshalb ist der Reserve-Bytes-Aufruf im Signierbeispiel wichtig; dimensionieren Sie ihn für das Zeitstempel-Token, das Sie erwarten.

Wie es weitergeht

Für Zeitstempel- und Langzeitvalidierungsschichten, die auf dieser Workbench aufbauen, siehe den PAdES-Signatur- und Validierungsdurchgang. Die Preflight-Hälfte behandelt der PDF/A- und PDF/UA-Preflight-Leitfaden ausführlicher.

Vollständige API-Dokumentation und Testdownloads stehen auf der PDFlibPas-Produktseite.