PDFium VCL potrafi teraz domknąć łańcuch podpisu PDF i sprawdzić rewokację przez sieć na swoim backendzie OpenSSL: gdy OnlineRetrieval jest włączony, ConfigureSslCmsVerifier instaluje weryfikator, który pobiera brakujące certyfikaty pośrednie z URL-i AIA caIssuers i CRL z punktów dystrybucji CRL, w ramach stałego budżetu czasu, żądań i bajtów na jedno wywołanie weryfikacji. Pobrane certyfikaty to zawsze wyłącznie materiał łańcucha. Zaufanie nadal pochodzi wyłącznie z magazynu systemowego i kotwic, które skonfigurujesz
Luka, którą to zamyka, wychodzi przy pierwszej walidacji prawdziwych PDF-ów na serwerze Linuksowym. Spora część podpisujących osadza w CMS tylko własny certyfikat liścia, więc OpenSSL nie dociera do roota, TrustStatus wraca jako invalid, a rewokacja nigdy nie rusza, bo łańcuch nigdy nie stał się godny zaufania. Przed v3.121.0 backend OpenSSL opisany w weryfikacji podpisów PDF przez OpenSSL w PDFium VCL był ściśle offline, a OnlineRetrieval nie robił na nim nic. Jedno warto powiedzieć na starcie: sam silnik PDFium w ogóle nie robi weryfikacji CMS, więc każda reguła poniżej mieszka w warstwie PAdES komponentu i w jego wiązaniu OpenSSL, gdzie możesz ją przeczytać
W jakiej kolejności backend OpenSSL weryfikuje, pobiera i sprawdza?
Najpierw integralność, potem zaufanie, potem rewokacja, a sieci dotyka się tylko między krokami, które jej potrzebują. VerifyCmsWithSsl sprawdza podpis CMS i atrybuty podpisane (RFC 5652) z wytłumioną ewaluacją łańcucha, a jeśli to padnie, wraca natychmiast, zanim sesja pobierania w ogóle powstanie, więc dokument ze zepsutymi bajtami nie wyzwala żadnego żądania na zewnątrz. Tylko gdy potem pada łańcuch i OnlineRetrieval jest włączony, backend idzie za linkami AIA i weryfikuje ponownie. Punkty dystrybucji CRL są pobierane dopiero, gdy łańcuch jest zaufany, bo CRL wiszący na niezaufanej ścieżce nie dowodzi niczego. Trzy werdykty pozostają osobne przez cały czas: poprawny podpis z niekompletnym łańcuchem jest nadal raportowany jako poprawny podpis
uses
PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;
var
Pdf: TPdf;
Probe: TPdfCmsVerifyOptions;
Diags: TPdfSslVerifyDiagnostics;
Trust: TPadesTrustValidationOptions;
Verdict: TPadesValidationResult;
I: Integer;
begin
ConfigureSslTrustAnchors(LoadCorporateRoots); // DER; jedyne dodatkowe zaufanie
ConfigureSslCmsVerifier;
Probe := TPdfCmsVerifyOptions.Default;
Probe.OnlineRetrieval := True;
Probe.CheckRevocation := True;
Diags := SslVerifyOptionsDiagnostics(Probe);
if psvdOnlineRetrievalIgnored in Diags then
Log('no HTTP transport or CMS_add1_cert: validation stays offline');
Trust := TPadesTrustValidationOptions.Default; // domyślnie ptnpOffline
Trust.NetworkPolicy := ptnpOnline;
Trust.CheckRevocation := True;
Trust.UrlRetrievalTimeoutMs := 10000; // na jedno wywołanie weryfikacji
Pdf := TPdf.Create(nil);
try
Pdf.FileName := 'signed-contract.pdf';
Pdf.Active := True;
Verdict := Pdf.ValidatePadesTrust(Trust);
for I := 0 to High(Verdict.Signatures) do
Log(Format('#%d trust=%d revocation=%d', [I,
Ord(Verdict.Signatures[I].CertificateTrustStatus),
Ord(Verdict.Signatures[I].RevocationStatus)]));
finally
Pdf.Free;
end;
end;
Dlaczego pobrane certyfikaty nigdy nie trafiają do magazynu zaufania?
Bo URL-e pochodzą z walidowanego certyfikatu, a wybrał je podpisujący. Wpis authorityInfoAccess caIssuers (RFC 5280 §4.2.2.1) to wskazówka, gdzie mieszka wystawca, i nic więcej. Gdyby cokolwiek, co odpowiada na ten URL, wpadło do magazynu kotwic zaufania, każdy mógłby podpisać własnoręcznie zrobionym kluczem, nakierować AIA na swój serwer i dostać zielony werdykt. RetrieveIntermediates podaje więc każdy sparsowany certyfikat do CMS_add1_cert, który umieszcza go w niezaufanym zbiorze tej jednej struktury CMS, a OpenSSL nadal musi zbudować od niego ścieżkę do kotwicy, którą skonfigurowałeś albo którą już trzyma magazyn systemowy. Jest też cichszy powód: argument certyfikatowy CMS_verify nie jest drop-in-owym zamiennikiem certyfikatów osadzonych w CMS, więc dodawanie do samego CMS to pewna droga
Pętla pobierania jest celowo ciasna. RetrieveIntermediates biegnie co najwyżej 4 rundy, każda zbiera URL-e caIssuers z każdego certyfikatu obecnego już w CMS, i zatrzymuje się, jak tylko runda nic nie doda albo budżet czasu się wyczerpie. Odpowiedź musi się dekodować przez d2i_X509 jako pojedynczy certyfikat DER zjadający całe ciało; bajty na ogonie są odrzucane, a pakiet PKCS#7 certs-only serwowany z URL-a .p7c jest pomijany, zamiast być rozpakowywany. Metoda dostępu OCSP w tym samym rozszerzeniu AIA jest ignorowana, bo ten backend nie mówi po OCSP. Po stronie rewokacji RetrieveCrls czyta tylko URI fullName każdego DistributionPoint (RFC 5280 §4.2.1.13) z certyfikatów CMS i skonfigurowanych kotwic, a pobrane CRL trafiają do drugiego, niezależnego X509_STORE z kontrolą CRL całego łańcucha, więc brakujące albo nieaktualne CRL zmienia RevocationStatus, nie dotykając nigdy TrustStatus
// Skondensowano z VerifyCmsWithSsl (FPdfCryptoSsl.pas); konfigurację BIO pominięto.
// Każde wywołanie _CMS_verify dostaje świeże BIO treści
if _CMS_verify(Cms, nil, nil, Bio, nil,
CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
Exit; // zepsuty podpis: zero sieci
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);
Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
(FetchSession <> nil) then
begin
RetrieveIntermediates(Cms, FetchSession); // CMS_add1_cert, tylko niezaufane
// zweryfikuj łańcuch ponownie wobec tego samego magazynu kotwic
end;
if Options.CheckRevocation and (FetchSession <> nil) and
(Result.TrustStatus = pcvsValid) then
RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// drugi, osobny magazyn: skonfigurowane CRL plus pobrane
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);
Ile kosztuje co najwyżej jedno wywołanie weryfikacji?
Twardy sufit, pilnowany przez jeden TPdfCryptoFetchSession, który kroki AIA i CRL pojedynczego wywołania weryfikacji współdzielą. Limity to stałe w FPdfCryptoHttp, nie sugestie:
- Czas:
UrlRetrievalTimeoutMs, który domyślnie wynosi 15000 i wTPdfCmsVerifyOptions.Default, i wTPadesTrustValidationOptions.Default; sesja stworzona z 0 cofa się do 30000, a zegar startuje, gdy podpis już przeszedł, obejmując każde późniejsze żądanie - Żądania: co najwyżej 8 na sesję, liczone przed próbą transportu, więc martwy host nadal zużywa slot
- Bajty: 1 MiB na odpowiedź i 4 MiB łącznie, przy URL-ach dłuższych niż 2048 znaków odrzucanych przed jakimkolwiek połączeniem
Rachunkowość jest surowsza, niż na pierwszy rzut oka wygląda. Bajty odebrane z nieudanej odpowiedzi nadal liczą się do sumy, więc serwer odpowiadający 404 z dużą stroną nie wyczerpie budżetu za darmo. Odczyt przekraczający limit na odpowiedź przerywa pobieranie, zamiast przepuszczać uciętą resztę do parsera ASN.1, a HTTP 200 z pustym ciałem jest odrzucany wprost, bo ścieżka AIA indeksowałaby inaczej Data[0] pustej tablicy. Przechodzą tylko gołe URL-e http:// i https://, bez przekierowań, cookies, poświadczeń ani automatycznego wykrywania proxy, przy czym HTTPS zachowuje swoje normalne kontrole certyfikatu i nazwy hosta. Deduplikacja URL-i jest celowo ograniczona do jednego wywołania: następna walidacja musi móc zobaczyć świeżo opublikowane CRL. Budżet jest też per wywołanie, nie per dokument, a ValidatePadesTrust weryfikuje każdy podpis i każdy token znacznika czasu osobno, więc przypadek najgorszy rośnie z liczbą podpisów
Dlaczego żądanie WinHTTP po timeoutie nadal może pisać do twojej pamięci?
Bo powrót po timeoutie nie anuluje callbacków już w locie. Transport Windows napędza WinHTTP asynchronicznie i czeka na zdarzeniu z pozostałym czasem sesji, a gdy to czekanie się podda, żądanie nadal może dokończyć odczyt i zasygnalizować później. Skierujesz odczyt asynchroniczny na bufor na stosie, i ta spóźniona dogrywka pisze do ramki, która należy już do jakiejś niepowiązanej funkcji. Poprawka to własność, nie timing: zdarzenie i 16 KB bufor odczytu mieszkają w rekordzie na stercie z dwiema referencjami, jedną trzymaną przez wywołującego i jedną zwalnianą tylko przez końcowe wywołanie zwrotne HANDLE_CLOSING, więc ta strona, która skończy ostatnia, zwalnia pamięć
type
PHttpState = ^THttpState;
THttpState = record
References: LongInt; // wywołujący + końcowy callback HANDLE_CLOSING
Event: THandle;
Status, Count: DWORD;
Buffer: array[0..16383] of Byte; // tu lądują odczyty asynchroniczne, nigdy na stosie
end;
procedure ReleaseState(State: PHttpState);
begin
if InterlockedDecrement(State.References) = 0 then
begin
CloseHandle(State.Event);
Dispose(State);
end;
end;
// W callbacku statusu: HANDLE_CLOSING to ostatnie powiadomienie, jakie WinHTTP
// wysyła dla żądania, więc zrzuca drugą referencję
if Status = HttpHandleClosing then
begin
ReleaseState(State);
Exit;
end;
Co libcurl musi dostarczyć na FPC Unix
Asynchroniczny resolver i build thread-safe, albo pobieranie online zostaje wyłączone. Na FPC Unix transport idzie przez libcurl, tę samą zależność, która stoi za backendem znaczników czasu libcurl dla celów spoza Windows, a wiązanie odrzuca każdą bibliotekę, której maska funkcji nie ma ani CURL_VERSION_ASYNCHDNS, ani CURL_VERSION_THREADSAFE. Powód: CURLOPT_NOSIGNAL, które biblioteka w cudzym procesie musi ustawić, w parze z synchronicznym resolverem znaczy, że zapytanie DNS potrafi po prostu przeżyć timeout. Druga pułapka to zamknięcie: curl_global_cleanup nie czeka na wątki asynchronicznego DNS, więc skoro libcurl został zainicjowany, moduł zostaje zamapowany do wyjścia procesu, zamiast puszczać wątek tła w rozładowany kod. Gdy którykolwiek warunek pada, SslCapabilities.OnlineRetrieval jest False, a SslVerifyOptionsDiagnostics zgłasza psvdOnlineRetrievalIgnored, zamiast udawać, że sieć była odpytana
Co wynik gwarantuje, a czego nie
Poprawny RevocationStatus z tego backendu znaczy, że znaleziono, skonfigurowano albo pobrano aktualne CRL pokrywające cały łańcuch i żadne nie wypisało w nim certyfikatu; nic więcej. OCSP nie ma, więc CA publikująca rewokację tylko przez OCSP zostawia wynik jako niewspierany, a awaria sieci wygląda dokładnie jak CA, która nie publikuje niczego. Zauważ też, że psvdNoCrlsConfigured opisuje tylko CRL, które skonfigurowałeś, więc przy pobieraniu online to wskazówka, nie zapowiedź porażki. Gdy ślad audytowy musi być odtwarzalny bez dostępu do sieci, zostaw NetworkPolicy na domyślnym ptnpOffline: sesja pobierania nie powstaje, a backend nigdy nie otwiera połączenia, co pasuje do kontraktu offline po stronie CryptoAPI opisanego w kontrolach rewokacji podpisów PDF bez sieci na Windows
Kod pobierania, budżety i wiązania transportowe płyną jako źródła z komponentem PDFium dla Delphi, więc możesz potwierdzić dokładnie, jakie URL-e walidacja może kontaktować i ile może pobrać, zanim włączysz ptnpOnline na serwerze obsługującym niezaufane dokumenty