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. Seit v3.119.1 bleibt ein Offline-ValidatePadesTrust-Aufruf wirklich vom Netz fern, und ein sauberes Ergebnis verlangt echte Revocation-Evidenz pro Zertifikat. Der Rest dieses Beitrags handelt davon, warum beide Hälften dieses Satzes eine Reparatur brauchten
Das Setup, das das Problem offenlegt, ist gewöhnlich. 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. Dann fällt 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 jetzt 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 jetzt auch für Revocation offline: nur gecachte CRL- und
// OCSP-Antworten, kein pcvstOnlineRetrieval-Checkpoint wird geworfen
Report := Pdf.ValidatePadesTrust(Options);
end;
Zwei Kettenaufbauten, zwei Fehlerfelder
Das Windows-Backend baut die Kette zweimal auf, und jeder Aufbau hat jetzt 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. Vor v3.119.1 schrieb ein Fehlschlag dieses zweiten Aufrufs GetLastError in TrustError, sodass eine Kette, die gerade als vertrauenswürdig verifiziert worden war, als nicht vertrauenswürdig zurückkommen konnte, weil ein Revocation-Provider gestolpert war. Der Fix 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. Der frühere Code mappte „kein revoked-Bit, kein unknown-Bit, kein offline-Bit“ geradewegs auf valid – der klassische Weg, wie ein Validator ein ungeprüftes Zertifikat als sauberes meldet. Die neue Routine ReadWinRevocationEvidence 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 v3.119.0 hat jedem seine eigene Diagnostik auf TPadesSignatureValidation gegeben: 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. 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
Was die Testmatrix beweist und was nicht
Die Windows-Verifikationsmatrix hat 30 kontrollierte Chain-API-Szenarien und einen echten Offline-CMS-Smoke auf jedem Delphi- und FPC-Win32- und Win64-Ziel bestanden. Der echte Smoke verifiziert eine gültige Signatur unter einer nicht vertrauenswürdigen privaten CA, während saubere und explizit gesperrte Ausgänge aus gestubbten CertGetCertificateChain-Antworten kommen statt aus installierten Trust Anchors oder Live-Abrufen. Das ist eine ehrliche Grenze, die man aussprechen sollte: Flag-Behandlung, Fehler-Isolation und Evidenz-Walk sind festgenagelt, aber was der Revocation-Cache einer bestimmten Maschine an einem bestimmten Tag enthält, bleibt weiterhin Windows' Sache, und ein leerer Cache produziert jetzt korrekt „unknown“ statt einer Netzwerkanfrage oder einem falschen „valid“
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