Artykuł techniczny

Pobieranie AIA i CRL dla podpisów PDF przez OpenSSL w Delphi

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

Kolejność VerifyCmsWithSsl w backendzie OpenSSL PDFium Component: kontrola podpisu CMS biegnie z wytłumioną ewaluacją łańcucha, więc zepsute bajty nigdy nie dotykają sieci; RetrieveIntermediates idzie za URL-ami AIA caIssuers dopiero po padzie łańcucha przy włączonym OnlineRetrieval, a RetrieveCrls pobiera CRL punktów dystrybucji do osobnego magazynu, gdy łańcuch jest już zaufany
Integralność, potem zaufanie, potem rewokacja: sieci dotyka się tylko między krokami, które jej potrzebują, a CRL wiszący na niezaufanej ścieżce nie dowodzi niczego
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 w TPdfCmsVerifyOptions.Default, i w TPadesTrustValidationOptions.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
Twarde sufity jednego TPdfCryptoFetchSession współdzielonego przez kroki AIA i CRL wywołania weryfikacji w PDFium Component: UrlRetrievalTimeoutMs domyślnie 15000 ms z cofnięciem na 30000 przy zerze, co najwyżej 8 żądań na sesję, 1 MiB na odpowiedź i 4 MiB łącznie, przy czym nieudane odpowiedzi też się liczą, a URL-e ponad 2048 znaków są odrzucane
Limity to stałe, nie sugestie: bajty z nieudanej odpowiedzi nadal wyciekają z budżetu, a ponieważ każdy podpis i każdy znacznik czasu weryfikuje się osobno, przypadek najgorszy rośnie z liczbą podpisów

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ęć

Dlaczego żądanie WinHTTP po timeoutie nadal może pisać do pamięci: powrót po timeoutie zostawia callbacki w locie, więc PDFium Component kieruje odczyt asynchroniczny na alokowany na stercie rekord THttpState, którego 16 KB bufor i dwie referencje, jedna trzymana przez wywołującego i jedna zwalniana przez końcowy callback HANDLE_CLOSING, są zwalniane dopiero, gdy ostatnia strona skończy
Spóźniona dogrywka może dokończyć odczyt po tym, jak twoje czekanie się poddało; własność na stercie z dwiema referencjami znaczy, że ten zapis ląduje w pamięci, która jeszcze żyje
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