Technisch artikel

Offline revocation checks voor PDF-handtekeningen in Delphi

PDFium VCL controleert intrekking van PDF-handtekeningen offline op Windows door CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY toe te voegen aan de intrekkingspas van CertGetCertificateChain, want de cache-only-vlag die voor het bouwen van de keten wordt gebruikt, dekt het ophalen van CRL of OCSP helemaal niet. Sinds v3.119.1 blijft een offline aanroep van ValidatePadesTrust van het netwerk af, en een schone uitkomst vraagt om echte intrekkingsbewijzen per certificaat. De rest van dit bericht gaat over waarom beide helften van die zin gerepareerd moesten worden

De opzet die het probleem blootlegt is alledaags. Een validatieservice draait op een hermetisch afgesloten Windows-host, TPadesTrustValidationOptions.NetworkPolicy staat op ptnpOffline (wat ook de standaard is), en de operator verwacht dat elk antwoord uit de lokale certificaatcache komt. Dan merkt iemand uitgaande verzoeken naar een distributiepunt van een CA in het firewalllogboek, of een batchtaak die bij elke handtekening de volledige UrlRetrievalTimeoutMs van 15000 ms blijft hangen. Niets in de code vroeg om het netwerk. Windows ging er zelf heen

Waarom haalt een offline ketenbouw op Windows toch CRL's op?

Omdat CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL alleen het URL-ophalen beperkt dat ketenbouw doet: AIA-issuer-fetches, root- en CTL-updates. De Microsoft-documentatie bij CertGetCertificateChain zegt expliciet dat de vlag niet geldt voor intrekkingscontrole. Intrekking heeft zijn eigen schakelaar, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), en zonder die mag de intrekkingprovider naar hartelust een CRL downloaden of een OCSP-verzoek sturen, ook al oogt de omringende aanroep offline. PDFium VCL voegt die vlag nu met OR samen in de intrekkingspas telkens als OnlineRetrieval op False staat, bovenop de ketenvlaggen, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT en CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Dat telt voor meer dan latentie: een OCSP-verzoek vertelt de responder welk certificaat u bekijkt, en precies dat moet een air-gapped validator vermijden

Twee onafhankelijke schakelaars bewaken offline validatie van PDF-handtekeningen op Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL beperkt alleen ketenbouw zoals AIA-issuer- en root-fetches, terwijl intrekking CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY nodig heeft, want zonder die downloaden providers nog steeds CRL's en sturen ze OCSP-verzoeken die verraden welk certificaat wordt gevalideerd
PDFium VCL voegt de cache-only-vlag voor intrekking met OR samen in de intrekkingspas telkens als OnlineRetrieval op False staat, dus een trustvalidatie ptnpOffline blijft voor beide passen van het netwerk af
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // standaard False
  Options.CheckTimeStamps := True;
  // Offline betekent nu ook offline voor intrekking: alleen gecachte CRL- en OCSP-
  // respons, er wordt geen checkpoint pcvstOnlineRetrieval afgegaan
  Report := Pdf.ValidatePadesTrust(Options);
end;

Twee ketenbouwsels, twee foutvelden

De Windows-backend bouwt de keten twee keer, en elke bouw heeft nu zijn eigen foutslot. De eerste aanroep van CertGetCertificateChain draait zonder intrekkingsvlaggen en voert CertVerifyCertificateChainPolicy uit met het basisbeleid, wat TrustStatus en TrustError oplevert. De tweede aanroep voegt de intrekkingsvlaggen toe. Vóór v3.119.1 schreef een faling van die tweede aanroep GetLastError in TrustError, zodat een keten die net als vertrouwd was geverifieerd, er onvertrouwd uit kon komen zien omdat een intrekkingprovider verslikte. De fix leest GetLastError onmiddellijk en bewaart hem in TPdfCmsVerifyResult.RevocationError, zodat het oordeel van de eerste pas onaangeroerd blijft. En een True-terugkeer van de tweede aanroep wordt ook niet als succes behandeld; die betekent slechts dat Windows een ketencontext teruggaf die het inspecteren waard is

Wat bewijst een foutmasker van nul voor trust eigenlijk?

Op zichzelf: niets. Een geaggregeerde TrustStatus.dwErrorStatus van nul na de intrekkingspas zegt dat er geen foutbit is afgegaan, en een keten waarin helemaal geen element intrekkingsinformatie droeg kan precies dat opleveren. De oudere code mapte "geen revoked-bit, geen unknown-bit, geen offline-bit" rechtstreeks op geldig, de klassieke manier waarop een validator een ongecontroleerd certificaat als schoon meldt. De nieuwe routine ReadWinRevocationEvidence loopt elke simple chain en elk element af, verwerpt structuren waarvan cbSize te klein is om veilig te lezen, en meldt alleen succes als er minstens één niet-wortelelement bestaat en elk zo'n element een CERT_REVOCATION_INFO draagt waarvan dwRevocationResult nul is

De bewijsronde die ReadWinRevocationEvidence in Delphi over elk Windows-ketenelement maakt: pRevocationInfo moet aanwezig zijn, cbSize groot genoeg om te lezen, dwRevocationResult moet nul zijn, en het laatste element wordt alleen uitgesloten als het als zelfondertekend of CA-vertrouwd is gemarkeerd, dus een foutmasker van nul voor trust kan een ongecontroleerd certificaat niet langer verbergen
Een schoon oordeel vereist minstens één niet-wortelelement en een antwoord van elk vereist element, met RevocationError dat de rauwe provider-DWORD vasthoudt, zoals CRYPT_E_REVOKED
// Ingekort uit de bewijsronde: een element telt alleen als een
// intrekkingprovider er werkelijk voor heeft geantwoord
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); // een keten met alleen een wortel bewijst niets

