Technischer Artikel

Was RFC 5280 vom Pfad-Validator einer PDF-Signatur verlangt

Eine signierte Rechnung verifiziert byteweise, ihr Signierer wurde letzten Monat widerrufen, und die CRL, die Sie geholt haben, listet die Seriennummer nie: Sie spricht für eine andere Partition. Pfadvalidierung in HotPDF ist für solche Fälle entworfen: RFC 5280 Scope- und Anchor-Regeln, eine UTC-Bewertungsuhr und RFC 3161 Time Stamps, deren Zeit erst zählt, wenn die TSA-Kette vertrauenswürdig ist

Die Bewertungsuhr läuft auf UTC

Jede Zeit, die ein Pfad-Validator vergleicht, ist in UTC gespeichert. RFC 5280 §4.1.2.5 verlangt, dass Gültigkeitsdaten von Zertifikaten als UTCTime oder GeneralizedTime in Greenwich Mean Time kodiert werden, CRL-thisUpdate und nextUpdate benutzen dieselben Kodierungen, und RFC 6960 OCSP-Responses tragen producedAt, thisUpdate und nextUpdate als GeneralizedTime in UTC. Delphis TDateTime hat keine Zeitzone, und Now liefert die lokale Wanduhrzeit, also mischt eine Prüfung wie if Now > NotAfter zwei Uhren, ohne Compiler-Warnung. Der Fehler verteilt sich nach Geographie. Westlich von Greenwich läuft er zu früh. Auf einem New Yorker Server um 09:00 lokal, wenn die Weltuhr 14:00Z zeigt, liegt eine OCSP-Response von 13:59Z fünf Stunden in der Zukunft der naiven Uhr, weit außerhalb einer Fünf-Minuten-Skew-Toleranz wie HotPDFs Default OCSPAllowedClockSkewMinutes, und wird als noch nicht gültig abgelehnt. In Shanghai kippt das Vorzeichen: Um 22:00 lokal, was 14:00Z ist, sieht ein Signaturzertifikat, dessen notAfter 20:00Z ist, bereits abgelaufen aus, acht Stunden zu früh. Keiner der Fehler zeigt sich auf einem auf UTC gestellten Build-Agenten — dem Default auf den meisten gehosteten CI-Runnern

HotPDF VerifyLoadedSignatureWithTrust in Delphi bewertet THPDFCertificateValidationOptions.ValidationTime in UTC und vermeidet die New-York- und Shanghai-Uhrfehler, die ein lokales Now gegenüber OCSP-producedAt- und Zertifikat-notAfter-Zeiten verursacht
Vergleicht man ein lokales Delphi-Now mit UTC-Gültigkeitszeiten, werden frische OCSP-Responses westlich von Greenwich abgelehnt und Zertifikate östlich zu früh für abgelaufen erklärt, zwei Fehler, die ein UTC-Build-Agent nie zeigt

HotPDF behandelt THPDFCertificateValidationOptions.ValidationTime als UTC. Auf dem Default null gelassen, wird die Bewertungszeit zur aktuellen UTC-Zeit, sofern nicht ein vertrauenswürdiger Timestamp die Signierzeit liefert (dazu gleich mehr), und der tatsächlich benutzte Zeitpunkt kommt in Info.CertificateValidation.EvaluationTime zurück. VerifyLoadedSignatureWithTrust führt die Byte-Range-Verifikation hinter svValid aus, bevor irgendetwas davon läuft, und baut dann den Pfad. Pinnen Sie selbst einen historischen Moment fest, etwa für einen Prüfer, der wissen will, ob eine Rechnungssignatur zum Quartalsende gültig war, ist die Umrechnung Ihre Aufgabe. TTimeZone.Local.ToUniversalTime aus System.DateUtils macht das. Wenden Sie es dort an, wo der Timestamp in Ihr System kommt, nicht beim Validierungsaufruf: In Zonen mit Sommerzeit wiederholt sich eine lokale Stunde jeden Herbst, und eine andere existiert im Frühling nie, also kann eine Datenbankspalte lokaler Timestamps nicht jeden Zeitpunkt unverändert hin- und zurückrechnen

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;
    // Quartalsende, wie das ERP es aufgezeichnet hat, in der lokalen Zone des Servers
    ClosingLocal := EncodeDateTime(2026, 3, 31, 23, 59, 59, 0);
    // Zertifikate, CRLs und OCSP-Responses tragen alle UTC-Zeiten
    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;

Welche Zertifikate deckt eine CRL wirklich ab?

