Technical Article

Revoked Signers That Look Valid: RFC 5280 Path Checks for PDF Signatures

A signed invoice verifies byte for byte, its signer was revoked last month, and the CRL you fetched never lists the serial: it speaks for another partition. Path validation in HotPDF is designed around such cases: RFC 5280 scope and anchor rules, a UTC evaluation clock, and RFC 3161 timestamps whose time counts only once the TSA chain is trusted

The evaluation clock runs on UTC

Every time a path validator compares is stored in UTC. RFC 5280 §4.1.2.5 requires certificate validity dates to be encoded as UTCTime or GeneralizedTime in Greenwich Mean Time, CRL thisUpdate and nextUpdate use the same encodings, and RFC 6960 OCSP responses carry producedAt, thisUpdate and nextUpdate as GeneralizedTime in UTC. Delphi's TDateTime has no time zone and Now returns local wall time, so a check written as if Now > NotAfter mixes two clocks without a compiler warning. The error splits by geography. West of Greenwich it runs early. On a New York server at 09:00 local, when the world clock reads 14:00Z, an OCSP response produced at 13:59Z sits five hours in the future of the naive clock, far outside a five-minute skew allowance such as HotPDF's default OCSPAllowedClockSkewMinutes, and gets refused as not yet valid. In Shanghai the sign flips: at 22:00 local, which is 14:00Z, a signing certificate whose notAfter is 20:00Z already looks expired, eight hours early. Neither failure appears on a build agent set to UTC — the default on most hosted CI runners

HotPDF VerifyLoadedSignatureWithTrust in Delphi evaluates THPDFCertificateValidationOptions.ValidationTime in UTC, avoiding the New York and Shanghai clock errors a local Now causes against OCSP producedAt and certificate notAfter times
Comparing a local Delphi Now with UTC validity times refuses fresh OCSP responses west of Greenwich and expires certificates early to the east, two failures a UTC build agent never shows

HotPDF treats THPDFCertificateValidationOptions.ValidationTime as UTC. Left at its default of zero, the evaluation time becomes the current UTC time, unless a trusted timestamp supplies the signing time (more on that below), and the instant actually used comes back in Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust runs the byte-range verification behind svValid before any of this, then builds the path. When you pin a historical moment yourself, say for an auditor asking whether an invoice signature was good at quarter close, the conversion is your job. TTimeZone.Local.ToUniversalTime from System.DateUtils does it. Apply it where the timestamp enters your system rather than at the validation call: in zones with daylight saving one local hour repeats every autumn and another never exists in spring, so a database column of local timestamps cannot round-trip every instant

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // Quarter close as the ERP recorded it, in the server's local zone
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Certificates, CRLs and OCSP responses all carry UTC times
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

Which certificates does a CRL actually cover?

A CRL speaks only for the certificates inside its scope, and the scope is declared in the Issuing Distribution Point extension of RFC 5280 §5.2.5. A CA that partitions its revocation data publishes several CRLs, all signed with the same key and all fresh, and a certificate's absence from one of them means nothing unless that CRL claims to cover it. The fields that narrow the scope are these:

  • distributionPoint: the CRL covers only certificates whose CRL Distribution Points extension names this point
  • onlyContainsUserCerts and onlyContainsCACerts: end-entity certificates only, or CA certificates only
  • onlySomeReasons: revocations for a subset of reason codes, such as keyCompromise
  • onlyContainsAttributeCerts: the CRL is about attribute certificates and says nothing about public-key certificates
  • indirectCRL: entries may belong to issuers other than the CRL signer

The naive lookup verifies the CRL signature, checks thisUpdate and nextUpdate, searches for the serial number and calls the certificate good when the serial is missing. That last step holds only after the scope test of RFC 5280 §6.3.3 (b)(2). The CRL for partition 7 never lists a certificate from partition 3. A CRL restricted to CA certificates never lists an end-entity signer. Both answer "not found" for a revoked certificate. HotPDF consults a base CRL for a certificate only when the CRL is limited neither to attribute certificates nor to some reasons, its user or CA restriction matches the certificate, and any distribution point it names matches one of the URIs in the certificate's own CRL Distribution Points extension, compared case-insensitively — a point given as a name relative to the CRL issuer matches nothing. A reasons-limited CRL is not consulted at all, so the revocations it lists do not count either. §6.3.3 does allow assembling full coverage from reason partitions, tracking a reasons mask until every reason is accounted for, but for document signatures an OCSP response or a full-scope CRL is simpler evidence: collect one of those. With no in-scope evidence left, the revocation status is unknown, and under rvmRequire the call returns cvsRevocationUnknown

HotPDF CRL scope test for PDF signature revocation checking in Delphi: Issuing Distribution Point fields decide whether a base CRL is consulted, and with no in-scope evidence rvmRequire returns cvsRevocationUnknown under RFC 5280
Before a CRL is searched for the serial number, its Issuing Distribution Point has to cover the certificate; otherwise a missing entry proves nothing about revocation

Trust anchors that close a path must be CAs

RFC 5280 §6.1.1 hands the path validation algorithm its trust anchor as plain information, an issuer name and a public key, and the algorithm never asks whether the anchor's own certificate may issue anything; the basicConstraints test in §6.1.4 runs on the intermediate certificates of the path. That is fine when the anchors are root CAs from a curated store. Document workflows break the assumption, because people pin end-entity certificates: a procurement system receiving contracts from three known suppliers often trusts each supplier's signing certificate directly. Pinning works for a one-certificate path, where signer and anchor are the same certificate. The danger is a two-certificate path. A supplier's private key can sign a fresh certificate carrying any subject at all, your CFO's name for instance, and a validator that treats every anchor key as an issuing key builds a clean path from that forgery to the pinned leaf

