Tekninen artikkeli

Offline PDF-mitätöintitarkistukset Windowsissa Delphissä

PDFium VCL tarkistaa PDF-allekirjoitusten mitätöinnin offline-tilassa Windowsissa lisäämällä CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLYin CertGetCertificateChainin mitätöintivaiheeseen, koska ketjun rakentamiseen käytetty vain välimuisti -lippu ei kata CRL- tai OCSP-hakuja lainkaan. Versiosta v3.119.1 lähtien offline-ValidatePadesTrust-kutsu pysyy verkoston ulkopuolella, ja puhdas tulos vaatii todellisen sertifikattikohtaisen mitätöintitodisteen. Loput tästä kirjoituksesta käsittelevät miksi molemmat lauseen puoliskot tarvitsivat korjausta

Asetus joka paljastaa ongelman on tavallinen. Validointipalvelu ajaa lukitussa Windows-isännässä, TPadesTrustValidationOptions.NetworkPolicy on ptnpOffline (mikä on myös oletus), ja operaattori odottaa jokaisen vastauksen tulevan paikallisesta sertifikaattivälimuistista. Sitten joku huomaa palomuurilokista lähtevät pyynnöt CA:n jakelupisteeseen, tai eräajon joka jumittuu koko UrlRetrievalTimeoutMsin 15000 ms:n ajoksi jokaisella allekirjoituksella. Mikään koodissa ei pyytänyt verkostoa. Windows meni sinne kuitenkin

Miksi offline-ketjun rakennus hakee yhä CRL:eitä Windowsissa?

Koska CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL rajoittaa vain URL-hakuja joita ketjun rakennus tekee: AIA-myöntäjähaut, juuri- ja CTL-päivitykset. Microsoftin dokumentaatio CertGetCertificateChainille sanoo eksplisiittisesti että lippu ei koske mitätöintitarkistusta. Mitätöinnillä on oma kytkimensä, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), ja ilman sitä mitätöintipalvelut voivat vapaasti ladata CRL:n tai lähettää OCSP-pyynnön vaikka ympäröivä kutsu näyttää offline-tilalta. PDFium VCL OR:aa kyseisen lipun mitätöintivaiheeseen aina kun OnlineRetrieval on False, ketjulippujen, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOTin ja CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUTin päälle. Sillä on enemmän väliä kuin viiveellä: OCSP-pyyntö kertoo vastaajalle mitä sertifikaattia katsot, mikä on juuri se mitä ilmatiivistetyn validoiijan kuuluu välttää

Kaksi riippumatonta kytkintä vartioi offline-PDF-allekirjoitusten validointia Windowsissa: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL rajoittaa vain ketjun rakentamista kuten AIA-myöntäjä- ja juurihaut, kun taas mitätöinti tarvitsee CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLYn, koska ilman sitä palvelut lataavat yhä CRL:eitä ja lähettävät OCSP-pyyntöjä jotka paljastavat mitä sertifikaattia validoidaan
PDFium VCL OR:aa mitätöinnin vain välimuisti -lipun mitätöintivaiheeseen aina kun OnlineRetrieval on False, joten ptnpOffline-luottamusvalidointi pysyy verkoston ulkopuolella molemmissa vaiheissa
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False oletuksena
  Options.CheckTimeStamps := True;
  // Offline tarkoittaa nyt offlinea myös mitätöinnille: vain välimuistissa olevat CRL- ja OCSP-
  // vastaukset, pcvstOnlineRetrieval-tarkistuspistettä ei nosteta
  Report := Pdf.ValidatePadesTrust(Options);
end;

Kaksi ketjurakennusta, kaksi virhekenttää

Windows-backend rakentaa ketjun kahdesti, ja kummallakin rakennuksella on nyt oma virhepaikkansa. Ensimmäinen CertGetCertificateChain-kutsu ajetaan ilman mitätöintilippuja ja syöttää CertVerifyCertificateChainPolicylle peruskäytännön, mikä tuottaa TrustStatusin ja TrustErrorin. Toinen kutsu lisää mitätöintiliput. Ennen v3.119.1 kyseisen toisen kutsun epäonnistuminen kirjoitti GetLastErrorin TrustErroriin, joten ketju joka oli juuri verifioitu luotettavaksi saattoi palata näyttäen luottamattomalta koska mitätöintipalvelu takelteli. Korjaus lukee GetLastErrorin heti ja tallentaa sen TPdfCmsVerifyResult.RevocationErroriin jättäen ensimmäisen kierroksen tuomion rauhaan. Ja toisen kutsun True-paluu ei myöskään tulkita onnistumiseksi; se tarkoittaa vain että Windows luovutti takaisin ketjukontekstin joka on tutkimisen arvoinen