Eine CRL spricht nur für die Zertifikate innerhalb ihres Scope, und der Scope wird in der Issuing-Distribution-Point-Erweiterung aus RFC 5280 §5.2.5 deklariert. Eine CA, die ihre Widerrufsdaten partitioniert, veröffentlicht mehrere CRLs, alle mit demselben Key signiert und alle frisch, und die Abwesenheit eines Zertifikats aus einer von ihnen bedeutet nichts, solange diese CRL nicht behauptet, es abzudecken. Die Felder, die den Scope einschränken, sind diese:

  • distributionPoint: die CRL deckt nur Zertifikate ab, deren CRL-Distribution-Points-Erweiterung diesen Punkt nennt
  • onlyContainsUserCerts und onlyContainsCACerts: nur End-Entity-Zertifikate, oder nur CA-Zertifikate
  • onlySomeReasons: Widerrufe für eine Untermenge von Reason Codes, etwa keyCompromise
  • onlyContainsAttributeCerts: die CRL betrifft Attribute-Zertifikate und sagt nichts über Public-Key-Zertifikate
  • indirectCRL: Einträge können zu Issuern gehören, die nicht der CRL-Signer ist

Der naive Lookup verifiziert die CRL-Signatur, prüft thisUpdate und nextUpdate, sucht die Seriennummer und ruft das Zertifikat gut, wenn die Nummer fehlt. Dieser letzte Schritt gilt erst nach dem Scope-Test aus RFC 5280 §6.3.3 (b)(2). Die CRL für Partition 7 listet nie ein Zertifikat aus Partition 3. Eine CRL, auf CA-Zertifikate beschränkt, listet nie einen End-Entity-Signierer. Beide antworten "not found" für ein widerrufenes Zertifikat. HotPDF zieht eine Base CRL für ein Zertifikat erst heran, wenn die CRL weder auf Attribute-Zertifikate noch auf einige Reasons beschränkt ist, ihre User- oder CA-Einschränkung zum Zertifikat passt, und jeder Distribution Point, den sie nennt, zu einer der URIs in der eigenen CRL-Distribution-Points-Erweiterung des Zertifikats passt, verglichen ohne Case-Sensitivity — ein Punkt, der als Name relativ zum CRL-Issuer angegeben ist, passt zu nichts. Eine auf Reasons beschränkte CRL wird überhaupt nicht konsultiert, also zählen auch die Widerrufe nicht, die sie listet. §6.3.3 erlaubt durchaus, volle Abdeckung aus Reason-Partitionen zusammenzusetzen, mit einer Reasons-Maske, die man führt, bis jeder Reason abgearbeitet ist, aber für Dokumentensignaturen ist eine OCSP-Response oder eine CRL mit vollem Scope die einfachere Evidenz: Sammeln Sie eine davon. Bleibt keine Evidence im Scope übrig, ist der Widerrufsstatus unbekannt, und unter rvmRequire gibt der Aufruf cvsRevocationUnknown zurück

HotPDF CRL-Scope-Test für die Widerrufsprüfung von PDF-Signaturen in Delphi: Issuing-Distribution-Point-Felder entscheiden, ob eine Base CRL konsultiert wird, und ohne Evidence im Scope gibt rvmRequire nach RFC 5280 cvsRevocationUnknown zurück
Bevor eine CRL nach der Seriennummer durchsucht wird, muss ihr Issuing Distribution Point das Zertifikat abdecken; sonst beweist ein fehlender Eintrag nichts über den Widerruf

Trust Anchors, die einen Pfad abschließen, müssen CAs sein

RFC 5280 §6.1.1 reicht dem Pfadvalidierungs-Algorithmus seinen Trust Anchor als schlichte Information weiter, einen Issuer-Namen und einen Public Key, und der Algorithmus fragt nie, ob das eigene Zertifikat des Anchors überhaupt etwas ausstellen darf; der basicConstraints-Test in §6.1.4 läuft auf den Intermediate-Zertifikaten des Pfads. Das ist in Ordnung, solange die Anchors Root-CAs aus einem kuratierten Store sind. Dokumenten-Workflows brechen die Annahme, weil Menschen End-Entity-Zertifikate pinnen: Ein Beschaffungssystem, das Verträge von drei bekannten Lieferanten erhält, vertraut oft direkt dem Signaturzertifikat jedes Lieferanten. Pinnen funktioniert für einen Pfad mit einem Zertifikat, wo Signierer und Anchor dasselbe Zertifikat sind. Die Gefahr ist ein Pfad mit zwei Zertifikaten. Der Private Key eines Lieferanten kann ein frisches Zertifikat mit jedem beliebigen Subject signieren, etwa dem Namen Ihres CFO, und ein Validator, der jeden Anchor-Key als Issuer-Key behandelt, baut von dieser Fälschung bis zum gepinnten Blatt einen sauberen Pfad

