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
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
// 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
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