Artykuł techniczny

Czego RFC 5280 wymaga od walidatora ścieżki podpisu PDF

Podpisana faktura weryfikuje się bajt w bajt, jej podpisujący został odwołany w zeszłym miesiącu, a CRL, który pobrałeś, nigdy nie wypisuje tego numeru seryjnego: mówi za inną partycję. Walidacja ścieżki w HotPDF jest zaprojektowana wokół takich przypadków: reguły zakresu i kotwic RFC 5280, zegar oceny w UTC i znaczniki czasu RFC 3161, których czas liczy się dopiero, gdy łańcuch TSA zostanie uznany

Zegar oceny chodzi po UTC

Każdy czas, który walidator ścieżki porównuje, jest przechowywany w UTC. RFC 5280 §4.1.2.5 wymaga, by daty ważności certyfikatu były kodowane jako UTCTime albo GeneralizedTime w Greenwich Mean Time, CRL thisUpdate i nextUpdate używają tych samych kodowań, a odpowiedzi OCSP z RFC 6960 niosą producedAt, thisUpdate i nextUpdate jako GeneralizedTime w UTC. Delphiowy TDateTime nie ma strefy czasowej, a Now zwraca lokalny czas ścienny, więc kontrola zapisana jako if Now > NotAfter miesza dwa zegary bez ostrzeżenia kompilatora. Błąd dzieli się geograficznie. Na zachód od Greenwich startuje za wcześnie. Na serwerze w Nowym Jorku o 09:00 lokalnego, gdy zegar światowy pokazuje 14:00Z, odpowiedź OCSP wyprodukowana o 13:59Z siedzi pięć godzin w przyszłości naiwnego zegara, daleko poza pięciominutową tolerancją poślizgu, jak domyślny OCSPAllowedClockSkewMinutes HotPDF, i zostaje odrzucona jako jeszcze nieaktualna. W Szanghaju znak się odwraca: o 22:00 lokalnego, czyli o 14:00Z, certyfikat podpisujący z notAfter 20:00Z już wygląda na wygasły, osiem godzin za wcześnie. Żadna z tych awarii nie pojawia się na agencie budującym ustawionym na UTC — domyślnie na większości hostowanych runnerów CI

VerifyLoadedSignatureWithTrust w Delphi z HotPDF ocenia THPDFCertificateValidationOptions.ValidationTime w UTC, unikając błędów zegara z Nowego Jorku i Szanghaju, które lokalny Now popełnia względem czasów producedAt odpowiedzi OCSP i notAfter certyfikatu
Porównywanie lokalnego Delphi Now z czasami ważności w UTC odrzuca świeże odpowiedzi OCSP na zachód od Greenwich i wygasa certyfikaty za wcześnie na wschód — dwie awarie, których agent budujący w UTC nigdy nie pokaże

HotPDF traktuje THPDFCertificateValidationOptions.ValidationTime jako UTC. Pozostawione na domyślnym zerze, czas oceny staje się bieżącym czasem UTC, chyba że zaufany znacznik czasu dostarcza czas podpisania (o tym niżej), a użyta faktycznie chwila wraca w Info.CertificateValidation.EvaluationTime. VerifyLoadedSignatureWithTrust najpierw robi weryfikację byte range stojącą za svValid, a dopiero potem buduje ścieżkę. Gdy sam przypinasz historyczną chwilę — na przykład dla audytora pytającego, czy podpis faktury był dobry na zamknięciu kwartału — konwersja jest twoją robotą. Zrobi to TTimeZone.Local.ToUniversalTime z System.DateUtils. Aplikuj ją tam, gdzie znacznik czasu wchodzi do twojego systemu, a nie przy wołaniu walidacji: w strefach z czasem letnim jedna lokalna godzina powtarza się co jesień, a inna wiosną nigdy nie istnieje, więc kolumna bazy danych z lokalnymi znacznikami czasu nie potrafi round-tripować każdej chwili

uses
  System.SysUtils, System.DateUtils, System.IOUtils,
  HPDFDoc, HPDFSignVerify;

