Artykuł techniczny

Backend znaczników czasu libcurl dla PDFium VCL na FPC

PDFium VCL wysyła żądania znaczników czasu RFC 3161 przez libcurl na targetach innych niż Windows, bindowane dynamicznie do ośmiu symboli, w lustrzanym kształcie backendu Windows, który binduje się do WinHTTP. Dwa ustawienia opcji rozstrzygają, czy transport jest niezawodny pod obciążeniem, a cały unit był walidowany na maszynie, która nie umiała go skompilować dla jego platformy docelowej

Znaczniki czasu to to, co zamienia podpis w coś, co przeżywa wygaśnięcie certyfikatu, i to operacja sieciowa siedząca wewnątrz operacji podpisywania. Ta kombinacja czyni wybór transportu doniosłym w sposób, w jakim zwykle nie jest: chodzi na wątku roboczym, rozmawia z serwerem, którego nie kontrolujesz, i zawieszenie tam blokuje potok podpisywania, a nie ładowanie strony

Dlaczego libcurl zamiast klienta HTTP FPC?

Bo alternatywa wleci do repozytorium stos TLS, a potem każe ci utrzymywać jego detekcję wersji. Oczywista droga na Free Pascalu to fphttpclient z warstwą gniazd OpenSSL i polega na detalach: bindingi OpenSSL w FPC 3.2.2 wykrywają OpenSSL 3.x zawodnie na większości bieżących dystrybucji, a macOS dokłada różnice LibreSSL na wierzchu. To, co zaczyna się jako małe wywołanie HTTP, staje się ciągłym utrzymywaniem cudzego ABI TLS

libcurl rozwiązuje własny backend TLS i waliduje łańcuchy względem platformowego magazynu zaufania, więc strona Pascala nie potrzebuje niczego z tego. Warstwa bindingu to osiem symboli. Ta liczba jest argumentem: mniejsza powierzchnia między twoim kodem a ruchomą zależnością znaczy mniej miejsc, w których upgrade dystrybucji może cię złamać, i pasuje do istniejącego backendu Windows, który binduje garstkę punktów wejścia WinHTTP tak samo

uses
  FPdfTsaFpc;

var
  ReqDer, RespDer: TBytes;
begin
  if not TsaHttpAvailable then
    raise Exception.Create('no HTTP transport for timestamping');

  Writeln('TSA transport: ', TsaHttpBackendName);

  ReqDer := BuildTimeStampQuery(DocumentDigest);
  if PostTimeStampQuery('https://tsa.example.org/tsr', ReqDer, RespDer) then
    AttachTimeStampToken(RespDer)
  else
    raise Exception.Create('timestamp request failed');
end;

Deklarowanie funkcji wariadycznej C w Pascalu

curl_easy_setopt i curl_easy_getinfo są wariadyczne po stronie C, a Object Pascal nie ma jak tego wyrazić. Podejście, które działa, to zadeklarowanie kilku stałych prototypów, po jednym na klasę argumentów, wszystkie wskazujących ten sam eksportowany symbol: wariant biorący long, wariant biorący wskaźnik i tak dalej, wybieranych w miejscu wywołania według tego, co faktycznie przekazujesz

To jest bezpieczne z konkretnego powodu wartego zrozumienia, a nie skopiowania. Każdy z tych typów argumentów jest przekazywany w rejestrze całkowitoliczbowym przy obowiązujących konwencjach wywołań platformy, czyli dokładnie tam, skąd czyta go implementacja va_arg w C. Trik trzyma się więc dla liczb całkowitych, wskaźników i uchwytów i nie trzyma dla argumentów zmiennoprzecinkowych, które jadą innymi rejestrami. Nie dodawaj wariantu biorącego double w założeniu, że wzorzec się uogólnia

// Jeden eksportowany symbol, kilka stałych prototypów. Każdy wariant podaje
// argument w rejestrze całkowitoliczbowym, czyli tam, gdzie czyta go strona C.
// Wariant zmiennoprzecinkowy by nie zadziałał i nie wolno go dodawać
type
  TCurlSetOptLong = function(Handle: Pointer; Option: Integer;
    Value: NativeInt): Integer; cdecl;
  TCurlSetOptPtr  = function(Handle: Pointer; Option: Integer;
    Value: Pointer): Integer; cdecl;

var
  curl_easy_setopt_long: TCurlSetOptLong;
  curl_easy_setopt_ptr:  TCurlSetOptPtr;

Dwa ustawienia rozstrzygające, czy żądanie się kończy

Pierwsze to jawny pusty nagłówek Expect:. libcurl włącza handshake HTTP 100-continue dla ciał żądań ponad około jeden kilobajt, a zapytanie znacznika czasu z żądaniem certyfikatu zwykle przekracza ten próg. Niektóre serwery TSA nigdy nie odpowiadają na kontynuację, więc klient odczekuje pełny timeout, zanim wyśle ciało, które serwer przyjąłby natychmiast. Wysłanie pustego nagłówka Expect: wycisza handshake i żądanie przechodzi w jednej rundzie

Drugie to CURLOPT_NOSIGNAL, które musi być ustawione. Bez niego libcurl implementuje swój timeout rozwiązywania nazw przez SIGALRM, a ten mechanizm nie jest thread-safe. Podpisywanie chodzi na wątku roboczym, więc domyślne zachowanie to utajony crash, który pojawia się pod współbieżnością i nigdy w teście jednowątkowym. Ustawienie flagi wyłącza ścieżkę opartą na sygnałach i kosztuje tylko ziarnistość timeoutu resolvera