Mitä nollan luottamusvirheen maski oikeasti todistaa?

Itsellään, ei mitään. Koostettu TrustStatus.dwErrorStatus nolla mitätöintivaiheen jälkeen sanoo ettei virhebittiä nostettu, ja ketju jossa yksikään elementti ei kantanut mitätöintitietoa lainkaan voi tuottaa täsmälleen sen. Aiempi koodi kuvasi ”ei kumottu-bittiä, ei tuntematon-bittiä, ei offline-bittiä” suoraan kelvolliseksi, mikä on klassinen tapa jolla validoija raportoi tarkistamattoman sertifikaatin puhtaana. Uusi ReadWinRevocationEvidence-rutiini kulkee jokaisen yksinkertaisen ketjun ja jokaisen elementin läpi, hylkää rakenteet joiden cbSize on liian pieni luettavaksi turvallisesti, ja raportoi onnistumisen vain kun vähintään yksi ei-juurielementti on olemassa ja jokainen tällainen elementti kantaa CERT_REVOCATION_INFOa jonka dwRevocationResult on nolla

Todistekierros jonka ReadWinRevocationEvidence suorittaa jokaiselle Windows-ketjun elementille Delphissä: pRevocationInfon on oltava läsnä, cbSizen on oltava riittävän suuri luettavaksi, dwRevocationResultin on oltava nolla, ja pääteelementti suljetaan pois vain kun se on merkitty itse allekirjoitetuksi tai CA-luotetuksi, joten nollan luottamusvirheen maski ei voi enää piilottaa tarkistamatonta sertifikaattia
Puhdas tuomio vaatii vähintään yhden ei-juurielementin ja vastauksen jokaiselta vaaditulta elementiltä, RevocationErrorin pitäessä palvelun raakan DWORDin kuten CRYPT_E_REVOKEDin
// Tiivistetty todistekierroksesta: elementti lasketaan vain kun
// mitätöintipalvelu oikeasti vastasi sen puolesta
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); // pelkkä juuriketju ei todista mitään

Palvelun tulos säilytetään raakana. RevocationError pitää dwRevocationResult-DWORDin täsmälleen niin kuin palvelu sen palautti, suosien kumotun elementin virhettä jos sellainen on (CRYPT_E_REVOKED on $80092010), eikä luottamusbitimaskia koskaan puettaisi natiiviksi virhekoodiksi. Kuvaus arvoon TPdfCmsRevocationReason on tahallaan karkea: pcrrCertificateRevoked yhdessä pcvsInvalidin kanssa eksplisiittisestä mitätöinnistä, pcrrChainUntrusted kun ketju epäonnistui syistä jotka eivät liity mitätöintiin, ja pcrrUnknown kaikelle muulle. Windows on saattanut kokeilla OCSP:tä CRL:n sijaan, joten offline- tai tuntematon-tulosta ei käännetä arvoksi pcrrCrlExpired. OpenSSL CMS -verifiointibackend voi tehdä kyseiset CRL-kohtaiset erottelut koska se evaluoi vain CRL:eitä jotka sille annetaan, kun taas macOS SecTrust -backend jättää kentät arvoihin pcrrNone ja nolla, mikä tarkoittaa ”ei yksityiskohtaista diagnostiikkaa”, ei ”läpäisi”

Missä juuren poissulkeminen pysähtyy

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT ohittaa ankkurin laillisesti, koska kukaan ei julkaise CRL:tä joka kumoaisi juuren sitä itseään vastaan. Ansa on päättää mikä elementti on juuri. PDFium VCL sulkee yksinkertaisen ketjun viimeisen elementin pois vain kun sen dwInfoStatus merkitsee sen itse allekirjoitetuksi ($00000008) tai eksplisiittisesti CA-luotetuksi ($00004000). Offline-isäntä ei usein voi hakea puuttuvaa myöntäjää, joten ketju päättyy väliedustajaan; kyseisen osittaisen ketjun viimeisen elementin käsitteleminen juurena pudottaisi hiljaa sen yhden sertifikaatin jonka mitätöintitila on todennäköisimmin poissa välimuistista. Kyseinen elementti jää vaadittuun joukkoon, sillä ei ole palvelun vastausta, ja tulos pysyy arvossa pcvsIndeterminate

