Technical Article

A libcurl Timestamp Backend for PDFium VCL on FPC

PDFium VCL sends RFC 3161 timestamp requests through libcurl on non-Windows targets, dynamically bound to eight symbols, mirroring the shape of the Windows backend that binds to WinHTTP. Two option settings decide whether the transport is reliable under load, and the whole unit was validated on a machine that could not compile it for its target platform

Timestamping is what turns a signature into something that survives certificate expiry, and it is a network operation sitting inside a signing operation. That combination makes the transport choice consequential in a way it usually is not: it runs on a worker thread, it talks to a server you do not control, and a hang there stalls a signing pipeline rather than a page load

Why libcurl instead of the FPC HTTP client?

Because the alternative drags a TLS stack into the repository and then makes you maintain its version detection. The obvious route on Free Pascal is fphttpclient with the OpenSSL socket layer, and it fails on the details: the FPC 3.2.2 OpenSSL bindings detect OpenSSL 3.x unreliably on most current distributions, and macOS adds LibreSSL differences on top. What starts as a small HTTP call becomes ongoing maintenance of somebody else TLS ABI

libcurl resolves its own TLS backend and validates chains against the platform trust store, so the Pascal side needs none of that. The binding layer is eight symbols. That count is the argument: a smaller surface between your code and a moving dependency means fewer places for a distribution upgrade to break you, and it matches the existing Windows backend, which binds a handful of WinHTTP entry points the same way

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;

Declaring a C variadic function in Pascal

curl_easy_setopt and curl_easy_getinfo are variadic on the C side, and Object Pascal has no way to express that. The approach that works is to declare several fixed prototypes, one per argument class, all pointing at the same exported symbol: a long-taking variant, a pointer-taking variant, and so on, chosen at the call site by what you are actually passing

This is safe for a specific reason worth understanding rather than copying. Each of those argument types is passed in an integer register under the platform calling conventions in play, which is exactly where the C implementation va_arg reads it from. The trick therefore holds for integers, pointers and handles, and it does not hold for floating-point arguments, which travel in different registers. Do not add a double-taking variant on the assumption that the pattern generalizes

// One exported symbol, several fixed prototypes. Every variant passes
// its argument in an integer register, which is where the C side reads
// it. A floating-point variant would not work and must not be added
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;

Two settings that decide whether the request completes

The first is an explicit empty Expect: header. libcurl turns on the HTTP 100-continue handshake for request bodies over roughly one kilobyte, and a timestamp query with a certificate request usually clears that threshold. Some TSA servers never answer the continuation, so the client waits out a full timeout before sending a body the server would have accepted immediately. Sending an empty Expect: header suppresses the handshake, and the request goes through in one round trip

The second is CURLOPT_NOSIGNAL, which must be set. Without it libcurl implements its name-resolution timeout using SIGALRM, and that mechanism is not thread-safe. Signing runs on a worker thread, so the default behavior is a latent crash that appears under concurrency and never in a single-threaded test. Setting the flag disables the signal-based path and costs only the resolver timeout granularity

Both defects share a profile that makes them expensive to find later. Neither shows up in a functional test against a well-behaved server on a single thread. Both appear in production, against one particular TSA, under load. When you bind a networking library, read what its defaults assume about your process before you assume they match

PDFium VCL libcurl timestamp transport diagram showing curl_easy_setopt declared as fixed long and pointer Pascal prototypes that pass arguments in integer registers, the empty Expect header that suppresses the HTTP 100-continue handshake, CURLOPT_NOSIGNAL that removes the SIGALRM path on worker threads, and the transport-level response cap
Two settings decide whether the request completes: an empty Expect header avoids servers that never answer the continuation, and NOSIGNAL keeps name-resolution timeouts off the signal path while signing runs on a worker thread

How do you verify code your compiler will never see?

By making the compiler see it anyway, through a controlled copy. The development machine here has no Linux or macOS cross-compiler, so the non-Windows branches of the timestamping unit never reach the code generator during a normal build. Code that is never compiled is code that silently rots: a rename in a shared type, a changed parameter list, an added unit dependency, and nobody notices for months

The technique is mechanical. Copy the unit to a temporary directory, rename it, and replace every Windows conditional, both the {$IFDEF MSWINDOWS} form and the {$IF DEFINED(MSWINDOWS) form, with a symbol that is never defined. Then compile the copy. When all 3,828 lines compile, you have proved that the non-Windows path uses units that exist, calls backend functions with matching signatures, and references types that are in scope. That is not proof the transport works, and nothing short of the target platform will give you that. It is proof that the branch is not already broken, which is the failure mode that actually accumulates

The companion habit is to leave the libcurl unit itself free of platform guards, so it participates in the ordinary Windows build even though nothing there references it. The daily build then keeps guarding its syntax and types for free. A unit that only compiles on a platform you do not have is a unit with no compiler checking it at all, and the same reasoning applies across the cross-compiler work described in Delphi and FPC cross-compiler pitfalls

Bounding what comes back

A timestamp response is a small DER structure, and nothing about the transport enforces that. A server that is compromised, misconfigured, or simply pointed at the wrong URL can return an arbitrary stream, and a client that reads until the connection closes will happily accumulate it. Both transports therefore cap the response, which is the correct place for the limit: refusing at the transport keeps an oversized body from ever being allocated, whereas a parser-level check only fires after the memory has been committed

The same reasoning applies to the URL. The backend accepts only schemes it can meaningfully speak, so a configuration mistake fails immediately with a clear message instead of being handed to libcurl to interpret in whatever way its protocol support allows

Where the transport sits in the signing story

Timestamping is the first step of the long-term validation story rather than the whole of it. The token has to be attached to the signature, the validation material has to be recorded in the document security store, and archive timestamps have to be renewed before the current one weakens. That whole arc is covered in long-term PDF signatures with RFC 3161 timestamps and the DSS

PDFium VCL diagram of an RFC 3161 timestamp request flowing from DocumentDigest through BuildTimeStampQuery and PostTimeStampQuery over libcurl to a TSA server, the DER response capped at the transport, then AttachTimeStampToken feeding the DSS and archive timestamp renewal in long-term validation
Timestamping is the first step of the long-term validation story: the token must be attached, validation material recorded in the document security store, and archive timestamps renewed before the current one weakens

The transport is also one piece of a broader portability position: the native library loader described in loading the native library on any target handles the same class of problem for the PDFium binary itself. In both cases the pattern is identical, bind a small number of symbols dynamically, report precisely what failed to bind, and never let a missing dependency turn into a link-time failure that stops the application from starting

The Windows and non-Windows timestamp backends both ship with the PDFium Delphi component, selected by target rather than by configuration, so a Lazarus application on Linux and a Delphi application on Windows produce the same timestamped signature through different plumbing