var
  Pdf: THotPDF;
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  ClosingLocal: TDateTime;
begin
  Pdf := THotPDF.Create(nil);
  try
    Pdf.LoadFromFile('invoices-2026-q1.pdf');
    Options := THPDFCertificateValidationOptions.Default;
    SetLength(Options.TrustAnchors, 1);
    Options.TrustAnchors[0] := TFile.ReadAllBytes('issuing-root.cer');
    Options.RevocationMode := rvmRequire;
    // Zamknięcie kwartału takie, jak zapisał je ERP, w lokalnej strefie serwera
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Certyfikaty, CRL-i i odpowiedzi OCSP niosą wszystkie czasy UTC
    Options.ValidationTime := TTimeZone.Local.ToUniversalTime(ClosingLocal);
    Status := Pdf.VerifyLoadedSignatureWithTrust(0, Options, Info);
    Writeln('Evaluated at (UTC): ',
      DateTimeToStr(Info.CertificateValidation.EvaluationTime));
    case Status of
      cvsTrusted:
        Writeln('Trusted via ',
          string(Info.CertificateValidation.TrustAnchorSubjectDN));
      cvsRevoked:
        Writeln('Revoked at (UTC): ',
          DateTimeToStr(Info.CertificateValidation.RevokedAt));
    else
      Writeln('Not trusted: ', string(Info.CertificateValidation.Issue));
    end;
  finally
    Pdf.Free;
  end;
end;

Które certyfikaty pokrywa faktycznie CRL?

CRL mówi tylko o certyfikatach wewnątrz swojego zakresu, a zakres jest deklarowany w rozszerzeniu Issuing Distribution Point z RFC 5280 §5.2.5. CA partycjonujące swoje dane o odwołaniach publikuje kilka CRL-i, wszystkie podpisane tym samym kluczem i wszystkie świeże, i nieobecność certyfikatu w jednym z nich nie znaczy nic, dopóki ten CRL nie deklaruje, że go pokrywa. Pola zwężające zakres są takie:

  • distributionPoint: CRL pokrywa tylko certyfikaty, których rozszerzenie CRL Distribution Points wskazuje ten punkt
  • onlyContainsUserCerts i onlyContainsCACerts: wyłącznie certyfikaty końcowe albo wyłącznie CA
  • onlySomeReasons: odwołania dla podzbioru kodów przyczyn, jak keyCompromise
  • onlyContainsAttributeCerts: CRL dotyczy certyfikatów atrybutowych i nic nie mówi o certyfikatach klucza publicznego
  • indirectCRL: wpisy mogą należeć do wystawców innych niż podpisujący CRL

Naiwne wyszukiwanie weryfikuje podpis CRL, sprawdza thisUpdate i nextUpdate, szuka numeru seryjnego i uznaje certyfikat za dobry, gdy numeru brak. Ten ostatni krok trzyma się tylko po teście zakresu z RFC 5280 §6.3.3 (b)(2). CRL dla partycji 7 nigdy nie wypisuje certyfikatu z partycji 3. CRL ograniczony do certyfikatów CA nigdy nie wypisuje podpisującego końcowego. Oba odpowiadają "not found" na odwołany certyfikat. HotPDF sięga po bazowy CRL dla certyfikatu tylko wtedy, gdy CRL nie jest ograniczony ani do certyfikatów atrybutowych, ani do wybranych przyczyn, jego restrykcja user albo CA pasuje do certyfikatu, a każdy wskazany przez niego punkt dystrybucji pasuje do jednego z URI we własnym rozszerzeniu CRL Distribution Points certyfikatu, porównywanych bez rozróżniania wielkości liter — punkt podany jako nazwa względna wobec wystawcy CRL nie pasuje do niczego. CRL ograniczony przyczynami nie jest w ogóle konsultowany, więc odwołania, które wypisuje, też się nie liczą. §6.3.3 pozwala co prawda złożyć pełne pokrycie z partycji przyczyn, śledząc maskę przyczyn, aż każda przyczyna będzie rozliczona, ale dla podpisów dokumentowych prostszym dowodem jest odpowiedź OCSP albo CRL pełnego zakresu: zbierz jedno z tych. Gdy nie zostaje już żaden dowód w zakresie, status odwołania jest nieznany, a pod rvmRequire wołanie zwraca cvsRevocationUnknown

