Technical Article

OpenSSL AIA and CRL Retrieval for PDF Signatures in Delphi

PDFium VCL can now complete a PDF signature chain and check revocation over the network on its OpenSSL backend: when OnlineRetrieval is enabled, ConfigureSslCmsVerifier installs a verifier that downloads missing intermediate certificates from the AIA caIssuers URLs and CRLs from the CRL distribution points, inside a fixed time, request and byte budget per verification call. Downloaded certificates are only ever chain material. Trust still comes exclusively from the system store and the anchors you configure

The gap this closes shows up the first time you validate real-world PDFs on a Linux server. A large share of signers embed only their own leaf certificate in the CMS, so OpenSSL cannot reach a root, TrustStatus comes back invalid, and revocation never runs because the chain never became trustworthy. Before v3.121.0 the OpenSSL backend described in verifying PDF signatures with OpenSSL in PDFium VCL was strictly offline and OnlineRetrieval had no effect on it. One thing is worth stating up front: the PDFium engine itself does no CMS verification at all, so every rule below lives in the component's PAdES layer and its OpenSSL binding, where you can read it

In what order does the OpenSSL backend verify, fetch and check?

Integrity first, then trust, then revocation, and the network is touched only between the steps that need it. VerifyCmsWithSsl checks the CMS signature and signed attributes (RFC 5652) with chain evaluation suppressed, and if that fails it returns immediately, before a fetch session even exists, so a document with broken bytes triggers no outbound request. Only if the chain then fails and OnlineRetrieval is on does it follow AIA links and verify again. CRL distribution points are fetched only once the chain is trusted, because a CRL that hangs off an untrusted path proves nothing. The three verdicts stay separate throughout: a valid signature with an incomplete chain is still reported as a valid signature

Order of VerifyCmsWithSsl in the PDFium Component OpenSSL backend: the CMS signature check runs with chain evaluation suppressed, so broken bytes never touch the network; RetrieveIntermediates follows AIA caIssuers URLs only after a chain failure with OnlineRetrieval enabled, and RetrieveCrls fetches distribution point CRLs into a separate store once the chain is trusted
Integrity, then trust, then revocation: the network is touched only between the steps that need it, and a CRL hanging off an untrusted path proves nothing
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; the only extra trust
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // ptnpOffline by default
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // per verification call

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

Why are downloaded certificates never added to the trust store?

Because the URLs come from the certificate being validated, and the signer chose them. The authorityInfoAccess caIssuers entry (RFC 5280 §4.2.2.1) is a hint about where the issuer lives, nothing more. If whatever answers that URL went into the trust-anchor store, anyone could sign with a self-made key, point AIA at their own server, and receive a green verdict. RetrieveIntermediates therefore passes each parsed certificate to CMS_add1_cert, which places it in this one CMS structure's untrusted set, and OpenSSL still has to build a path from it to an anchor you configured or the system store already holds. There is a quieter reason too: the certificate argument of CMS_verify is not a drop-in substitute for the certificates embedded in the CMS, so adding to the CMS itself is the reliable path

The retrieval loop is deliberately narrow. RetrieveIntermediates runs at most 4 rounds, each collecting caIssuers URLs from every certificate now in the CMS, and stops as soon as a round adds nothing or the time budget is spent. A response must decode with d2i_X509 as a single DER certificate that consumes the entire body; trailing bytes are rejected, and a PKCS#7 certs-only bundle served from a .p7c URL is skipped rather than unpacked. The OCSP access method in the same AIA extension is ignored, since this backend does not speak OCSP. On the revocation side, RetrieveCrls reads only the fullName URIs of each DistributionPoint (RFC 5280 §4.2.1.13) from the CMS certificates and the configured anchors, and the downloaded CRLs go into a second, independent X509_STORE with full-chain CRL checking, so a missing or stale CRL changes RevocationStatus without ever touching TrustStatus

// Condensed from VerifyCmsWithSsl (FPdfCryptoSsl.pas); BIO setup omitted.
// Every _CMS_verify call gets a fresh content BIO
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // broken signature: no network at all
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert, untrusted only
  // verify the chain again against the same anchor store
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// second, separate store: configured CRLs plus retrieved ones
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

What does one verification call cost at most?

