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 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 nenntonlyContainsUserCertsundonlyContainsCACerts: nur End-Entity-Zertifikate, oder nur CA-ZertifikateonlySomeReasons: Widerrufe für eine Untermenge von Reason Codes, etwa keyCompromiseonlyContainsAttributeCerts: die CRL betrifft Attribute-Zertifikate und sagt nichts über Public-Key-ZertifikateindirectCRL: 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
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
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