Test zakresu CRL w HotPDF przy sprawdzaniu odwołań podpisu PDF w Delphi: pola Issuing Distribution Point rozstrzygają, czy bazowy CRL zostanie skonsultowany, a bez dowodu w zakresie rvmRequire zwraca cvsRevocationUnknown według RFC 5280
Zanim CRL zostanie przeszukany dla numeru seryjnego, jego Issuing Distribution Point musi pokrywać certyfikat; inaczej brak wpisu nie dowodzi niczego o odwołaniu

Kotwice zaufania zamykające ścieżkę muszą być CA

RFC 5280 §6.1.1 wręcza algorytmowi walidacji ścieżki kotwicę zaufania jako zwykłą informację — nazwę wystawcy i klucz publiczny — a algorytm nigdy nie pyta, czy własny certyfikat kotwicy może cokolwiek wystawiać; test basicConstraints z §6.1.4 biegnie na pośrednich certyfikatach ścieżki. To w porządku, gdy kotwicami są główne CA z kuratorowanego skarbca. Przepływy dokumentowe łamią to założenie, bo ludzie przypinają certyfikaty końcowe: system zakupowy przyjmujący kontrakty od trzech znanych dostawców często ufa bezpośrednio certyfikatowi podpisującemu każdego dostawcy. Przypinanie działa dla ścieżki jednoelementowej, gdzie podpisujący i kotwica to ten sam certyfikat. Niebezpieczeństwo to ścieżka dwuelementowa. Prywatny klucz dostawcy może podpisać świeży certyfikat z dowolnym subjectem — choćby nazwiskiem twojego CFO — a walidator traktujący każdy klucz kotwicy jako klucz wystawiający zbuduje czystą ścieżkę od tej podrobki do przypiętego liścia

HotPDF stosuje wymagania wystawcy z RFC 5280 do kotwicy za każdym razem, gdy ścieżka jest dłuższa niż jeden certyfikat. Kotwica potrzebuje wtedy basicConstraints z cA=TRUE (§4.2.1.9), a jeśli niesie rozszerzenie keyUsage, bit keyCertSign musi być ustawiony (§4.2.1.3). Korzeń bez rozszerzenia basicConstraints liczy się jako wystawca tylko wtedy, gdy jest self-issued, z równymi nazwami subject i issuer, co pozwala dalej działać starym korzeniom w wersji 1, sprzed rozszerzeń. Przypinanie dalej działa. Przypięty certyfikat końcowy nadal waliduje własne podpisy, podczas gdy certyfikat przez niego podpisany wraca jako cvsPathValidationFailed, z napisem Issue stwierdzającym, że kotwica wystawiająca ścieżkę nie jest urzędem certyfikacji

Walidacja ścieżki certyfikatów RFC 5280 w Delphi z HotPDF: przypięty certyfikat końcowy w TrustAnchors waliduje własne podpisy, ale wystawiona przez niego ścieżka pada z cvsPathValidationFailed bez basicConstraints cA=TRUE i keyCertSign
Przypięcie certyfikatu podpisującego dostawcy nadal działa dla podpisów tego dostawcy, a każdy certyfikat podpisany jego kluczem zostaje odrzucony, bo kotwica nie jest urzędem certyfikacji

Które znaczniki czasu mogą ustawić czas oceny?

Tylko zaufane. Token RFC 3161 osadzony w CMS twierdzi, że wartość podpisu istniała w genTime, i walidatorzy oceniają certyfikat podpisującego w tej chwili — tak podpis przeżywa swój certyfikat. Token jest też po prostu kolejnym podpisem CMS. Zweryfikowanie go wobec certyfikatu, który niesie, dowodzi spójności wewnętrznej i niczego o tym, kto go wyprodukował: każdy, kto trzyma wygasły albo odwołany klucz podpisujący, może odpalić własną usługę znaczników czasu, ostemplować nowy podpis zeszłorocznym genTime i przecisnąć go z powrotem do okresu ważności certyfikatu. RFC 3161 §2.3 wymaga, by certyfikat TSA niesł extended key usage id-kp-timeStamping jako swój jedyny cel, oznaczony jako critical. HotPDF przyjmuje Info.Timestamp.GenerationTime jako czas oceny dopiero po tym, jak certyfikat tokena zwaliduje się wobec twoich kotwic, w bieżącym czasie UTC, dla celu time-stamping. Znacznik czasu dokumentu, /SubFilter ETSI.RFC3161, to inny przypadek. Tam token jest podpisem poddanym walidacji, a jego genTime dotyczy własnej ścieżki