HotPDF wendet die RFC-5280-Issuer-Anforderungen auf den Anchor an, sobald der Pfad länger als ein Zertifikat ist. Der Anchor braucht dann basicConstraints mit cA=TRUE (§4.2.1.9), und falls er eine keyUsage-Erweiterung trägt, muss das keyCertSign-Bit gesetzt sein (§4.2.1.3). Ein Root ohne basicConstraints-Erweiterung zählt nur dann als Issuer, wenn er self-issued ist, mit gleichem Subject- und Issuer-Namen, was die alten Version-1-Roots, die älter sind als Extensions, weiterlaufen lässt. Das Pinnen funktioniert weiter. Ein gepinntes End-Entity-Zertifikat validiert weiterhin seine eigenen Signaturen, während ein Zertifikat, das es signiert hat, mit cvsPathValidationFailed zurückkommt, mit einem Issue-String, der besagt, dass der Anchor, der den Pfad ausgestellt hat, keine Certificate Authority ist

HotPDF RFC-5280-Zertifikatspfadvalidierung in Delphi: ein gepinntes End-Entity-Zertifikat in TrustAnchors validiert seine eigenen Signaturen, aber ein Pfad, den es ausgestellt hat, scheitert mit cvsPathValidationFailed ohne basicConstraints cA=TRUE und keyCertSign
Das Pinnen des Signaturzertifikats eines Lieferanten funktioniert weiterhin für dessen Signaturen, während jedes Zertifikat, das sein Key signiert, abgelehnt wird, weil der Anchor keine Certificate Authority ist

Welche Time Stamps dürfen die Bewertungszeit setzen?

Nur vertrauenswürdige. Ein RFC 3161 Token, im CMS eingebettet, behauptet, dass der Signaturwert zu genTime existierte, und Validator beurteilen das Signiererzertifikat zu diesem Zeitpunkt — so überlebt eine Signatur ihr Zertifikat. Der Token ist aber auch nur eine weitere CMS-Signatur. Ihn gegen das Zertifikat zu verifizieren, das er trägt, beweist interne Konsistenz und nichts darüber, wer ihn erzeugt hat: Jeder, der einen abgelaufenen oder widerrufenen Signier-Key hält, kann seinen eigenen Time-Stamping-Service betreiben, eine neue Signatur mit dem genTime des Vorjahers stempeln und sie zurück in die Gültigkeitsperiode des Zertifikats schieben. RFC 3161 §2.3 verlangt, dass das TSA-Zertifikat die Extended Key Usage id-kp-timeStamping als einzigen Zweck trägt, als critical markiert. HotPDF übernimmt Info.Timestamp.GenerationTime erst dann als Bewertungszeit, wenn das Token-Zertifikat gegen Ihre Anchors, zur aktuellen UTC-Zeit, für den Time-Stamping-Zweck validiert. Ein Document Timestamp, /SubFilter ETSI.RFC3161, ist ein anderer Fall. Dort ist der Token die Signatur unter Validierung, und sein genTime gilt für seinen eigenen Pfad

Weil der Token mit Ihren eigenen Optionen geprüft wird, muss das TSA-Root in derselben TrustAnchors-Liste sitzen wie das Signierer-Root, und unter rvmRequire braucht die TSA-Kette eigene Widerrufs-Evidence. Fehlt eines von beiden, fällt die Bewertung auf die aktuelle Zeit zurück. Die Lebensdauer des TSA-Zertifikats ist das andere Limit: Der Token wird zur aktuellen Zeit beurteilt, also fällt die Signierzeit-Bewertung mit ihm weg, sobald das Zertifikat abläuft, und Archive fügen vorher einen Document Timestamp hinzu, der Schritt, der unter PAdES-LTV-Evidence und B-LTA-Timestamp-Erneuerung behandelt wird. Code, der rohe CMS-Bytes hält, kann dieselbe Frage über HPDFCMSTimestampTimeIsTrusted(CMSDER, Options) stellen, und ValidateLoadedSignatureBatch wendet sie auf jede Signatur in einem Batch an. Die Funktion unten meldet, auf welcher Uhr ein Urteil gefällt wurde

// Erwartet: mit nur SignerRoot wird eine Signatur, deren Zertifikat 2025
// abgelaufen ist, zur aktuellen UTC-Zeit bewertet und kommt nicht als
// cvsTrusted zurück, obwohl ihr Timestamp-Token verifiziert; mit TsaRoot
// wird derselbe Aufruf zu genTime bewertet
function DescribeVerdictClock(Pdf: THotPDF; SigIndex: Integer;
  const SignerRoot, TsaRoot: TBytes): string;
var
  Options: THPDFCertificateValidationOptions;
  Info: THPDFSignatureInfo;
  Status: THPDFCertificateValidationStatus;
  Clock: string;
