Teknisk artikel

PDF-signaturrevokering offline på Windows i Delphi

PDFium VCL kontrollerar PDF-signaturrevokering offline på Windows genom att lägga till CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY i revokeringspasset av CertGetCertificateChain, för flaggan enbart-cache som används vid kedjebygge täcker inte CRL- eller OCSP-hämtning alls. Sedan v3.119.1 håller ett offline-anrop av ValidatePadesTrust sig borta från nätverket, och ett rent resultat kräver faktisk revokeringsbevisning per certifikat. Resten av det här inlägget handlar om varför båda halvorna av den meningen behövde lagas

Uppläggningen som avslöjar problemet är ordinär. En valideringstjänst körs på en låst Windows-värd, TPadesTrustValidationOptions.NetworkPolicy är ptnpOffline (vilket också är standard), och operatören förväntar sig att alla svar kommer från den lokala certifikatcachen. Sedan märker någon utgående förfrågningar till en CA-distributionspunkt i brandväggsloggen, eller ett batchjobb som fastnar hela UrlRetrievalTimeoutMs på 15000 ms för varje signatur. Ingenting i koden bad om nätverket. Windows gick dit ändå

Varför hämtar ett offline-kedjebygge fortfarande CRL:er på Windows?

För att CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL bara begränsar den URL-hämtning som kedjebygget gör: AIA-hämtningar av utfärdare, rot- och CTL-uppdateringar. Microsofts dokumentation för CertGetCertificateChain säger uttryckligen att flaggan inte gäller revokeringskontroll. Revokering har sin egen växel, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), och utan den står revokeringsleverantörerna fritt att ladda ner en CRL eller skicka en OCSP-förfrågan även om det omgivande anropet ser offline ut. PDFium VCL OR:ar nu in den flaggan i revokeringspasset närhelst OnlineRetrieval är False, ovanpå kedjeflaggorna, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT och CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Det spelar roll för mer än latens: en OCSP-förfrågan talar om för svararen vilket certifikat du tittar på, vilket är precis vad en luftgapande validator ska undvika

Två oberoende växlar bevakar offline-validering av PDF-signaturer på Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL begränsar bara kedjebygge som AIA-utfärdare och rothämtningar, medan revokering behöver CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, för utan den laddar leverantörer fortfarande ner CRL:er och skickar OCSP-förfrågningar som avslöjar vilket certifikat som valideras
PDFium VCL OR:ar in revokeringens enbart-cache-flagga i revokeringspasset närhelst OnlineRetrieval är False, så en ptnpOffline-förtroendevalidering håller sig borta från nätverket för båda passen
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False som standard
  Options.CheckTimeStamps := True;
  // Offline betyder nu offline även för revokering: cachade CRL- och OCSP-
  // svar bara, ingen kontrollpunkt pcvstOnlineRetrieval höjs
  Report := Pdf.ValidatePadesTrust(Options);
end;

Två kedjebyggen, två felfält

Windows-backenden bygger kedjan två gånger, och varje bygge äger nu sitt eget felfält. Det första anropet av CertGetCertificateChain körs utan revokeringflaggor och matar CertVerifyCertificateChainPolicy med baspolicyn, vilket producerar TrustStatus och TrustError. Det andra anropet lägger till revokeringflaggorna. Före v3.119.1 skrev ett misslyckande av det andra anropet GetLastError i TrustError, så en kedja som just verifierats som betrodd kunde komma tillbaka och se ut som obetrodd för att en revokeringsleverantör snubblade. Fixen läser GetLastError omedelbart och lagrar den i TPdfCmsVerifyResult.RevocationError och lämnar första-pass-domen ifred. Och ett True-svar från det andra anropet behandlas inte som framgång heller; det betyder bara att Windows lämnade tillbaka ett kedjekontext värt att inspektera

Vad bevisar en noll trust-feltmask egentligen?

I sig själv, ingenting. En aggregerad TrustStatus.dwErrorStatus på noll efter revokeringspasset säger att ingen felbit höjdes, och en kedja där inget element alls bar revokeringsinformation kan producera exakt det. Den tidigare koden mappade "ingen revokerad-bit, ingen okänd-bit, ingen offline-bit" rakt på giltig, vilket är det klassiska sättet en validator rapporterar ett okontrollerat certifikat som ett rent. Den nya rutinen ReadWinRevocationEvidence går igenom varje enkel kedja och varje element, avvisar strukturer vars cbSize är för liten för att läsa säkert, och rapporterar framgång bara när åtminstone ett icke-rotelement finns och vart och ett sådant element bär en CERT_REVOCATION_INFO vars dwRevocationResult är noll

Bevisvandringen ReadWinRevocationEvidence utför på varje Windows-kedjeelement i Delphi: pRevocationInfo måste finnas, cbSize måste vara stort nog att läsa, dwRevocationResult måste vara noll, och slutelementet utesluts bara när det är märkt självsignerat eller CA-betrott, så en noll trust-feltmask kan inte längre dölja ett okontrollerat certifikat
En ren dom kräver åtminstone ett icke-rotelement och ett svar från varje krävbart element, med RevocationError som behåller leverantörens råa DWORD som CRYPT_E_REVOKED
// Komprimerat från bevisvandringen: ett element räknas bara när en
// revokeringsleverantör faktiskt svarade för det
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); // en kedja med bara rot bevisar ingenting