Skoro token jest sprawdzany z twoimi własnymi opcjami, korzeń TSA musi siedzieć w tej samej liście TrustAnchors co korzeń podpisującego, a pod rvmRequire łańcuch TSA potrzebuje własnego dowodu braku odwołania. Przeocz jedno albo drugie, a ocena cofa się do bieżącego czasu. Życie certyfikatu TSA to drugi limit: token jest oceniany w bieżącym czasie, więc gdy ten certyfikat wygaśnie, ocena w czasie podpisania idzie z nim, i archiwa dodają znacznik czasu dokumentu przed tym punktem — krok opisany w dowodach LTV PAdES i odnawianiu znaczników B-LTA. Kod trzymający surowe bajty CMS może zadać to samo pytanie przez HPDFCMSTimestampTimeIsTrusted(CMSDER, Options), a ValidateLoadedSignatureBatch aplikuje je do każdego podpisu w partii. Funkcja poniżej raportuje, na którym zegarze zapadł werdykt

// Oczekiwane: z samym SignerRoot podpis, którego certyfikat wygasł
// w 2025, jest oceniany w bieżącym czasie UTC i nie wraca jako
// cvsTrusted, choć jego token znacznika czasu się weryfikuje; po dodaniu
// TsaRoot to samo wołanie jest oceniane w genTime
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // Łańcuch TSA jest oceniany wobec tej samej listy kotwic co podpisujący
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // zaufany znacznik czasu może dostarczyć czas
  Status := Pdf.VerifyLoadedSignatureWithTrust(SigIndex, Options, Info);
  if Info.Timestamp.Kind <> tskSignature then
    Clock := 'no signature timestamp, current UTC time'
  else if (Info.Timestamp.Status = svValid) and
    (Info.CertificateValidation.EvaluationTime =
     Info.Timestamp.GenerationTime) then
    Clock := 'trusted timestamp genTime'
  else
    Clock := 'timestamp not trusted, current UTC time';
  Result := Format('%s at %s (UTC), status %d', [Clock,
    DateTimeToStr(Info.CertificateValidation.EvaluationTime), Ord(Status)]);
end;

Wrogie dane wejściowe przed pierwszą decyzją o zaufaniu

Część pracy walidacyjnej biegnie na danych, którymi atakujący steruje w całości, zanim cokolwiek zostało uznane. Brakujące pośrednie są pobierane przez URL-e caIssuers Authority Information Access z RFC 5280 §4.2.2.1, i każdy pobrany pośredni może z kolei reklamować URL swojego wystawcy, więc lista robocza rośnie, podczas gdy ją obchodzisz. W Delphi wyklucza to pętlę for. Wartość końcowa instrukcji for jest oceniana raz, przed startem pętli — URL-e doklejone w trakcie obchodu nigdy nie zostają odwiedzone, a łańcuch z brakującymi dwoma szczeblami zostaje niekompletny. Odczytuj Count przy każdym przebiegu pętlą while zamiast tego. W HotPDF MaxFetch na HPDFFetchAIAIntermediates ogranicza, ile certyfikatów jeden obchód pobierze, a MaxFetchedIntermediates ustawia ten sam limit, gdy FetchMissingIntermediates pozwala walidacji ścieżki pobierać o własnych siłach

