Technisch artikel

Wat RFC 5280 vraagt van een PDF-signatuur-padvalidator

Een getekende factuur verifieert byte voor byte, zijn ondertekenaar is vorige maand ingetrokken, en de CRL die u ophaalde noemt het serienummer nooit: hij spreekt voor een andere partitie. Path validation in HotPDF is rond zulke gevallen ontworpen: RFC 5280-scope- en anchor-regels, een UTC-evaluatieklok en RFC 3161-timestamps waarvan de tijd pas telt zodra de TSA-keten vertrouwd is

De evaluatieklok loopt op UTC

Elke tijd die een path validator vergelijkt staat in UTC opgeslagen. RFC 5280 §4.1.2.5 eist dat certificaat-geldigheidsdatums als UTCTime of GeneralizedTime in Greenwich Mean Time worden gecodeerd, CRL-thisUpdate en nextUpdate gebruiken dezelfde coderingen, en RFC 6960 OCSP-antwoorden dragen producedAt, thisUpdate en nextUpdate als GeneralizedTime in UTC. De TDateTime van Delphi heeft geen tijdzone en Now geeft lokale kloktijd terug, dus een check geschreven als if Now > NotAfter mengt twee klokken zonder compilerwaarschuwing. De fout splitst per geografie. Ten westen van Greenwich loopt hij vroeg. Op een server in New York om 09:00 lokale tijd, wanneer de wereldklok 14:00Z aangeeft, zit een OCSP-antwoord geproduceerd om 13:59Z vijf uur in de toekomst van de naïeve klok, ruim buiten een skew-marge van vijf minuten zoals de default OCSPAllowedClockSkewMinutes van HotPDF, en wordt geweigerd als nog niet geldig. In Shanghai draait het teken om: om 22:00 lokale tijd, dus 14:00Z, lijkt een tekeningcertificaat met notAfter op 20:00Z al verlopen, acht uur te vroeg. Geen van beide fouten verschijnt op een build agent die op UTC staat — de default op de meeste gehoste CI-runners

HotPDF VerifyLoadedSignatureWithTrust in Delphi evalueert THPDFCertificateValidationOptions.ValidationTime in UTC, en vermijdt zo de klokfouten van New York en Shanghai die een lokale Now veroorzaakt tegen OCSP-producedAt en certificaat-notAfter-tijden
Een lokale Delphi-Now vergelijken met UTC-geldigheidstijden weigert verse OCSP-antwoorden ten westen van Greenwich en laat certificaten in het oosten vroeg verlopen, twee fouten die een UTC-build agent nooit toont

HotPDF behandelt THPDFCertificateValidationOptions.ValidationTime als UTC. Standaard op nul gelaten wordt de evaluatietijd de huidige UTC-tijd, tenzij een vertrouwde timestamp de tekentijd levert (daarover hieronder meer), en het werkelijk gebruikte moment komt terug in Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust draait de byte-range-verificatie achter svValid vóór dit alles, en bouwt dan het pad. Pint u zelf een historisch moment vast, bijvoorbeeld voor een auditor die wil weten of een factuurondertekening op kwartaalsluiting in orde was, dan is de conversie uw werk. TTimeZone.Local.ToUniversalTime uit System.DateUtils doet dat. Pas het toe waar de timestamp uw systeem binnenkomt in plaats van bij de validatie-aanroep: in zones met zomertijd herhaalt één lokaal uur zich elke herfst en bestaat een ander in het voorjaar nooit, dus een databasekolom met lokale timestamps kan niet elk moment round-trippen

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;
    // Kwartaalsluiting zoals de ERP hem registreerde, in de lokale zone van de server
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Certificaten, CRL's en OCSP-antwoorden dragen allemaal UTC-tijden
    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;

Welke certificaten dekt een CRL eigenlijk?

Een CRL spreekt alleen voor de certificaten binnen zijn scope, en de scope staat gedeclareerd in de Issuing Distribution Point-uitbreiding van RFC 5280 §5.2.5. Een CA die zijn intrekkingsdata partitioneert publiceert meerdere CRL's, allemaal getekend met dezelfde key en allemaal vers, en de afwezigheid van een certificaat in een van hen betekent niets tenzij die CRL claimt hem te dekken. De velden die de scope vernauwen zijn deze:

  • distributionPoint: de CRL dekt alleen certificaten waarvan de CRL Distribution Points-uitbreiding dit punt benoemt
  • onlyContainsUserCerts en onlyContainsCACerts: uitsluitend end-entity-certificaten, of uitsluitend CA-certificaten
  • onlySomeReasons: intrekkingen voor een subset van reason codes, zoals keyCompromise
  • onlyContainsAttributeCerts: de CRL gaat over attribute certificates en zegt niets over public-key-certificaten
  • indirectCRL: entries kunnen van issuers komen die anders zijn dan de CRL-ondertekenaar

