Technischer Artikel

Ein libcurl-Timestamp-Backend für PDFium VCL unter FPC

PDFium VCL schickt RFC-3161-Timestamp-Requests über libcurl auf Nicht-Windows-Zielen, dynamisch an acht Symbole gebunden, als Spiegelbild des Windows-Backends, das an WinHTTP bindet. Zwei Options-Einstellungen entscheiden, ob der Transport unter Last verlässlich ist, und die ganze Unit wurde auf einer Maschine validiert, die sie für ihre Zielplattform nicht kompilieren konnte

Timestamping macht aus einer Signatur etwas, das den Ablauf des Zertifikats überlebt, und es ist eine Netzwerkoperation, die in einer Signieroperation sitzt. Diese Kombination macht die Transportwahl auf eine Weise folgenreich, wie es sonst selten ist: Sie läuft auf einem Worker-Thread, sie spricht mit einem Server, den man nicht kontrolliert, und ein Hängen dort blockiert eine Signing-Pipeline statt eines Seitenladevorgangs

Warum libcurl statt des FPC-HTTP-Clients?

Weil die Alternative einen TLS-Stack ins Repository zieht und einen dann deren Versionserkennung pflegen lässt. Der naheliegende Weg unter Free Pascal ist fphttpclient mit der OpenSSL-Socket-Layer, und er scheitert an Details: Die FPC-3.2.2-OpenSSL-Bindings erkennen OpenSSL 3.x auf den meisten aktuellen Distributionen unzuverlässig, und macOS legt LibreSSL-Unterschiede obendrauf. Was als kleiner HTTP-Aufruf beginnt, wird zur dauerhaften Pflege des TLS-ABI eines anderen

libcurl löst sein eigenes TLS-Backend auf und validiert Ketten gegen den Trust Store der Plattform, die Pascal-Seite braucht davon nichts. Die Binding-Layer sind acht Symbole. Diese Zahl ist das Argument: Eine kleinere Oberfläche zwischen dem eigenen Code und einer wandernden Abhängigkeit heißt weniger Stellen, an denen ein Distributions-Upgrade einen bricht — und sie entspricht dem bestehenden Windows-Backend, das auf dieselbe Weise eine Handvoll WinHTTP-Einsprungpunkte bindet

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

Eine C-variadische Funktion in Pascal deklarieren

curl_easy_setopt und curl_easy_getinfo sind auf der C-Seite variadisch, und Object Pascal hat keine Möglichkeit, das auszudrücken. Der Weg, der funktioniert: mehrere feste Prototypen deklarieren, einen pro Argumentklasse, alle auf dasselbe exportierte Symbol zeigend — eine Long-Variante, eine Pointer-Variante und so weiter, gewählt an der Aufrufstelle danach, was man tatsächlich übergibt

Das ist aus einem bestimmten Grund sicher, den man besser versteht als kopiert. Jeder dieser Argumenttypen wird unter den im Spiel befindlichen Plattform-Aufrufkonventionen in einem Integer-Register übergeben — genau dort, wo die C-Implementierung ihn mit va_arg liest. Der Trick gilt also für Integer, Pointer und Handles, und er gilt nicht für Fließkommaargumente, die in anderen Registern reisen. Fügen Sie keine Double-Variante hinzu in der Annahme, das Muster verallgemeinere sich

// Ein exportiertes Symbol, mehrere feste Prototypen. Jede Variante übergibt
// ihr Argument in einem Integer-Register, genau dort liest die C-Seite es.
// Eine Fließkomma-Variante würde nicht funktionieren und darf nicht dazu kommen
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

Zwei Einstellungen, die entscheiden, ob der Request durchläuft

Die erste ist ein explizit leerer Expect:-Header. libcurl schaltet den HTTP-100-continue-Handshake für Request-Bodies über etwa ein Kilobyte ein, und eine Timestamp-Query mit Zertifikatsrequest übersteigt diese Schwelle meist. Manche TSA-Server antworten nie auf die Continuation, also wartet der Client einen vollen Timeout ab, bevor er einen Body sendet, den der Server sofort akzeptiert hätte. Ein leerer Expect:-Header unterdrückt den Handshake, und der Request läuft in einem Round Trip durch

Die zweite ist CURLOPT_NOSIGNAL, die gesetzt sein muss. Ohne sie implementiert libcurl ihren Namensauflösungs-Timeout mit SIGALRM, und dieser Mechanismus ist nicht thread-safe. Signing läuft auf einem Worker-Thread, das Default-Verhalten ist also ein latenter Crash, der unter Nebenläufigkeit auftaucht und im einsträngigen Test nie. Das Flag zu setzen deaktiviert den signalbasierten Pfad und kostet nur die Granularität des Resolver-Timeouts

Beide Defekte teilen ein Profil, das sie später teuer macht. Keiner zeigt sich in einem Funktionstest gegen einen wohlgesonnenen Server auf einem einzelnen Thread. Beide erscheinen in Produktion, gegen einen bestimmten TSA, unter Last. Wenn man eine Netzwerkbibliothek bindet, liest man nach, was ihre Defaults über den eigenen Prozess annehmen, bevor man annimmt, sie passen