Het providerresultaat wordt rauw bewaard. RevocationError bevat de DWORD dwRevocationResult precies zoals de provider hem teruggaf, bij voorkeur de fout van het ingetrokken element als die er is (CRYPT_E_REVOKED is $80092010), en de trust-bitmasker wordt nooit verkleed als native foutcode. De mapping naar TPdfCmsRevocationReason is bewust grof: pcrrCertificateRevoked met pcvsInvalid voor expliciete intrekking, pcrrChainUntrusted wanneer de keten faalde om redenen buiten intrekking, en pcrrUnknown voor al het andere. Windows heeft het misschien op OCSP geprobeerd in plaats van op een CRL, dus een offline of onbekende uitkomst wordt niet vertaald naar pcrrCrlExpired. De OpenSSL CMS-verificatiebackend kan die CRL-specifieke onderscheidingen maken omdat hij alleen CRL's evalueert die u hem geeft, terwijl de macOS SecTrust-backend de velden op pcrrNone en nul laat, wat "geen gedetailleerde diagnose" betekent, niet "geslaagd"

Waar de worteluitsluiting ophoudt

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT slaat het anker terecht over, want niemand publiceert een CRL die een wortel tegen zichzelf intrekt. De valkuil is bepalen welk element de wortel is. PDFium VCL sluit het laatste element van een simple chain alleen uit wanneer zijn dwInfoStatus hem als zelfondertekend ($00000008) of expliciet CA-vertrouwd ($00004000) markeert. Een offline host kan een ontbrekende issuer vaak niet ophalen, dus eindigt de keten op een intermediate; dat laatste element van de partiële keten als wortel behandelen zou geruisloos het ene certificaat laten vallen waarvan de intrekkingsstatus het meest waarschijnlijk in de cache ontbreekt. Dat element blijft in de vereiste set, heeft geen antwoord van een provider, en de uitkomst blijft pcvsIndeterminate

Hoe worden de intrekkingsresultaten van handtekening en timestamp uit elkaar gehouden?

Als aparte velden die elkaar nooit overschrijven. De PAdES-validator verifieert de detached CMS van de documentsignature en de attached CMS van het RFC 3161-timestamptoken in twee onafhankelijke aanroepen, en v3.119.0 gaf elk zijn eigen diagnostiek op TPadesSignatureValidation: RevocationReason en NativeRevocationError voor de ondertekenaar, TimeStampRevocationReason en NativeTimeStampRevocationError voor de TSA. Een ingetrokken TSA-certificaat kan zich dus niet voordoen als ingetrokken ondertekenaar, en een timestampfaling wist geen integriteitsresultaat dat al vaststaat. Is CheckRevocation op False, of kwam de validatie dat stadium niet bereiken, dan blijven de velden op pcrrNone en 0, dus lees ze altijd naast RevocationStatus en TimeStampRevocationStatus

Intrekking van handtekening en timestamp blijven uit elkaar in PDFium VCL: de detached CMS van de documentsignature vult RevocationReason en NativeRevocationError, de attached CMS van het RFC 3161-token vult TimeStampRevocationReason en NativeTimeStampRevocationError, en ongecontroleerde stadia laten pcrrNone en nul naast hun statusvelden achter
Een ingetrokken TSA-certificaat kan zich dus niet voordoen als ingetrokken ondertekenaar, en een timestampfaling wist nooit een integriteitsresultaat dat de handtekeningverificatie al heeft vastgesteld
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;

Het bewijsrapport volgt dezelfde regel. De CSV-export voegt revocationReason, nativeRevocationError en de timestamp-kolommen tot en met nativeTimeStampRevocationError toe achteraan in de bestaande kolomvolgorde, dus oudere parsers blijven werken, en de JSON-export voegt matchende velden toe zonder de betekenis van de oude te veranderen. Blijft offline validatie maar als indeterminate terugkomen, dan is de duurzame fix stroomopwaarts: verzamel het validatiemateriaal op het moment van ondertekenen, zoals beschreven in langetermijn-PDF-handtekeningen met RFC 3161-timestamps en DSS, in plaats van te hopen dat de verifiërende machine een warme cache heeft

Wat de testmatrix bewijst en wat niet

De Windows-verificatiematrix doorstond 30 gecontroleerde keten-API-scenario's en één echte offline CMS-rooktest op elke Delphi- en FPC-doel op Win32 en Win64. De echte rooktest verifieert een geldige handtekening onder een niet-vertrouwde private CA, terwijl de schone en expliciet ingetrokken uitkomsten uit gestubde CertGetCertificateChain-responsen komen in plaats van uit geïnstalleerde trust anchors of live ophalen. Dat is een eerlijke grens die vermelding verdient: de vlagafhandeling, de foutisolatie en de bewijsronde liggen vast, maar wat de intrekkingscache van een bepaalde machine op een bepaalde dag bevat, blijft de zaak van Windows, en een lege cache levert nu terecht "onbekend" op in plaats van een netwerkverzoek of een vals "geldig"

De offline intrekkingsafhandeling, de diagnostiek per veld en de bewijsexports horen bij de PDF-handtekeningvalidatie-API in PDFium VCL voor Delphi en C++Builder, naast de OpenSSL- en macOS-backends voor platformoverstijgende uitrol