De naïeve opzoeking verifieert de CRL-handtekening, controleert thisUpdate en nextUpdate, zoekt het serienummer en roept het certificaat goed uit wanneer het serienummer ontbreekt. Die laatste stap geldt pas na de scope-test van RFC 5280 §6.3.3 (b)(2). De CRL voor partitie 7 noemt nooit een certificaat uit partitie 3. Een CRL beperkt tot CA-certificaten noemt nooit een end-entity-ondertekenaar. Beiden antwoorden "niet gevonden" voor een ingetrokken certificaat. HotPDF raadpleegt een base CRL voor een certificaat alleen wanneer de CRL niet beperkt is tot attribute certificates of tot sommige redenen, zijn gebruiker- of CA-beperking bij het certificaat past, en elk distribution point dat hij benoemt matcht met een van de URI's in de eigen CRL Distribution Points-uitbreiding van het certificaat, case-insensitief vergeleken — een punt opgegeven als naam relatief aan de CRL-issuer matcht niets. Een op redenen beperkte CRL wordt helemaal niet geraadpleegd, dus de intrekkingen die hij noemt tellen evenmin. §6.3.3 staat wel toe volledige dekking uit redenpartities samen te stellen, met een redenmasker bijgehouden tot elke reden verklaard is, maar voor documentsignaturen is een OCSP-antwoord of een full-scope CRL eenvoudiger bewijs: verzamel er een van. Blijft er geen in-scope bewijs over, dan is de intrekkingsstatus onbekend, en onder rvmRequire geeft de aanroep cvsRevocationUnknown terug

HotPDF CRL-scope-test voor het controleren van PDF-signatuurintrekkingen in Delphi: Issuing Distribution Point-velden beslissen of een base CRL wordt geraadpleegd, en zonder in-scope bewijs geeft rvmRequire cvsRevocationUnknown terug onder RFC 5280
Voordat een CRL op het serienummer wordt doorzocht moet zijn Issuing Distribution Point het certificaat dekken; anders bewijst een ontbrekende entry niets over de intrekking

Trust anchors die een pad sluiten moeten CA's zijn

RFC 5280 §6.1.1 overhandigt het path validation-algoritme zijn trust anchor als blote informatie, een issuer-naam en een public key, en het algoritme vraagt nooit of het eigen certificaat van de anchor iets mag uitgeven; de basicConstraints-test in §6.1.4 draait op de tussenliggende certificaten van het pad. Dat is prima zolang de anchors root-CA's uit een gecureerde store zijn. Documentworkflows breken die aanname, want mensen pinnen end-entity-certificaten: een inkoopsysteem dat contracten van drie bekende leveranciers ontvangt vertrouwt vaak het tekeningcertificaat van elke leverancier rechtstreeks. Pinnen werkt voor een pad van één certificaat, waar signer en anchor hetzelfde certificaat zijn. Het gevaar zit in een pad van twee certificaten. De private key van een leverancier kan een vers certificaat tekenen met willekeurig welke subject, bijvoorbeeld de naam van uw CFO, en een validator die elke anchor-key als issuer-key behandelt bouwt van die vervalsing een schoon pad naar de gepinde leaf

HotPDF past de RFC 5280-issuereisen op de anchor toe zodra het pad langer is dan één certificaat. De anchor heeft dan basicConstraints met cA=TRUE nodig (§4.2.1.9), en draagt hij een keyUsage-uitbreiding, dan moet de bit keyCertSign gezet zijn (§4.2.1.3). Een root zonder basicConstraints-uitbreiding telt alleen als issuer wanneer hij self-issued is, met gelijke subject- en issuer-namen, wat de oude version 1-roots die vóór de uitbreidingen dateren werkend houdt. Pinnen blijft werken. Een gepind end-entity-certificaat valideert nog steeds zijn eigen signaturen, terwijl een certificaat dat hij tekende terugkomt als cvsPathValidationFailed, met een Issue-string die zegt dat de anchor die het pad uitgaf geen certificate authority is

HotPDF RFC 5280 certificaat-path validation in Delphi: een gepind end-entity-certificaat in TrustAnchors valideert zijn eigen signaturen, maar een pad dat hij uitgaf faalt met cvsPathValidationFailed zonder basicConstraints cA=TRUE en keyCertSign
Het tekeningcertificaat van een leverancier pinnen blijft werken voor de signaturen van die leverancier, terwijl elk certificaat dat zijn key tekent wordt geweigerd omdat de anchor geen certificate authority is

Welke timestamps mogen de evaluatietijd bepalen?

Alleen vertrouwde. Een RFC 3161-token embedded in de CMS claimt dat de signatuurwaarde bestond op genTime, en validators beoordelen het signer-certificaat op dat moment — zo overleeft een signatuur zijn certificaat. Het token is tegelijk gewoon weer een CMS-handtekening. Verifiëren tegen het certificaat dat hij meedraagt bewijst interne consistentie en niets over wie hem produceerde: wie een verlopen of ingetrokken tekenkey heeft, kan zijn eigen time-stamping-service draaien, een nieuwe signatuur stempelen met de genTime van vorig jaar en hem teruglopen naar de geldigheidsperiode van het certificaat. RFC 3161 §2.3 eist dat het TSA-certificaat de extended key usage id-kp-timeStamping draagt als enige doel, gemarkeerd critical. HotPDF neemt Info.Timestamp.GenerationTime pas als evaluatietijd over nadat het tokencertificaat tegen uw anchors valideert, op de huidige UTC-tijd, voor het time-stamping-doeleinde. Een document timestamp, /SubFilter ETSI.RFC3161, is een ander geval. Daar is het token de signatuur die onder validatie staat, en zijn genTime geldt voor zijn eigen pad

