Artykuł techniczny

Offline sprawdzanie unieważnień podpisów PDF w Delphi

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ć

Dwa niezależne przełączniki strzegą offline’owej walidacji podpisów PDF na Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ogranicza wyłącznie budowę łańcucha, jak pobrania wystawców AIA i korzeni, podczas gdy unieważnienia potrzebują CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, bo bez niego providerzy dalej pobierają CRL i wysyłają żądania OCSP, które zdradzają, który certyfikat jest walidowany
PDFium VCL skleja flagę cache-only unieważnień operatorem OR z przebiegiem unieważnień, gdy OnlineRetrieval ma False, więc walidacja zaufania ptnpOffline nie schodzi do sieci w żadnym z przebiegów
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

Spacer po dowodach, który ReadWinRevocationEvidence wykonuje na każdym elemencie łańcucha Windows w Delphi: pRevocationInfo musi być obecne, cbSize musi być dostatecznie duże do bezpiecznego odczytu, dwRevocationResult musi być zero, a końcowy element jest wyłączany tylko, gdy oznaczony jako self-signed albo CA-trusted, więc maska błędu zaufania równa zero nie ukryje już niesprawdzonego certyfikatu
Czysty werdykt wymaga co najmniej jednego elementu niekorzennego i odpowiedzi od każdego wymaganego elementu, przy czym RevocationError trzyma surowe DWORD providerów, jak CRYPT_E_REVOKED
// 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

Unieważnienia podpisu i znacznika czasu pozostają osobne w PDFium VCL: odłączony CMS podpisu dokumentu wypełnia RevocationReason i NativeRevocationError, dołączony CMS tokenu RFC 3161 wypełnia TimeStampRevocationReason i NativeTimeStampRevocationError, a niesprawdzone etapy zostawiają pcrrNone i zero obok swoich pól statusu
Unieważniony certyfikat TSA nie może więc podszyć się pod unieważnionego podpisującego, a porażka znacznika czasu nigdy nie wymazuje wyniku integralności, który weryfikacja podpisu już ustaliła
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