begin
  Options := THPDFCertificateValidationOptions.Default;
  // Die TSA-Kette wird gegen dieselbe Anchor-Liste beurteilt wie der Signierer
  SetLength(Options.TrustAnchors, 2);
  Options.TrustAnchors[0] := SignerRoot;
  Options.TrustAnchors[1] := TsaRoot;
  Options.RevocationMode := rvmBestEffort;
  Options.ValidationTime := 0; // ein vertrauenswürdiger Timestamp darf die Zeit liefern
  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;

Feindlicher Input vor der ersten Trust-Entscheidung

Ein Teil der Validierungsarbeit läuft auf Daten, die ein Angreifer vollständig kontrolliert, bevor irgendetwas vertrauenswürdig ist. Fehlende Intermediates werden über die Authority-Information-Access-caIssuers-URLs aus RFC 5280 §4.2.2.1 geholt, und jedes geholte Intermediate kann seinerseits die URL seines eigenen Issuers anpreisen, also wächst die Arbeitsliste, während man sie abläuft. In Delphi scheidet damit eine for-Schleife aus. Der Endwert eines for-Statements wird einmal ausgewertet, bevor die Schleife startet — während des Walks angehängte URLs werden nie besucht, und eine Kette, der zwei Ebenen fehlen, bleibt unvollständig. Lesen Sie Count stattdessen bei jedem Durchlauf neu, mit einer while-Schleife. In HotPDF begrenzt MaxFetch bei HPDFFetchAIAIntermediates, wie viele Zertifikate ein Walk herunterlädt, und MaxFetchedIntermediates setzt dieselbe Grenze, wenn FetchMissingIntermediates die Pfadvalidierung selbst holen lässt

Der Retry-After-Hinweis ist ein weiterer Wert, den ein Fremder wählt. Sein Server wurde von einem unverifizierten Zertifikat benannt, und er kann bei jeder Anfrage um eine Stunde Pause bitten. Begrenzen Sie ihn. THPDFSignatureEvidenceRetryPolicy.MaxRetryAfterMS setzt diese Obergrenze für HotPDFs Evidence- und AIA-Fetches, per Default 30.000 ms. Die Routine unten wendet beide Regeln an für Teams, die AIA selbst crawlen, etwa um den Intermediate-Cache einer Offline-Signier-Stufe vorzuwärmen

uses
  System.SysUtils, System.Generics.Collections;

type
  // Ihr Transport: lädt ein caIssuers-Ziel herunter und liefert die
  // caIssuers-URLs, die das empfangene Zertifikat anpreist
  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;
    // Eine for-Schleife würde Queue.Count beim Eintritt festschreiben und
    // nie die URLs erreichen, die geholte Zertifikate unten anhängen
    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;
        // Die Verzögerung ist der Wunsch des Servers, und ein unverifiziertes
        // Zertifikat hat den Server gewählt
        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;

Die CMS-Signaturprüfung selbst läuft vor der Pfadvalidierung, denn für eine Signatur, die nicht verifiziert, einen Pfad zu bauen, ist verschwendete Arbeit, und sie benutzt den Public Key des in der Signatur eingebetteten Zertifikats. Diese Reihenfolge setzt eine vom Angreifer gewählte Key-Größe an die Schalthebel, in dem Moment, in dem die teuerste Arithmetik läuft. Noch ist nichts vertrauenswürdig. Die Kosten der RSA-Verifikation wachsen mit Modulus-Länge und Exponenten-Länge, also kann ein Zertifikat mit enormem Modulus und einem ebenso langen Exponenten auf einem Server, der hochgeladene Dateien verifiziert, einen Thread in modularer Exponentiation beschäftigen. HotPDF verweigert einen RSA-Modulus über 16,384 Bits, verweigert einen Exponenten, der länger als der Modulus ist, und akzeptiert über 3,072 Bits nur Exponenten von höchstens 64 Bits. Echte Signier-Keys sitzen zwischen 2,048 und 4,096 Bits mit e = 65537, einem 17-Bit-Exponenten, also berührt die Obergrenze sie nie; die Untergrenze ist eine separate, konfigurierbare Policy, AlgorithmPolicy.MinimumRSAKeyBits, deren Default 2,048 ist

Dasselbe Zertifikat kann zu genTime seines Timestamps cvsTrusted sein und am heutigen Datum scheitern. Loggen Sie Info.CertificateValidation.EvaluationTime und TrustAnchorSubjectDN neben jedem Urteil, das die HotPDF Delphi PDF-Komponente zurückgibt, und ein Prüfer, der den Record später liest, sieht, auf welcher Uhr und auf welchem Anchor die Antwort ruht