PDFium VCL prüft die Revocation von PDF-Signaturen unter Windows offline, indem es CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY in den Revocation-Durchlauf von CertGetCertificateChain aufnimmt, denn das Cache-only-Flag, das für den Kettenaufbau benutzt wird, deckt CRL- oder OCSP-Abrufe überhaupt nicht ab. Ein Offline-ValidatePadesTrust-Aufruf bleibt also fern vom Netz, und ein sauberes Ergebnis verlangt echte Revocation-Evidenz pro Zertifikat. Der Rest dieses Beitrags handelt davon, warum jede Hälfte dieses Satzes bewusste Arbeit braucht
Die Falle zeigt sich in einem gewöhnlichen Setup. Ein Validierungsdienst läuft auf einem abgeriegelten Windows-Host, TPadesTrustValidationOptions.NetworkPolicy steht auf ptnpOffline (was zugleich der Default ist), und der Betreiber erwartet, dass jede Antwort aus dem lokalen Zertifikats-Cache kommt. Bauen Sie den Revocation-Durchlauf nur mit dem Chain-Building-Flag, fällt bald jemandem im Firewall-Log ausgehender Traffic zu einem CA-Verteilpunkt auf, oder ein Batch-Job, der bei jeder Signatur für die vollen UrlRetrievalTimeoutMs von 15000 ms stehen bleibt. Nichts im Code hat das Netz angefragt. Windows ist trotzdem dorthin gegangen
Warum holt ein Offline-Kettenaufbau unter Windows trotzdem CRLs?
Weil CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL nur die URL-Abrufe einschränkt, die der Kettenaufbau macht: AIA-Issuer-Fetches, Root- und CTL-Updates. Die Microsoft-Dokumentation zu CertGetCertificateChain sagt ausdrücklich, dass das Flag für die Revocation-Prüfung nicht gilt. Revocation hat ihren eigenen Schalter, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), und ohne ihn dürfen die Revocation-Provider nach Lust und Laune eine CRL laden oder eine OCSP-Anfrage senden, obwohl der umgebende Aufruf offline aussieht. PDFium VCL ODER-t dieses Flag in den Revocation-Durchlauf, wann immer OnlineRetrieval False ist, oben auf die Chain-Flags, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT und CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Das zählt für mehr als Latenz: Eine OCSP-Anfrage verrät dem Responder, welches Zertifikat Sie gerade anschauen – genau das soll ein Air-gapped-Validator vermeiden
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // False per Default
Options.CheckTimeStamps := True;
// Offline heißt auch für Revocation offline: gecachte CRL- und OCSP-
// Antworten, kein pcvstOnlineRetrieval-Checkpoint wird ausgelöst
Report := Pdf.ValidatePadesTrust(Options);
end;
Zwei Kettenaufbauten, zwei Fehlerfelder
Das Windows-Backend baut die Kette zweimal auf, und jeder Aufbau besitzt seinen eigenen Fehler-Slot. Der erste CertGetCertificateChain-Aufruf läuft ohne Revocation-Flags und füttert CertVerifyCertificateChainPolicy mit der Basis-Policy, was TrustStatus und TrustError erzeugt. Der zweite Aufruf fügt die Revocation-Flags hinzu. Würde ein Fehlschlag des zweiten Aufrufs GetLastError in TrustError schreiben, könnte eine Kette, die gerade als vertrauenswürdig verifiziert wurde, als nicht vertrauenswürdig zurückkommen, nur weil ein Revocation-Provider kurz gestolpert ist. PDFium VCL liest GetLastError sofort und legt ihn in TPdfCmsVerifyResult.RevocationError ab, das Urteil des ersten Durchlaufs bleibt unangetastet. Und ein True-Rückgabewert des zweiten Aufrufs gilt auch nicht als Erfolg; er bedeutet nur, dass Windows einen Chain-Kontext zurückgibt, der eine Inspektion wert ist
Was beweist eine Null-Trust-Error-Maske eigentlich?
Für sich genommen: nichts. Ein aggregiertes TrustStatus.dwErrorStatus von null nach dem Revocation-Durchlauf sagt nur, dass kein Error-Bit gesetzt wurde, und eine Kette, in der überhaupt kein Element Revocation-Informationen trug, kann exakt das produzieren. „Kein revoked-Bit, kein unknown-Bit, kein offline-Bit“ direkt auf valid zu mappen ist der klassische Weg, wie ein Validator ein ungeprüftes Zertifikat als sauberes meldet. Die Routine ReadWinRevocationEvidence in PDFium VCL läuft jede Simple Chain und jedes Element ab, weist Strukturen zurück, deren cbSize zu klein ist, um sie sicher zu lesen, und meldet Erfolg erst, wenn mindestens ein Nicht-Root-Element existiert und jedes solche Element eine CERT_REVOCATION_INFO trägt, deren dwRevocationResult null ist
// Kondensiert aus dem Evidenz-Walk: Ein Element zählt nur, wenn ein
// Revocation-Provider tatsächlich für es geantwortet hat
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); // eine reine Wurzelkette beweist nichts
Das Provider-Ergebnis bleibt roh. RevocationError hält den dwRevocationResult-DWORD exakt so, wie der Provider ihn zurückgegeben hat, bevorzugt den Fehler des revoked-Elements, wenn es eines gibt (CRYPT_E_REVOKED ist $80092010), und die Trust-Bitmaske wird niemals als nativer Fehlercode verkleidet. Das Mapping auf TPdfCmsRevocationReason ist bewusst grob: pcrrCertificateRevoked mit pcvsInvalid für explizite Sperrung, pcrrChainUntrusted, wenn die Kette aus Gründen scheitert, die nichts mit Revocation zu tun haben, und pcrrUnknown für alles andere. Windows hat möglicherweise OCSP statt einer CRL probiert, ein Offline- oder Unknown-Ergebnis wird also nicht in pcrrCrlExpired übersetzt. Das OpenSSL-CMS-Verifikations-Backend kann diese CRL-spezifischen Unterscheidungen treffen, weil es nur CRLs auswertet, die man ihm reicht, während das macOS-SecTrust-Backend die Felder auf pcrrNone und null lässt – das heißt „keine detaillierte Diagnose“, nicht „bestanden“
Wo die Root-Ausnahme endet
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT überspringt den Anker zu Recht, denn niemand publiziert eine CRL, die eine Wurzel gegen sich selbst sperrt. Die Falle liegt darin zu entscheiden, welches Element die Wurzel ist. PDFium VCL schließt das letzte Element einer Simple Chain nur aus, wenn sein dwInfoStatus es als self-signed ($00000008) oder ausdrücklich CA-trusted ($00004000) markiert. Ein Offline-Host kann einen fehlenden Issuer oft nicht nachladen, also endet die Kette an einem Intermediate; das letzte Element dieser Teil-Kette als Wurzel zu behandeln würde stillschweigend genau das eine Zertifikat fallen lassen, dessen Revocation-Status am ehesten nicht im Cache liegt. Dieses Element bleibt im erforderlichen Set, hat keine Provider-Antwort, und das Ergebnis bleibt pcvsIndeterminate
Wie werden Signature- und Timestamp-Revocation-Ergebnisse getrennt gehalten?
Als getrennte Felder, die einander nie überschreiben. Der PAdES-Validator verifiziert das detached CMS der Dokumentsignatur und das attached CMS des RFC-3161-Timestamp-Tokens in zwei unabhängigen Aufrufen, und jeder bekommt seine eigene Diagnostik auf TPadesSignatureValidation: RevocationReason und NativeRevocationError für den Unterzeichner, TimeStampRevocationReason und NativeTimeStampRevocationError für die TSA. Ein gesperrtes TSA-Zertifikat kann sich also nicht als gesperrter Unterzeichner ausgeben, und ein Timestamp-Fehlschlag löscht kein Integritätsergebnis aus, das bereits steht. Ist CheckRevocation False oder hat die Validierung diese Stufe nie erreicht, bleiben die Felder auf pcrrNone und 0 – lesen Sie sie deshalb immer neben RevocationStatus und TimeStampRevocationStatus
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;
Der Evidenz-Report folgt derselben Regel. Der CSV-Export hängt revocationReason, nativeRevocationError und die Timestamp-Spalten bis nativeTimeStampRevocationError ans Ende der bestehenden Spaltenreihenfolge an, ältere Parser funktionieren also weiter, und der JSON-Export fügt passende Felder hinzu, ohne die Bedeutung der alten zu ändern. Was der Revocation-Cache eines bestimmten Rechners an einem gegebenen Tag enthält, ist Sache von Windows, und ein leerer Cache liefert „unknown“, statt eine Netzwerkanfrage zu stellen oder ein falsches „valid“ zu melden. Wenn die Offline-Validierung immer wieder indeterminiert zurückkommt, liegt die dauerhafte Lösung stromaufwärts: Sammeln Sie das Validierungsmaterial zur Signaturzeit, wie in langfristige PDF-Signaturen mit RFC-3161-Timestamps und DSS beschrieben, statt zu hoffen, dass der prüfende Rechner einen warmen Cache hat
Die Offline-Revocation-Behandlung, die Diagnostik pro Feld und die Evidenz-Exporte sind Teil der PDF-Signaturvalidierungs-API in PDFium VCL für Delphi und C++Builder, neben den OpenSSL- und macOS-Backends für plattformübergreifende Deployments