Technisch artikel

Offline revocation checks voor PDF-handtekeningen in Delphi

PDFium VCL controleert de intrekking van PDF-signaturen offline op Windows door CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY toe te voegen aan de revocation-pass van CertGetCertificateChain, want de cache-only-vlag die voor ketenbouw wordt gebruikt dekt CRL- of OCSP-opvraag helemaal niet. Een offline ValidatePadesTrust-aanroep blijft daarom van het netwerk af, en een schone uitkomst vereist echte intrekkingsbewijzen per certificaat. De rest van deze post gaat over waarom elke helft van die zin bewust werk vergt

De valkuil verschijnt in een gewone opzet. Een validatieservice draait op een afgesloten Windows-host, TPadesTrustValidationOptions.NetworkPolicy staat op ptnpOffline (ook de standaard), en de beheerder verwacht dat elk antwoord uit de lokale certificaatcache komt. Bouw de revocation-pass met alleen de ketenbouw-vlag en binnenkort ziet iemand uitgaande requests naar een CA distribution point in de firewalllog, of een batchjob die bij elke handtekening de volle UrlRetrievalTimeoutMs van 15000 ms vastzit. 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 de URL-opvraag beperkt die ketenbouw doet: AIA-issuer-fetches, root- en CTL-updates. De Microsoft-documentatie bij CertGetCertificateChain zegt expliciet dat de vlag niet geldt voor revocation-checking. Intrekking heeft een eigen schakelaar, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), en zonder die mogen de revocation-providers naar hartenlust een CRL downloaden of een OCSP-request sturen, ook al ziet de omringende aanroep er offline uit. PDFium VCL voegt die vlag met een OR toe aan de revocation-pass wanneer OnlineRetrieval False is, bovenop de keten-vlaggen CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT en CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Dat telt zwaarder dan alleen latency: een OCSP-request vertelt de responder welk certificaat je bekijkt, en precies dat moet een air-gapped validator zien te 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 ook voor revocation offline: uitsluitend gecachte CRL- en OCSP-
  // responsen, er komt geen pcvstOnlineRetrieval-checkpoint voorbij
  Report := Pdf.ValidatePadesTrust(Options);
end;

Twee ketenbouwsels, twee foutvelden

De Windows-backend bouwt de keten twee keer, en elke bouw heeft een eigen foutslot. De eerste CertGetCertificateChain-aanroep draait zonder revocation-vlaggen en voert CertVerifyCertificateChainPolicy uit met het base policy, wat TrustStatus en TrustError oplevert. De tweede aanroep voegt de revocation-vlaggen toe. Als een mislukking van die tweede aanroep GetLastError in TrustError zou schrijven, kon een keten die net als vertrouwd was gekeurd, er als onvertrouwd uitzien omdat een revocation-provider een stuip kreeg. PDFium VCL leest GetLastError direct en bewaart hem in TPdfCmsVerifyResult.RevocationError, en laat het oordeel van de eerste pass met rust. En een True-terugkomst van de tweede aanroep wordt ook niet als succes behandeld; die betekent alleen dat Windows een ketencontext teruggeeft die inspectie waard is

Wat bewijst een foutmasker van nul voor trust eigenlijk?

Afzonderlijk genomen: niets. Een geaggregeerde TrustStatus.dwErrorStatus van nul na de revocation-pass zegt dat er geen foutbit is gezet, en een keten waarin helemaal geen element intrekkingsinformatie droeg kan precies dat opleveren. “geen revoked-bit, geen unknown-bit, geen offline-bit” rechtstreeks naar geldig vertalen is de klassieke manier waarop een validator een ongecontroleerd certificaat als schoon meldt. De routine ReadWinRevocationEvidence in PDFium VCL loopt elke simple chain en elk element langs, weigert structuren waarvan cbSize te klein is om veilig te lezen, en meldt pas succes als er minstens één niet-root-element bestaat en elk van die elementen 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 afzonderlijke velden die elkaar nooit overschrijven. De PAdES-validator verifieert de detached CMS van de documentsignatuur en de attached CMS van het RFC 3161-timestamp-token in twee onafhankelijke aanroepen, en elk heeft 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 een ingetrokken ondertekenaar, en een timestamp-mislukking wist geen integriteitsuitslag die al vaststaat. Als CheckRevocation False is, of validatie dat stadium nooit bereikte, blijven de velden pcrrNone en 0, dus lees ze altijd samen met 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;

De evidentierapportage volgt dezelfde regel. De CSV-export voegt revocationReason, nativeRevocationError en de timestamp-kolommen tot en met nativeTimeStampRevocationError achteraan aan de bestaande kolomvolgorde toe, zodat oudere parsers blijven werken, en de JSON-export voegt matchende velden toe zonder de betekenis van de oude te veranderen. Wat de revocation-cache van een bepaalde machine op een gegeven dag bevat is de zaak van Windows, en een lege cache levert “unknown” op in plaats van een netwerkrequest of een vals “valid”. Als offline validatie steeds onbeslist terugkomt, zit de duurzame fix stroomopwaarts: verzamel het validatiemateriaal bij het ondertekenen, zoals beschreven in langlopende PDF-handtekeningen met RFC 3161-timestamps en DSS, in plaats van te hopen dat de verifiërende machine een warme cache heeft

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