Diagramm des libcurl-Timestamp-Transports in PDFium VCL: curl_easy_setopt als feste Long- und Pointer-Pascal-Prototypen deklariert, die Argumente in Integer-Registern übergeben, der leere Expect-Header, der den HTTP-100-continue-Handshake unterdrückt, CURLOPT_NOSIGNAL, das den SIGALRM-Pfad auf Worker-Threads entfernt, und das Response-Limit auf Transportebene
Zwei Einstellungen entscheiden, ob der Request durchläuft: Ein leerer Expect-Header vermeidet Server, die die Continuation nie beantworten, und NOSIGNAL hält Namensauflösungs-Timeouts vom Signalpfad fern, während Signing auf einem Worker-Thread läuft

Wie verifiziert man Code, den der Compiler nie zu sehen bekommt?

Indem man den Compiler ihn doch sehen lässt — über eine kontrollierte Kopie. Die Entwicklungsmaschine hier hat keinen Linux- oder macOS-Cross-Compiler, also erreichen die Nicht-Windows-Zweige der Timestamping-Unit den Codegenerator bei einem normalen Build nie. Code, der nie kompiliert wird, ist Code, der still verrottet: eine Umbenennung in einem geteilten Typ, eine geänderte Parameterliste, eine hinzugekommene Unit-Abhängigkeit — und niemand bemerkt es monatelang

Die Technik ist mechanisch. Die Unit in ein temporäres Verzeichnis kopieren, umbenennen und jedes Windows-Conditional ersetzen — sowohl die {$IFDEF MSWINDOWS}-Form als auch die {$IF DEFINED(MSWINDOWS)-Form — durch ein Symbol, das nie definiert ist. Dann die Kopie kompilieren. Wenn alle 3.828 Zeilen kompilieren, hat man bewiesen, dass der Nicht-Windows-Pfad Units benutzt, die existieren, Backend-Funktionen mit passenden Signaturen aufruft und Typen referenziert, die im Scope sind. Das ist kein Beweis, dass der Transport funktioniert, und nichts unterhalb der Zielplattform gibt einem das. Es ist der Beweis, dass der Zweig nicht bereits gebrochen ist — und das ist der Fehlermodus, der sich tatsächlich akkumuliert

Die Begleitgewohnheit: Die libcurl-Unit selbst frei von Plattform-Guards lassen, damit sie am gewöhnlichen Windows-Build teilnimmt, obwohl sie dort nichts referenziert. Der Daily Build bewacht dann gratis ihre Syntax und Typen. Eine Unit, die nur auf einer Plattform kompiliert, die man nicht hat, ist eine Unit ohne jeden Compiler, der sie prüft — und dieselbe Überlegung gilt über die gesamte Cross-Compiler-Arbeit, beschrieben in Delphi- und FPC-Cross-Compiler-Stolperfallen

Begrenzen, was zurückkommt

Eine Timestamp-Response ist eine kleine DER-Struktur, und nichts am Transport setzt das durch. Ein kompromittierter, falsch konfigurierter oder schlicht auf die falsche URL zeigender Server kann einen beliebigen Stream zurückgeben, und ein Client, der liest, bis die Verbindung schließt, akkumuliert den fröhlich. Beide Transporte deckeln deshalb die Response, und das ist der richtige Ort für das Limit: Die Verweigerung auf Transportebene verhindert, dass ein übergroßer Body überhaupt allokiert wird, während eine Prüfung auf Parser-Ebene erst feuert, nachdem der Speicher schon verpflichtet wurde

Dasselbe Denken gilt für die URL. Das Backend akzeptiert nur Schemata, die es sinnvoll sprechen kann, also scheitert ein Konfigurationsfehler sofort mit einer klaren Meldung, statt an libcurl weitergereicht zu werden, das ihn interpretiert, wie es seine Protokollunterstützung erlaubt

Wo der Transport in der Signing-Geschichte sitzt

Timestamping ist der erste Schritt der Langzeitvalidierungsgeschichte, nicht deren Ganzes. Der Token muss an die Signatur angehängt werden, das Validierungsmaterial muss im Document Security Store festgehalten werden, und Archiv-Timestamps müssen erneuert werden, bevor der aktuelle schwächelt. Den ganzen Bogen behandelt Langzeit-PDF-Signaturen mit RFC-3161-Timestamps und dem DSS

PDFium-VCL-Diagramm eines RFC-3161-Timestamp-Requests, der von DocumentDigest über BuildTimeStampQuery und PostTimeStampQuery über libcurl zu einem TSA-Server fließt, die DER-Response auf Transportebene gedeckelt, dann AttachTimeStampToken, der den DSS und die Erneuerung von Archiv-Timestamps in der Langzeitvalidierung speist
Timestamping ist der erste Schritt der Langzeitvalidierung: Der Token muss angehängt werden, Validierungsmaterial im Document Security Store festgehalten, und Archiv-Timestamps erneuert, bevor der aktuelle schwächelt

Der Transport ist auch ein Teil einer breiteren Portabilitätsposition: Der in dem Laden der nativen Bibliothek auf jedem Ziel beschriebene native Library-Loader behandelt dieselbe Problemklasse für die PDFium-Binary selbst. In beiden Fällen ist das Muster identisch: eine kleine Zahl von Symbolen dynamisch binden, präzise melden, was nicht gebunden hat, und eine fehlende Abhängigkeit niemals zu einem Link-Fehler werden lassen, der die Anwendung am Starten hindert

Die Windows- und die Nicht-Windows-Timestamp-Backends liegen beide der PDFium Delphi component bei, gewählt nach Ziel statt nach Konfiguration, sodass eine Lazarus-Anwendung unter Linux und eine Delphi-Anwendung unter Windows dieselbe Signatur mit Timestamp über unterschiedliche Leitungssysteme erzeugen