HotPDF applies the RFC 5280 issuer requirements to the anchor whenever the path is longer than one certificate. The anchor then needs basicConstraints with cA=TRUE (§4.2.1.9), and if it carries a keyUsage extension, the keyCertSign bit has to be set (§4.2.1.3). A root with no basicConstraints extension counts as an issuer only when it is self-issued, with equal subject and issuer names, which keeps the old version 1 roots that predate extensions working. Pinning keeps working. A pinned end-entity certificate still validates its own signatures, while a certificate it signed comes back cvsPathValidationFailed, with an Issue string stating that the anchor that issued the path is not a certificate authority

HotPDF RFC 5280 certificate path validation in Delphi: a pinned end-entity certificate in TrustAnchors validates its own signatures, but a path it issued fails with cvsPathValidationFailed without basicConstraints cA=TRUE and keyCertSign
Pinning a supplier's signing certificate still works for that supplier's signatures, while any certificate its key signs is rejected because the anchor is not a certificate authority

Which timestamps may set the evaluation time?

Only trusted ones. An RFC 3161 token embedded in the CMS claims that the signature value existed at genTime, and validators judge the signer certificate at that moment — that is how a signature outlives its certificate. The token is also just another CMS signature. Verifying it against the certificate it carries proves internal consistency and nothing about who produced it: anyone holding an expired or revoked signing key can run their own time-stamping service, stamp a new signature with last year's genTime and walk it back into the certificate's validity period. RFC 3161 §2.3 requires the TSA certificate to carry the id-kp-timeStamping extended key usage as its only purpose, marked critical. HotPDF adopts Info.Timestamp.GenerationTime as the evaluation time only after the token certificate validates against your anchors, at the current UTC time, for the time-stamping purpose. A document timestamp, /SubFilter ETSI.RFC3161, is a different case. There the token is the signature under validation, and its genTime applies to its own path

Because the token is checked with your own options, the TSA root has to sit in the same TrustAnchors list as the signer root, and under rvmRequire the TSA chain needs revocation evidence of its own. Miss either and evaluation falls back to the current time. The TSA certificate's lifetime is the other limit: the token is judged at the current time, so once that certificate expires the signing-time evaluation goes with it, and archives add a document timestamp before that point, the step covered under PAdES LTV evidence and B-LTA timestamp renewal. Code that holds raw CMS bytes can ask the same question through HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), and ValidateLoadedSignatureBatch applies it to every signature in a batch. The function below reports which clock a verdict was taken on

// Expected: with SignerRoot alone, a signature whose certificate expired
// in 2025 is evaluated at the current UTC time and does not come back
// cvsTrusted, although its timestamp token verifies; with TsaRoot added,
// the same call is evaluated at genTime
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // The TSA chain is judged against the same anchor list as the signer
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // a trusted timestamp may supply the time
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

Hostile input before the first trust decision

Some validation work runs on data an attacker fully controls, before anything has been trusted. Missing intermediates are fetched through the Authority Information Access caIssuers URLs of RFC 5280 §4.2.2.1, and each fetched intermediate can advertise its own issuer's URL in turn, so the worklist grows while you walk it. In Delphi that rules out a for loop. The final value of a for statement is evaluated once, before the loop starts — URLs appended during the walk are never visited, and a chain missing two levels stays incomplete. Reread Count on every pass with a while loop instead. In HotPDF, MaxFetch on HPDFFetchAIAIntermediates caps how many certificates one walk downloads, and MaxFetchedIntermediates sets the same cap when FetchMissingIntermediates lets path validation fetch on its own

The Retry-After hint is another value a stranger picks. Its server was named by an unverified certificate, and it can ask for an hour's pause on every request. Clamp it. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS sets that ceiling for HotPDF's evidence and AIA fetches, 30,000 ms by default. The routine below applies both rules for teams that crawl AIA themselves, say to prewarm the intermediate cache of an offline signing tier

uses
  System.SysUtils, System.Generics.Collections;

type
  // Your transport: downloads one caIssuers target and returns the
  // caIssuers URLs advertised by the certificate it received
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // A for loop would fix Queue.Count on entry and never reach the
    // URLs that fetched certificates append below
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // The delay is the server's wish, and an unverified certificate
        // chose the server
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

The CMS signature check itself runs before path validation, because building a path for a signature that does not verify is wasted work, and it uses the public key of the certificate embedded in the signature. That ordering puts an attacker-chosen key size in charge at the moment the most expensive arithmetic runs. Nothing is trusted yet. RSA verification cost grows with both modulus length and exponent length, so a certificate with an enormous modulus and an exponent just as long can keep a thread busy in modular exponentiation on a server that verifies uploaded files. HotPDF refuses an RSA modulus above 16,384 bits, refuses an exponent longer than the modulus, and above 3,072 bits accepts only exponents of at most 64 bits. Real signing keys sit between 2,048 and 4,096 bits with e = 65537, a 17-bit exponent, so the ceiling never touches them; the floor is a separate, configurable policy, AlgorithmPolicy.MinimumRSAKeyBits, which defaults to 2,048

The same signature can be cvsTrusted at its timestamp's genTime and fail at today's date. Log Info.CertificateValidation.EvaluationTime and TrustAnchorSubjectDN next to every verdict the HotPDF Delphi PDF component returns, and an auditor reading the record later can see which clock and which anchor the answer rests on