Podpowiedź Retry-After to kolejna wartość, którą wybiera obcy. Jej serwer został nazwany przez niezweryfikowany certyfikat i może prosić o godzinę pauzy przy każdym żądaniu. Przyciąć. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS ustawia ten sufit dla pobierania dowodów i AIA w HotPDF, domyślnie 30 000 ms. Procedura poniżej stosuje obie reguły dla zespołów, które same indeksują AIA — na przykład żeby rozgrzać cache pośrednich tieru podpisującego pracującego offline

uses
  System.SysUtils, System.Generics.Collections;

type
  // Twój transport: pobiera jeden cel caIssuers i zwraca URL-e caIssuers
  // reklamowane przez otrzymany certyfikat
  TFetchIssuer = reference to function(const URL: string;
    out NextURLs: TArray<string>; out RetryAfterMS: Cardinal): Boolean;

function WalkCAIssuers(const SeedURLs: TArray<string>;
  const Fetch: TFetchIssuer; MaxFetch: Integer;
  MaxRetryAfterMS: Cardinal): Integer;
var
  Queue: TList<string>;
  Seen: TDictionary<string, Boolean>;
  NextURLs: TArray<string>;
  URL: string;
  I, Attempt: Integer;
  RetryAfter: Cardinal;
  Ok: Boolean;

  procedure Enqueue(const AURL: string);
  begin
    if not Seen.ContainsKey(LowerCase(AURL)) then
    begin
      Seen.Add(LowerCase(AURL), True);
      Queue.Add(AURL);
    end;
  end;

begin
  Result := 0;
  Queue := TList<string>.Create;
  Seen := TDictionary<string, Boolean>.Create;
  try
    for URL in SeedURLs do
      Enqueue(URL);
    I := 0;
    // Pętla for przypięłaby Queue.Count na wejściu i nigdy nie doszłaby do
    // URL-i, które dokleają pobrane certyfikaty
    while (I < Queue.Count) and (Result < MaxFetch) do
    begin
      Ok := False;
      for Attempt := 1 to 3 do
      begin
        NextURLs := nil;
        RetryAfter := 0;
        Ok := Fetch(Queue[I], NextURLs, RetryAfter);
        if Ok or (Attempt = 3) then
          Break;
        // Zwłoka to życzenie serwera, a serwer wybrał niezweryfikowany
        // certyfikat
        if RetryAfter > MaxRetryAfterMS then
          RetryAfter := MaxRetryAfterMS;
        Sleep(RetryAfter);
      end;
      Inc(I);
      Inc(Result);
      if Ok then
        for URL in NextURLs do
          Enqueue(URL);
    end;
  finally
    Seen.Free;
    Queue.Free;
  end;
end;

Sama kontrola podpisu CMS biegnie przed walidacją ścieżki, bo budowanie ścieżki dla podpisu, który się nie weryfikuje, to zmarnowana robota, i używa klucza publicznego certyfikatu osadzonego w podpisie. Ta kolejność oddaje rozmiar klucza wybrany przez atakującego stery w chwili, gdy biegnie najdroższa arytmetyka. Nic nie jest jeszcze uznane. Koszt weryfikacji RSA rośnie z długością modulusa i długością wykładnika, więc certyfikat z ogromnym modułem i wykładnikiem równie długim potrafi zaprząc wątek w modularnym potęgowaniu na serwerze weryfikującym uploadowane pliki. HotPDF odmawia modulusa RSA powyżej 16 384 bitów, odmawia wykładnika dłuższego niż modulus, a powyżej 3 072 bitów przyjmuje wyłącznie wykładniki co najwyżej 64-bitowe. Prawdziwe klucze podpisujące siedzą między 2 048 a 4 096 bitami z e = 65537, wykładnikiem 17-bitowym, więc sufit nigdy ich nie dotyka; podłoga to osobna, konfigurowalna polityka, AlgorithmPolicy.MinimumRSAKeyBits, domyślnie 2 048

Ten sam podpis może być cvsTrusted w genTime swojego znacznika czasu i paść przy dzisiejszej dacie. Loguj Info.CertificateValidation.EvaluationTime i TrustAnchorSubjectDN obok każdego werdyktu, który zwraca HotPDF, komponent PDF dla Delphi, a audytor czytający zapis później zobaczy, na którym zegarze i na której kotwicy spoczywa odpowiedź