Miten allekirjoituksen ja aikaleiman mitätöintitulokset pidetään erillään?

Erillisinä kenttinä jotka eivät koskaan ylikirjoita toisiaan. PAdES-validoija verifioi asiakirja-allekirjoituksen erillisen CMS:n ja RFC 3161 -aikaleimatokenin liitetyn CMS:n kahdessa riippumattomassa kutsussa, ja v3.119.0 antoi kummallekin omat diagnostiikkansa TPadesSignatureValidationissa: RevocationReason ja NativeRevocationError allekirjoittajalle, TimeStampRevocationReason ja NativeTimeStampRevocationError TSA:lle. Kumottu TSA-sertifikaatti ei siksi voi esiintyä kumottuna allekirjoittajana, eikä aikaleiman vika pyyhi pois eheystulosta joka on jo vakiintunut. Kun CheckRevocation on False, tai validointi ei koskaan ylettynyt kyseiseen vaiheeseen, kentät pysyvät arvoissa pcrrNone ja 0, joten lue ne aina RevocationStatusin ja TimeStampRevocationStatusin vierestä

Allekirjoituksen ja aikaleiman mitätöinti pysyvät erillään PDFium VCL:ssä: asiakirja-allekirjoituksen erillinen CMS täyttää RevocationReasonin ja NativeRevocationErrorin, RFC 3161 -tokenin liitetty CMS täyttää TimeStampRevocationReasonin ja NativeTimeStampRevocationErrorin, ja tarkistamattomat vaiheet jättävät pcrrNonen ja nollan tilakenttiensä viereen
Kumottu TSA-sertifikaatti ei siksi voi esiintyä kumottuna allekirjoittajana, eikä aikaleiman vika koskaan pyyhi eheystulosta jonka allekirjoituksen verifiointi on jo vakiinnuttanut
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;

Todisteraportti noudattaa samaa sääntöä. CSV-export liittää revocationReasonin, nativeRevocationErrorin ja aikaleimasarakkeet aina nativeTimeStampRevocationErroriin asti olemassa olevan sarakejärjestyksen loppuun, joten vanhemmat jäsennykset jatkavat toimintaansa, ja JSON-export lisää vastaavat kentät muuttamatta mitä vanhemmat tarkoittavat. Jos offline-validointi palaa yhä epämääräisenä, kestävä korjaus on ylävirrassa: kokoa validointimateriaali allekirjoitushetkellä, kuten artikkelissa pitkäaikaiset PDF-allekirjoitukset RFC 3161 -aikaleimoin ja DSS:llä kuvataan, sen sijaan että toivoisit verifioivan koneen omavan lämmintä välimuistia

Mitä testimatriisi todistaa ja mitä ei

Windows-verifiointimatriisi läpäisi 30 kontrolloitua ketju-API-skenaariota ja yhden aidon offline-CMS-smokein jokaisella Delphi- ja FPC-Win32- ja Win64-kohteella. Aito smoke verifioi kelvollisen allekirjoituksen luottamattoman yksityisen CA:n alla, kun taas puhtaat ja eksplisiittisesti kumotut lopputulokset tulevat tukituista CertGetCertificateChain-vastauksista asennettujen luottamusankkurien tai elävien hakujen sijaan. Se on rehellinen raja jonka kannattaa sanoa ääneen: lipunkäsittely, virheiden eristäminen ja todistekierros on naulattu kiinni, mutta se mitä tietyn koneen mitätöintivälimuisti sisältää tiettynä päivänä on yhä Windowsin asia, ja tyhjä välimuisti tuottaa nyt oikein arvon ”tuntematon” verkkopyynnön tai vale ”kelvollinen” -tuloksen sijaan

Offline-mitätöinnin käsittely, kenttäkohtainen diagnostiikka ja todisteexportit kuuluvat PDF-allekirjoitusten validointi-API:hin PDFium VCL for Delphi and C++Builder -komponentissa OpenSSL- ja macOS-backendien rinnalla monialustaisia käyttöönottoja varten