Leverantörsresultatet behålls rått. RevocationError håller dwRevocationResult-DWORD:en exakt som leverantören returnerade den, med företräde för felet från det revokerade elementet när det finns ett (CRYPT_E_REVOKED är $80092010), och förtroendebitmasken kläds aldrig ut som en nativ felkod. Mappningen till TPdfCmsRevocationReason är medvetet grov: pcrrCertificateRevoked med pcvsInvalid för explicit revokering, pcrrChainUntrusted när kedjan faller av skäl orelaterade till revokering, och pcrrUnknown för allt annat. Windows kan ha försökt OCSP snarare än en CRL, så ett offline- eller okänt-resultat översätts inte till pcrrCrlExpired. OpenSSL CMS-verifieringsbackenden kan göra de CRL-specifika åtskillnaderna för den evaluerar bara CRL:er du räcker den, medan macOS SecTrust-backenden lämnar fälten på pcrrNone och noll, vilket betyder "ingen detaljerad diagnos", inte "godkänd"

Där rotuteslutningen stannar

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT hoppar legitimt över ankaret, för ingen publicerar en CRL som revokerar en rot mot sig själv. Fällan är att avgöra vilket element som är roten. PDFium VCL utesluter sista elementet i en enkel kedja bara när dess dwInfoStatus märker det som självsignerat ($00000008) eller uttryckligen CA-betrott ($00004000). En offline-värd kan ofta inte hämta en saknad utfärdare, så kedjan slutar på en mellanliggande; att behandla den partiella kedjans sista element som en rot skulle tyst tappa det enda certifikat vars revokeringsstatus mest sannolikt saknas i cachen. Elementet stannar i den krävda mängden, har inget leverantörssvar, och resultatet stannar på pcvsIndeterminate

Hur hålls signatur- och tidsstämpelrevokeringsresultat isär?

Som separata fält som aldrig skriver över varandra. PAdES-validatoren verifierar den fristående CMS:en av dokumentsignaturen och den bifogade CMS:en av RFC 3161-tidsstämpeltoken i två oberoende anrop, och v3.119.0 gav var och en sina egna diagnoser på TPadesSignatureValidation: RevocationReason och NativeRevocationError för undertecknaren, TimeStampRevocationReason och NativeTimeStampRevocationError för TSA:n. Ett revokerat TSA-certifikat kan därför inte utge sig för att vara en revokerad undertecknare, och ett tidsstämpelfel raderar inte ett integritetsresultat som redan fastställts. När CheckRevocation är False, eller valideringen aldrig nådde det steget, stannar fälten på pcrrNone och 0, så läs dem alltid bredvid RevocationStatus och TimeStampRevocationStatus

Signatur- och tidsstämpelrevokering hålls isär i PDFium VCL: den fristående CMS:en av dokumentsignaturen fyller RevocationReason och NativeRevocationError, den bifogade CMS:en av RFC 3161-token fyller TimeStampRevocationReason och NativeTimeStampRevocationError, och okontrollerade steg lämnar pcrrNone och noll bredvid sina statusfält
Ett revokerat TSA-certifikat kan därför inte utge sig för att vara en revokerad undertecknare, och ett tidsstämpelfel raderar aldrig ett integritetsresultat som signaturverifieringen redan fastställt
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;

Bevisrapporten följer samma regel. CSV-exporten lägger till revocationReason, nativeRevocationError och tidsstämpelkolumnerna genom nativeTimeStampRevocationError i slutet av den befintliga kolumnordningen, så äldre parser fortsätter fungera, och JSON-exporten lägger till matchande fält utan att ändra vad de gamla betyder. Kommer offline-valideringen att fortsätta återkomma som obestämd är den hållbara fixen uppströms: samla in valideringsmaterialet vid signeringstillfället, som beskrivs i långtids-PDF-signaturer med RFC 3161-tidsstämplar och DSS, i stället för att hoppas att den verifierande maskinen har en varm cache

Vad testmatrisen bevisar och inte bevisar

Windows-verifieringsmatrisen klarade 30 kontrollerade kedje-API-scenarier och en riktig offline-CMS-rök på vart och ett av Delphi- och FPC-målen Win32 och Win64. Den riktiga röken verifierar en giltig signatur under en obetrodd privat CA, medan rena och uttryckligen revokerade utfall kommer från stubbade CertGetCertificateChain-svar i stället för från installerade förtroendeankare eller live-hämtning. Det är en ärlig gräns värd att nämna: flagghanteringen, felisoleringen och bevisvandringen är nålade fast, men vad en bestämd maskins revokeringscache innehåller en bestämd dag är fortfarande Windows affär, och en tom cache producerar nu korrekt "okänd" i stället för en nätverksförfrågan eller ett falskt "giltigt"

Den offline revokeringshanteringen, per fält-diagnostiken och bevisexporterna är del av PDF-signaturvaliderings-API:et i PDFium VCL för Delphi och C++Builder, vid sidan av OpenSSL- och macOS-backenderna för plattformsoberoende utrullningar