PDFium VCL sprawdza unieważnienia podpisów PDF offline na Windows, dodają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. Offline’owe wywołanie ValidatePadesTrust pozostaje więc poza siecią, a czysty wynik wymaga realnych dowodów unieważnienia per certyfikat. Reszta tego wpisu jest o tym, czemu każda połowa tego zdania wymaga przemyślanej roboty
Pułapka wychodzi w zwyczajnej konfiguracji. Serwis walidacyjny biegnie na zablokowanym hoście Windows, TPadesTrustValidationOptions.NetworkPolicy ma ptnpOffline (to też wartość domyślna), a operator oczekuje, że każda odpowiedź przyjdzie z lokalnego cache’a certyfikatów. Zbuduj przebieg unieważnień z samą flagą budowy łańcucha, a ktoś szybko zauważy w logu firewalla wychodzące żądania do punktu dystrybucji CA 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 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 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ń. Gdyby porażka tego drugiego wywołania wpisała GetLastError do TrustError, łańcuch właśnie zweryfikowany jako zaufany mógłby wrócić wyglądając na niezaufany, bo provider unieważnień się zakrztusił. PDFium VCL czyta GetLastError natychmiast i zapisuje go w TPdfCmsVerifyResult.RevocationError, zostawiając werdykt pierwszego przebiegu w spokoju. A zwrócone True z drugiego wywołania też nie jest traktowane 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. Mapowanie „brak bitu revoked, brak bitu unknown, brak bitu offline” wprost na poprawny to klasyczny sposób, w jaki walidator raportuje niesprawdzony certyfikat jako czysty. Rutyna ReadWinRevocationEvidence w PDFium VCL 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 poza korzeniem 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 każde ma 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. Co dana maszyna ma tego dnia w cache’u unieważnień, to sprawa Windows, a pusty cache daje „unknown” zamiast żądania sieciowego albo fałszywego „valid”. 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
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