Technical Article

Offline PDF Signature Revocation Checks on Windows in Delphi

PDFium VCL checks PDF signature revocation offline on Windows by adding CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY to the revocation pass of CertGetCertificateChain, because the cache-only flag used for chain building does not cover CRL or OCSP retrieval at all. Since v3.119.1 an offline ValidatePadesTrust call stays off the network, and a clean result requires actual per-certificate revocation evidence. The rest of this post is about why both halves of that sentence needed fixing

The setup that exposes the problem is ordinary. A validation service runs on a locked-down Windows host, TPadesTrustValidationOptions.NetworkPolicy is ptnpOffline (which is also the default), and the operator expects every answer to come from the local certificate cache. Then someone notices outbound requests to a CA distribution point in the firewall log, or a batch job that stalls for the full UrlRetrievalTimeoutMs of 15000 ms on every signature. Nothing in the code asked for the network. Windows went there anyway

Why does an offline chain build still fetch CRLs on Windows?

Because CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL only restricts the URL retrieval that chain building does: AIA issuer fetches, root and CTL updates. The Microsoft documentation for CertGetCertificateChain says explicitly that the flag does not apply to revocation checking. Revocation has its own switch, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), and without it the revocation providers are free to download a CRL or send an OCSP request even though the surrounding call looks offline. PDFium VCL now ORs that flag into the revocation pass whenever OnlineRetrieval is False, on top of the chain flags, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT and CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. That matters for more than latency: an OCSP request tells the responder which certificate you are looking at, which is exactly what an air-gapped validator is supposed to avoid

Two independent switches guard offline PDF signature validation on Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL restricts only chain building such as AIA issuer and root fetches, while revocation needs CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, because without it providers still download CRLs and send OCSP requests that reveal which certificate is being validated
PDFium VCL ORs the revocation cache-only flag into the revocation pass whenever OnlineRetrieval is False, so a ptnpOffline trust validation stays off the network for both passes
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False by default
  Options.CheckTimeStamps := True;
  // Offline now means offline for revocation too: cached CRL and OCSP
  // responses only, no pcvstOnlineRetrieval checkpoint is raised
  Report := Pdf.ValidatePadesTrust(Options);
end;

Two chain builds, two error fields

The Windows backend builds the chain twice, and each build now owns its own error slot. The first CertGetCertificateChain call runs without revocation flags and feeds CertVerifyCertificateChainPolicy with the base policy, which produces TrustStatus and TrustError. The second call adds the revocation flags. Before v3.119.1 a failure of that second call wrote GetLastError into TrustError, so a chain that had just been verified as trusted could come back looking untrusted because a revocation provider hiccupped. The fix reads GetLastError immediately and stores it in TPdfCmsVerifyResult.RevocationError, leaving the first-pass verdict alone. And a True return from the second call is not treated as success either; it only means Windows handed back a chain context worth inspecting

What does a zero trust error mask actually prove?

On its own, nothing. An aggregate TrustStatus.dwErrorStatus of zero after the revocation pass says no error bit was raised, and a chain where no element carried revocation information at all can produce exactly that. The earlier code mapped "no revoked bit, no unknown bit, no offline bit" straight to valid, which is the classic way a validator reports an unchecked certificate as a clean one. The new ReadWinRevocationEvidence routine walks every simple chain and every element, rejects structures whose cbSize is too small to read safely, and reports success only when at least one non-root element exists and every such element carries a CERT_REVOCATION_INFO whose dwRevocationResult is zero

The evidence walk ReadWinRevocationEvidence performs on each Windows chain element in Delphi: pRevocationInfo must be present, cbSize must be large enough to read, dwRevocationResult must be zero, and the end element is excluded only when marked self-signed or CA-trusted, so a zero trust error mask can no longer hide an unchecked certificate
A clean verdict requires at least one non-root element and an answer from every required element, with RevocationError keeping the raw provider DWORD such as CRYPT_E_REVOKED
// Condensed from the evidence walk: an element counts only when a
// revocation provider actually answered for it
for J := 0 to ElementCount - 1 do
begin
  Element := Elements[J];
  ExcludedRoot := (J = ElementCount - 1) and
    ((Element^.TrustStatus.dwInfoStatus and
      (CERT_TRUST_IS_SELF_SIGNED or CERT_TRUST_IS_CA_TRUSTED)) <> 0);
  InfoPresent := (Element^.pRevocationInfo <> nil) and
    (Element^.pRevocationInfo^.cbSize >= SizeOf(TCERT_REVOCATION_INFO));
  if not ExcludedRoot then
  begin
    Inc(RequiredCount);
    if not InfoPresent or
      (Element^.pRevocationInfo^.dwRevocationResult <> 0) then
      Complete := False;
  end;