A fixed ceiling, enforced by one TPdfCryptoFetchSession that the AIA and CRL steps of a single verification call share. The limits are constants in FPdfCryptoHttp, not suggestions:

  • Time: UrlRetrievalTimeoutMs, which defaults to 15000 in both TPdfCmsVerifyOptions.Default and TPadesTrustValidationOptions.Default; a session created with 0 falls back to 30000, and the clock starts once the signature has passed, covering every later request
  • Requests: at most 8 per session, counted before the transport is attempted, so a dead host still uses up a slot
  • Bytes: 1 MiB per response and 4 MiB in total, with URLs longer than 2048 characters refused before any connection
The hard ceilings of one TPdfCryptoFetchSession shared by the AIA and CRL steps of a verification call in PDFium Component: UrlRetrievalTimeoutMs defaults to 15000 ms with a zero fallback of 30000, at most 8 requests per session, 1 MiB per response and 4 MiB in total with failed responses still counting, and URLs over 2048 characters refused
The limits are constants, not suggestions: bytes from a failed response still drain the budget, and because each signature and timestamp verifies separately the worst case grows with the signature count

The accounting is stricter than it first looks. Bytes received from a failed response still count against the total, so a server answering 404 with a large page cannot drain the budget for free. The read that crosses the per-response limit aborts the download instead of passing a truncated body on to the ASN.1 parser, and an HTTP 200 with an empty body is rejected outright, because the AIA path would otherwise index Data[0] of an empty array. Only plain http:// and https:// URLs pass, with no redirects, cookies, credentials or automatic proxy discovery, while HTTPS keeps its normal certificate and hostname checks. URL deduplication is scoped to one call on purpose: the next validation must be able to see a freshly published CRL. The budget is also per call, not per document, and ValidatePadesTrust verifies each signature and each timestamp token separately, so the worst case grows with the number of signatures

Why can a timed-out WinHTTP request still write into your memory?

Because returning on timeout does not cancel the callbacks already in flight. The Windows transport drives WinHTTP asynchronously and waits on an event with the session's remaining time, and when that wait gives up, the request can still complete a read and signal afterwards. Point the asynchronous read at a stack buffer and that late completion writes into a frame that belongs to some unrelated function by then. The fix is ownership, not timing: the event and the 16 KB read buffer live in a heap record with two references, one held by the caller and one released only by the final HANDLE_CLOSING callback, so whichever side finishes last frees the memory

Why a timed-out WinHTTP request can still write into memory: returning on timeout leaves callbacks in flight, so the PDFium Component points the asynchronous read at a heap allocated THttpState record whose 16 KB buffer and two references, one held by the caller and one released by the final HANDLE_CLOSING callback, are freed only when the last side finishes
A late completion may finish its read after your wait has given up; heap ownership with two references means that write lands in memory that is still alive
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // caller + final HANDLE_CLOSING callback
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // async reads land here, never on a stack
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// In the status callback: HANDLE_CLOSING is the last notification WinHTTP
// sends for the request, so it drops the second reference
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

What libcurl has to provide on FPC Unix

An asynchronous resolver and a thread-safe build, or online retrieval stays off. On FPC Unix the transport goes through libcurl, the same dependency behind the libcurl timestamp backend for non-Windows targets, and the binding refuses any library whose feature mask lacks either CURL_VERSION_ASYNCHDNS or CURL_VERSION_THREADSAFE. The reason is that CURLOPT_NOSIGNAL, which a library inside someone else's process must set, combined with a synchronous resolver means a DNS lookup can simply outlive the timeout. The second trap is shutdown: curl_global_cleanup does not wait for asynchronous DNS threads, so once libcurl has been initialized the module stays mapped until the process exits rather than letting a background thread run into unloaded code. When either requirement fails, SslCapabilities.OnlineRetrieval is False and SslVerifyOptionsDiagnostics reports psvdOnlineRetrievalIgnored instead of pretending the network was consulted

What the result does and does not guarantee

A valid RevocationStatus from this backend means current CRLs covering the whole chain were found, configured or downloaded, and none listed a certificate in it; nothing more. There is no OCSP, so a CA that publishes revocation only through OCSP leaves the result unsupported, and a network failure looks exactly like a CA that publishes nothing. Also note that psvdNoCrlsConfigured describes only the CRLs you configured, so with online retrieval it is a hint, not a forecast of failure. When an audit trail must be reproducible without network access, leave NetworkPolicy at its ptnpOffline default: no fetch session is created and the backend never opens a connection, which matches the offline contract on the CryptoAPI side described in offline PDF signature revocation checks on Windows

The retrieval code, the budgets and the transport bindings ship as source with the PDFium Delphi component, so you can confirm exactly which URLs a validation may contact and how much it may download before you enable ptnpOnline on a server that handles untrusted documents