PDFium VCL sprawdza unieważnienia podpisów PDF offline na Windows, dokładając CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY do przebiegu unieważnień w CertGetCertificateChain, bo flaga cache-only używana przy budowie łańcucha nie pokrywa pobierania CRL ani OCSP w ogóle. Od v3.119.1 offline’owe wywołanie ValidatePadesTrust nie schodzi do sieci, a czysty wynik wymaga realnych dowodów unieważnienia per certyfikat. Reszta tego tekstu jest o tym, czemu obie połowy tamtego zdania wymagały naprawy
Konfiguracja, która wystawia problem, jest zwyczajna. Serwis walidacyjny biegnie na zablokowanym hoście Windows, TPadesTrustValidationOptions.NetworkPolicy ma ptnpOffline (co jest też wartością domyślną), a operator oczekuje, że każda odpowiedź przyjdzie z lokalnego cache’a certyfikatów. Aż ktoś zauważa wychodzące żądania do punktu dystrybucji CA w logu firewalla albo zadanie wsadowe, które przystaje na całe UrlRetrievalTimeoutMs wynoszące 15000 ms przy każdym podpisie. Nic w kodzie nie prosiło o sieć. Windows poszedł tam i tak
Dlaczego offline’owa budowa łańcucha i tak pobiera CRL na Windows?
Bo CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ogranicza tylko pobieranie URL, które robi budowa łańcucha: pobrania wystawców AIA, aktualizacje korzeni i CTL. Dokumentacja Microsoftu dla CertGetCertificateChain mówi wprost, że flaga nie dotyczy sprawdzania unieważnień. Unieważnienia mają własny przełącznik, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), a bez niego providerzy unieważnień są wolni pobrać CRL albo wysłać żądanie OCSP, choć okoliczne wywołanie wygląda na offline. PDFium VCL skleja teraz tę flagę operatorem OR z przebiegiem unieważnień, gdy tylko OnlineRetrieval ma False, na dodatek do flag łańcucha, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT i CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. To znaczy więcej niż opóźnienie: żądanie OCSP mówi responderowi, na który certyfikat patrzysz — dokładnie to, czego walidator odcięty od sieci ma unikać
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // domyślnie False
Options.CheckTimeStamps := True;
// Offline znaczy teraz offline także dla unieważnień: wyłącznie cache’owane
// odpowiedzi CRL i OCSP, żaden checkpoint pcvstOnlineRetrieval nie jest podnoszony
Report := Pdf.ValidatePadesTrust(Options);
end;
Dwie budowy łańcucha, dwa pola błędów
Backend Windows buduje łańcuch dwa razy i każda budowa ma teraz własne miejsce na błąd. Pierwsze wywołanie CertGetCertificateChain biegnie bez flag unieważnień i karmi CertVerifyCertificateChainPolicy polityką bazową, co produkuje TrustStatus i TrustError. Drugie wywołanie dokłada flagi unieważnień. Przed v3.119.1 porażka tego drugiego wywołania wpisywała GetLastError do TrustError, więc łańcuch, który właśnie został zweryfikowany jako zaufany, mógł wrócić wyglądając na niezaufany, bo provider unieważnień się zakrztusił. Poprawka czyta GetLastError natychmiast i zapisuje go w TPdfCmsVerifyResult.RevocationError, zostawiając werdykt pierwszego przebiegu w spokoju. A zwrócony True z drugiego wywołania też nie jest traktowany jako sukces; znaczy tylko, że Windows podał kontekst łańcucha warty oględzin
Co faktycznie dowodzi maska błędu zaufania równa zero?
Sama w sobie: nic. Zagregowane TrustStatus.dwErrorStatus równe zero po przebiegu unieważnień znaczy, że żaden bit błędu nie został podniesiony, a łańcuch, w którym żaden element w ogóle nie niósł informacji o unieważnieniach, potrafi dać dokładnie to. Dawniejszy kod mapował „brak bitu revoked, brak bitu unknown, brak bitu offline” wprost na poprawny — klasyczny sposób, w jaki walidator raportuje niesprawdzony certyfikat jako czysty. Nowa rutyna ReadWinRevocationEvidence przechodzi każdą prostą składową łańcucha i każdy element, odrzuca struktury, których cbSize jest za małe, by czytać bezpiecznie, i raportuje sukces tylko wtedy, gdy istnieje co najmniej jeden element niekorzenny i każdy taki element niesie CERT_REVOCATION_INFO z dwRevocationResult równym zero
// Skrócone ze spaceru po dowodach: element liczy się tylko, gdy
// provider unieważnień faktycznie mu odpowiedział
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); // łańcuch samych korzeni niczego nie dowodzi
Wynik providerów zostaje surowy. RevocationError trzyma DWORD dwRevocationResult dokładnie tak, jak zwrócił go provider, przedkładaając błąd z elementu unieważnionego, gdy taki jest (CRYPT_E_REVOKED to $80092010), a bitmaska zaufania nigdy nie przebiera za natywny kod błędu. Mapowanie na TPdfCmsRevocationReason jest celowo grube: pcrrCertificateRevoked z pcvsInvalid dla jawnego unieważnienia, pcrrChainUntrusted, gdy łańcuch zawodzi z powodów niezwiązanych z unieważnieniami, i pcrrUnknown dla wszystkiego innego. Windows mógł próbować OCSP zamiast CRL, więc wynik offline albo unknown nie jest tłumaczony na pcrrCrlExpired. Backend weryfikacji CMS OpenSSL może robić te rozróżnienia specyficzne dla CRL, bo ewaluuje wyłącznie CRL, które mu podasz, podczas gdy backend SecTrust na macOS zostawia pola na pcrrNone i zero, co znaczy „brak szczegółowej diagnostyki”, a nie „zaliczone”
Gdzie kończy się wyłączanie korzenia
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT słusznie pomija kotwicę, bo nikt nie publikuje CRL unieważniającej korzeń względem niego samego. Pułapka kryje się w rozstrzyganiu, który element jest korzeniem. PDFium VCL wyłącza ostatni element prostej składowej łańcucha tylko wtedy, gdy jego dwInfoStatus oznacza go jako self-signed ($00000008) albo jawnie CA-trusted ($00004000). Offline’owy host często nie umie pobrać brakującego wystawcy, więc łańcuch kończy się na pośrednim; potraktowanie ostatniego elementu takiego częściowego łańcucha jako korzenia cicho wyrzuciłoby ten jedyny certyfikat, którego statusu unieważnienia najprawdopodobniej nie ma w cache. Ten element zostaje w zbiorze wymaganych, nie ma odpowiedzi od providera, a wynik pozostaje pcvsIndeterminate
Jak wyniki unieważnień podpisu i znacznika czasu są trzymane osobno?
Jako osobne pola, które nigdy siebie nie nadpisują. Walidator PAdES weryfikuje odłączony CMS podpisu dokumentu i dołączony CMS tokenu znacznika czasu RFC 3161 w dwóch niezależnych wywołaniach, a v3.119.0 dała każdemu własną diagnostykę na TPadesSignatureValidation: RevocationReason i NativeRevocationError dla podpisującego, TimeStampRevocationReason i NativeTimeStampRevocationError dla TSA. Unieważniony certyfikat TSA nie może więc podszyć się pod unieważnionego podpisującego, a porażka znacznika czasu nie wymazuje wyniku integralności, który został już ustalony. Gdy CheckRevocation ma False albo walidacja nigdy nie doszła do tego etapu, pola pozostają pcrrNone i 0, więc czytaj je zawsze obok RevocationStatus i 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;
Raport dowodów trzyma się tej samej reguły. Eksport CSV dokleja revocationReason, nativeRevocationError i kolumny znacznika czasu aż po nativeTimeStampRevocationError na końcu istniejącej kolejności kolumn, więc starsze parsery dalej działają, a eksport JSON dodaje pasujące pola bez zmiany znaczenia starych. Jeśli offline’owa walidacja ciągle wraca jako indeterminate, trwała poprawka jest u źródła: zbieraj materiał walidacyjny w chwili podpisywania, jak opisuje tekst o długoterminowych podpisach PDF ze znacznikami czasu RFC 3161 i DSS, zamiast liczyć, że maszyna weryfikująca ma ciepły cache
Co matryca testów dowodzi, a czego nie
Matryca weryfikacji Windows przeszła 30 kontrolowanych scenariuszy API łańcucha i jeden prawdziwy offline’owy smoke CMS na każdym celu Delphi i FPC Win32 oraz Win64. Prawdziwy smoke weryfikuje poprawny podpis pod niezaufanym prywatnym CA, podczas gdy wyniki czyste i jawnie unieważnione pochodzą z podrobionych odpowiedzi CertGetCertificateChain, a nie z zainstalowanych kotwic zaufania albo żywego pobierania. To uczciwa granica, którą warto nazwać: obsługa flag, izolacja błędów i spacer po dowodach są przypięte, ale to, co cache unieważnień danej maszyny zawiera danego dnia, to nadal sprawa Windows — a pusty cache produkuje teraz poprawnie „unknown”, zamiast żądania sieciowego albo fałszywego „poprawny”
Obsługa offline’owych unieważnień, diagnostyka per pole i eksporty dowodów są częścią API walidacji podpisów PDF w PDFium VCL dla Delphi i C++Buildera, obok backendów OpenSSL i macOS dla wdrożeń wieloplatformowych