end;
Complete := Complete and (RequiredCount > 0); // a root-only chain proves nothing

The provider result is kept raw. RevocationError holds the dwRevocationResult DWORD exactly as the provider returned it, preferring the error from the revoked element when there is one (CRYPT_E_REVOKED is $80092010), and the trust bitmask is never dressed up as a native error code. The mapping to TPdfCmsRevocationReason is deliberately coarse: pcrrCertificateRevoked with pcvsInvalid for explicit revocation, pcrrChainUntrusted when the chain failed for reasons unrelated to revocation, and pcrrUnknown for everything else. Windows may have tried OCSP rather than a CRL, so an offline or unknown result is not translated into pcrrCrlExpired. The OpenSSL CMS verification backend can make those CRL-specific distinctions because it only ever evaluates CRLs you hand it, while the macOS SecTrust backend leaves the fields at pcrrNone and zero, which means "no detailed diagnostic", not "passed"

Where the root exclusion stops

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT legitimately skips the anchor, since nobody publishes a CRL that revokes a root against itself. The trap is deciding which element is the root. PDFium VCL excludes the last element of a simple chain only when its dwInfoStatus marks it as self-signed ($00000008) or explicitly CA-trusted ($00004000). An offline host often cannot fetch a missing issuer, so the chain ends at an intermediate; treating that partial chain's final element as a root would silently drop the one certificate whose revocation status is most likely to be absent from the cache. That element stays in the required set, has no provider answer, and the result stays pcvsIndeterminate

How are signature and timestamp revocation results kept apart?

As separate fields that never overwrite each other. The PAdES validator verifies the detached CMS of the document signature and the attached CMS of the RFC 3161 timestamp token in two independent calls, and v3.119.0 gave each its own diagnostics on TPadesSignatureValidation: RevocationReason and NativeRevocationError for the signer, TimeStampRevocationReason and NativeTimeStampRevocationError for the TSA. A revoked TSA certificate therefore cannot masquerade as a revoked signer, and a timestamp failure does not erase an integrity result that has already been established. When CheckRevocation is False, or validation never reached that stage, the fields stay pcrrNone and 0, so always read them next to RevocationStatus and TimeStampRevocationStatus

Signature and timestamp revocation stay apart in PDFium VCL: the detached CMS of the document signature fills RevocationReason and NativeRevocationError, the attached CMS of the RFC 3161 token fills TimeStampRevocationReason and NativeTimeStampRevocationError, and unchecked stages leave pcrrNone and zero next to their status fields
A revoked TSA certificate therefore cannot masquerade as a revoked signer, and a timestamp failure never erases an integrity result that the signature verification has already established
for I := 0 to High(Report.Signatures) do
begin
  S := Report.Signatures[I];
  case S.RevocationStatus of
    pcsInvalid:
      Log(Format('sig %d: signer revoked, provider 0x%.8x',
        [I, S.NativeRevocationError]));
    pcsIndeterminate:
      Log(Format('sig %d: revocation unknown, reason %d, provider 0x%.8x',
        [I, Ord(S.RevocationReason), S.NativeRevocationError]));
    pcsNotChecked:
      Log(Format('sig %d: revocation not checked', [I]));
  end;
  if S.TimeStampRevocationStatus = pcsIndeterminate then
    Log(Format('sig %d: TSA revocation unknown, provider 0x%.8x',
      [I, S.NativeTimeStampRevocationError]));
end;

The evidence report follows the same rule. The CSV export appends revocationReason, nativeRevocationError and the timestamp columns through nativeTimeStampRevocationError at the end of the existing column order, so older parsers keep working, and the JSON export adds matching fields without changing what the old ones mean. If offline validation keeps coming back indeterminate, the durable fix is upstream: gather the validation material at signing time, as described in long-term PDF signatures with RFC 3161 timestamps and DSS, rather than hoping the verifying machine has a warm cache

What the test matrix does and does not prove

The Windows verification matrix passed 30 controlled chain-API scenarios and one real offline CMS smoke on each Delphi and FPC Win32 and Win64 target. The real smoke verifies a valid signature under an untrusted private CA, while clean and explicitly revoked outcomes come from stubbed CertGetCertificateChain responses rather than installed trust anchors or live retrieval. That is an honest boundary worth stating: the flag handling, the error isolation and the evidence walk are pinned down, but what a particular machine's revocation cache contains on a given day is still Windows' business, and an empty cache now correctly produces "unknown" instead of a network request or a false "valid"

The offline revocation handling, the per-field diagnostics and the evidence exports are part of the PDF signature validation API in PDFium VCL for Delphi and C++Builder, alongside the OpenSSL and macOS backends for cross-platform deployments