Oba defekty dzielą profil, który czyni je drogimi w znalezienie później. Żaden nie wychodzi w teście funkcjonalnym przeciw grzecznemu serwerowi na jednym wątku. Oba wychodzą na produkcji, przeciw jednemu konkretnemu TSA, pod obciążeniem. Gdy bindujesz bibliotekę sieciową, przeczytaj, co jej domyślne zakładają o twoim procesie, zanim założysz, że pasują

Diagram transportu znaczników czasu libcurl w PDFium VCL pokazujący curl_easy_setopt zadeklarowany jako stałe prototypy Pascala biorące long i wskaźnik, podające argumenty w rejestrach całkowitoliczbowych, pusty nagłówek Expect wyciszający handshake HTTP 100-continue, CURLOPT_NOSIGNAL usuwający ścieżkę SIGALRM na wątkach roboczych oraz transportowy limit odpowiedzi
Dwa ustawienia rozstrzygają, czy żądanie się kończy: pusty nagłówek Expect omija serwery, które nigdy nie odpowiadają na kontynuację, a NOSIGNAL trzyma timeouty rozwiązywania nazw z dala od ścieżki sygnałowej, podczas gdy podpisywanie chodzi na wątku roboczym

Jak zweryfikować kod, którego twój kompilator nigdy nie zobaczy?

Sprawiając, że kompilator i tak go zobaczy, przez kontrolowaną kopię. Maszyna developerska nie ma tu cross-kompilatora na Linuxa ani macOS, więc gałęzie inne niż Windows w unicie znaczników czasu nigdy nie docierają do generatora kodu przy normalnym buildzie. Kod, który nigdy nie jest kompilowany, to kod gnijący po cichu: rename we współdzielonym typie, zmieniona lista parametrów, dodana zależność unitu i nikt tego nie zauważa miesiącami

Technika jest mechaniczna. Skopiuj unit do tymczasowego katalogu, zmień mu nazwę i zastąp każdy warunek Windowsowy, zarówno formę {$IFDEF MSWINDOWS}, jak i formę {$IF DEFINED(MSWINDOWS), symbolem, który nigdy nie jest zdefiniowany. Potem skompiluj kopię. Gdy wszystkie 3 828 linii się kompiluje, udowodniłeś, że ścieżka inna niż Windows używa unitów, które istnieją, woła funkcje backendu o pasujących sygnaturach i referencjonuje typy, które są w zasięgu. To nie jest dowód, że transport działa, i nic krócej niż platforma docelowa ci tego nie da. To dowód, że gałąź już nie jest zepsuta, czyli ten tryb awarii, który faktycznie się kumuluje

Nawykiem towarzyszącym jest trzymać sam unit libcurl bez straż platformowych, żeby uczestniczył w zwykłym buildzie Windows, choć nic tam do niego nie referuje. Codzienny build pilnuje wtedy jego składni i typów za darmo. Unit kompilujący się wyłącznie na platformie, której nie masz, to unit bez żadnej kontroli kompilatora i to samo rozumowanie obowiązuje w całej pracy międzykompilatorowej opisanej w artykule pułapki cross-kompilacji Delphi i FPC

Ograniczanie tego, co wraca

Odpowiedź znacznika czasu to mała struktura DER i nic w transporcie tego nie egzekwuje. Serwer skompromitowany, źle skonfigurowany albo po prostu celujący w zły URL może zwrócić dowolny strumień, a klient czytający do zamknięcia połączenia z przyjemnością go zakumuluje. Oba transporty capują więc odpowiedź, co jest właściwym miejscem dla limitu: odmowa na poziomie transportu nie pozwala w ogóle zaalokować nadmiarowego ciała, a kontrola na poziomie parsera odpala dopiero po zarezerwowaniu pamięci

To samo rozumowanie dotyczy URL-a. Backend przyjmuje wyłącznie schematy, które umie mówić sensownie, więc błąd konfiguracji polegnie natychmiast z jasnym komunikatem, zamiast być oddany libcurlowi do zinterpretowania w dowolny sposób, na jaki pozwala jego wsparcie protokołów

Gdzie transport siedzi w historii podpisywania

Znaczniki czasu to pierwszy krok historii długoterminowej walidacji, a nie całość. Token musi zostać doczepiony do podpisu, materiał walidacyjny zapisany w document security store, a znaczniki archiwalne odnowione, zanim obecny osłabnie. Cały ten łuk opisuje długoterminowe podpisy PDF ze znacznikami RFC 3161 i DSS

Diagram PDFium VCL żądania znacznika czasu RFC 3161 płynącego od DocumentDigest przez BuildTimeStampQuery i PostTimeStampQuery po libcurl do serwera TSA, odpowiedź DER capowana na transporcie, potem AttachTimeStampToken karmiący DSS i odnawianie znaczników archiwalnych w długoterminowej walidacji
Znaczniki czasu to pierwszy krok historii długoterminowej walidacji: token musi zostać doczepiony, materiał walidacyjny zapisany w document security store, a znaczniki archiwalne odnowione, zanim obecny osłabnie

Transport to też jeden kawałek szerszej pozycji przenośności: natywny loader bibliotek opisany w artykule ładowanie biblioteki natywnej na dowolnym targecie obsługuje tę samą klasę problemu dla samej binarki PDFium. W obu przypadkach wzorzec jest identyczny: zbinduj dynamicznie małą liczbę symboli, raportuj precyzyjnie, co się nie zbindowało, i nigdy nie pozwól, by brakująca zależność stała się błędem linkowania zatrzymującym aplikację przed startem

Backendy znaczników czasu dla Windows i innych systemów są obie częścią komponentu PDFium dla Delphi, wybierane według targetu, a nie konfiguracji, więc aplikacja Lazarus na Linuxie i aplikacja Delphi na Windows produkują ten sam podpis ze znacznikiem czasu przez różne zaplecze