Omdat het token met uw eigen opties wordt gecontroleerd, moet de TSA-root in dezelfde lijst TrustAnchors zitten als de signer-root, en onder rvmRequire heeft de TSA-keten eigen intrekkingsbewijs nodig. Mist u een van beide, dan valt de evaluatie terug op de huidige tijd. De levensduur van het TSA-certificaat is de andere grens: het token wordt op de huidige tijd beoordeeld, dus zodra dat certificaat verloopt gaat de evaluatie op tekentijd met hem mee, en archieven voegen daarvoor een document timestamp toe, de stap die bij PAdES LTV-bewijs en B-LTA-timestamp-vernieuwing staat beschreven. Code die rauwe CMS-bytes vasthoudt kan dezelfde vraag stellen via HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), en ValidateLoadedSignatureBatch past hem op elke signatuur in een batch toe. De functie hieronder meldt op welke klok een oordeel viel

// Verwacht: met alleen SignerRoot wordt een signatuur waarvan het certificaat
// in 2025 verliep op de huidige UTC-tijd geëvalueerd en komt niet terug
// als cvsTrusted, al verifieert zijn timestamp-token; met TsaRoot erbij
// wordt dezelfde aanroep op genTime geëvalueerd
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // De TSA-keten wordt tegen dezelfde anchorlijst beoordeeld als de signer
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // een vertrouwde timestamp mag de tijd leveren
  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;

Vijandige invoer vóór de eerste trust-beslissing

Soms draait validatiewerk op data die een aanvaller volledig beheerst, voordat er iets vertrouwd is. Ontbrekende intermediates worden opgehaald via de Authority Information Access caIssuers-URL's van RFC 5280 §4.2.2.1, en elke opgehaalde intermediate kan op zijn beurt de URL van zijn eigen issuer adverteren, dus de werklijst groeit terwijl u hem afloopt. In Delphi sluit dat een for-lus uit. De eindwaarde van een for-statement wordt één keer geëvalueerd, voordat de lus begint — tijdens de walk toegevoegde URL's worden nooit bezocht, en een keten die twee niveaus mist blijft incompleet. Lees Count in elke pass opnieuw met een while-lus. In HotPDF beperkt MaxFetch op HPDFFetchAIAIntermediates hoeveel certificaten één walk downloadt, en MaxFetchedIntermediates zet dezelfde limiet wanneer FetchMissingIntermediates path validation zelf laat ophalen

De Retry-After-hint is nog zo'n waarde die een vreemde kiest. Zijn server werd benoemd door een ongeverifieerd certificaat, en hij kan om een pauze van een uur bij elk verzoek vragen. Klem hem af. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS zet dat plafond voor de evidence- en AIA-fetches van HotPDF, default 30.000 ms. De routine hieronder past beide regels toe voor teams die AIA zelf crawlen, bijvoorbeeld om de intermediate-cache van een offline signing-tier voor te warmen

uses
  System.SysUtils, System.Generics.Collections;

type
  // Uw transport: downloadt één caIssuers-doel en geeft de caIssuers-URL's
  // terug die het ontvangen certificaat adverteert
  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;
    // Een for-lus zou Queue.Count bij binnenkomst vastzetten en de
    // URL's die opgehaalde certificaten hieronder toevoegen nooit bereiken
    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;
        // De vertraging is de wens van de server, en een ongeverifieerd
        // certificaat koos de 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;

De CMS-signaturecontrole zelf draait vóór path validation, want voor een signatuur die niet verifieert een pad bouwen is verspild werk, en hij gebruikt de public key van het certificaat embedded in de signatuur. Die volgorde geeft een door een aanvaller gekozen keygrootte het zeggen op het moment dat de duurste rekenkunde draait. Er is nog niets vertrouwd. De kost van RSA-verificatie groeit met zowel de moduluslengte als de exponentlengte, dus een certificaat met een enorme modulus en een net zo lange exponent kan een thread bezighouden met modulaire exponentiatie op een server die geüploade bestanden verifieert. HotPDF weigert een RSA-modulus boven 16.384 bits, weigert een exponent langer dan de modulus, en accepteert boven 3.072 bits alleen exponenten van hooguit 64 bits. Echte tekenkeys zitten tussen 2.048 en 4.096 bits met e = 65537, een 17-bits exponent, dus het plafond raakt ze nooit; de vloer is een apart, instelbaar beleid, AlgorithmPolicy.MinimumRSAKeyBits, dat op 2.048 staat

Dezelfde signatuur kan cvsTrusted zijn op de genTime van zijn timestamp en faalt op de datum van vandaag. Log Info.CertificateValidation.EvaluationTime en TrustAnchorSubjectDN naast elk oordeel dat de HotPDF Delphi PDF component teruggeeft, en een auditor die het log later leest ziet op welke klok en welke anchor het antwoord rust