Tehnički članak

Offline provjere opoziva PDF potpisa na Windowsu u Delphiju

PDFium VCL provjerava opoziv PDF potpisa offline na Windowsu dodavanjem CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY revocation prolazu CertGetCertificateChain, jer cache-only zastavica korištena za gradnju lanca uopće ne pokriva CRL ili OCSP dohvat. Od v3.119.1 offline ValidatePadesTrust poziv ostaje izvan mreže, a čist rezultat traži stvarne dokaze opoziva po certifikatu. Ostatak ovog posta govori o tome zašto su obje polovice te rečenice trebale popravak

Postavka koja izvlači problem na vidjelo svakidašnja je. Servis validacije radi na zaključanom Windows hostu, TPadesTrustValidationOptions.NetworkPolicy je ptnpOffline (što je ujedno i default), i operater očekuje da svaki odgovor dođe iz lokalnog certifikatnog keša. Onda netko primijeti odlazne zahtjeve prema CA distribution pointu u logu vatrozida, ili batch posao koji staje punih UrlRetrievalTimeoutMs od 15000 ms na svakom potpisu. Ništa u kodu nije tražilo mrežu. Windows je ionako otputovao tamo

Zašto offline gradnja lanca i dalje dohvaća CRL-ove na Windowsu?

Jer CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ograničava samo URL dohvat koji radi gradnja lanca: AIA dohvat izdavatelja, update korijena i CTL-a. Microsoftova dokumentacija za CertGetCertificateChain izričito kaže da zastavica ne vrijedi za provjeru opoziva. Opoziv ima vlastiti prekidač, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), i bez njega revocation provideri slobodno preuzimaju CRL ili šalju OCSP zahtjev iako okolni poziv izgleda offline. PDFium VCL sada tu zastavicu OR-a u revocation prolaz kad god je OnlineRetrieval False, uz chain zastavice, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT i CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. To je važno za više od latencije: OCSP zahtjev govori responderu koji certifikat gledate, a upravo to treba izbjegavati validator odvojen od mreže

Dva neovisna prekidača čuvaju offline validaciju PDF potpisa na Windowsu: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL ograničava samo gradnju lanca poput AIA dohvata izdavatelja i korijena, dok opoziv treba CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, jer bez njega provideri i dalje preuzimaju CRL-ove i šalju OCSP zahtjeve koji otkrivaju koji se certifikat validira
PDFium VCL OR-a revocation cache-only zastavicu u revocation prolaz kad god je OnlineRetrieval False, pa ptnpOffline validacija povjerenja ostaje izvan mreže u oba prolaza
uses
  PDFium, FPdfCrypto, FPdfPades;

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
begin
  Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
  Options.CheckRevocation := True;                 // False po defaultu
  Options.CheckTimeStamps := True;
  // Offline sada znači offline i za opoziv: keširani CRL i OCSP
  // samo odgovori, ne diže se pcvstOnlineRetrieval checkpoint
  Report := Pdf.ValidatePadesTrust(Options);
end;

Dvije gradnje lanca, dva polja greške

Windows backend gradi lanac dvaput, i svaka gradnja sada posjeduje vlastiti utor za grešku. Prvi poziv CertGetCertificateChain radi bez revocation zastavica i hrani CertVerifyCertificateChainPolicy baznom politikom, što proizvodi TrustStatus i TrustError. Drugi poziv dodaje revocation zastavice. Prije v3.119.1 pad tog drugog poziva pisao je GetLastError u TrustError, pa je lanac upravo verificiran kao pouzdan mogao doći natrag izgledajući nepouzdano jer je revocation provider šmrknuo. Popravak odmah čita GetLastError i čuva ga u TPdfCmsVerifyResult.RevocationError, ostavljajući presudu prvog prolaza na miru. I True povrat drugog poziva ne tretira se kao uspjeh; znači samo da je Windows vratio kontekst lanca vrijedan pregleda

Što maska trust greške od nule zapravo dokazuje?

Sama po sebi, ništa. Zbirni TrustStatus.dwErrorStatus od nule nakon revocation prolaza kaže da nije dignut nijedan greškovni bit, i lanac u kojem nijedan element nije nosio informaciju o opozivu može proizvesti točno to. Raniji kod preslikavao je "nema opozvanog bita, nema nepoznatog bita, nema offline bita" ravno u valjano, što je klasičan način na koji validator javlja neprovjereni certifikat kao čistog. Nova rutina ReadWinRevocationEvidence prelazi svaki simple lanac i svaki element, odbija strukture čiji je cbSize premalen za sigurno čitanje, i javlja uspjeh samo kad postoji barem jedan ne-korijenski element i svaki takav element nosi CERT_REVOCATION_INFO čiji je dwRevocationResult nula

Prolaz dokazima koji ReadWinRevocationEvidence izvodi nad svakim Windows elementom lanca u Delphiju: pRevocationInfo mora biti prisutan, cbSize mora biti dovoljno velik za čitanje, dwRevocationResult mora biti nula, a krajnji se element isključuje samo kad je označen kao self-signed ili CA-trusted, pa maska trust greške od nule više ne može sakriti neprovjereni certifikat
Čista presuda traži barem jedan ne-korijenski element i odgovor od svakog traženog elementa, pri čemu RevocationError čuva sirovi provider DWORD poput CRYPT_E_REVOKED
// Sažeto iz prolaza dokazima: element se računa samo kad je
// revocation provider stvarno odgovorio za njega
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); // lanac samo od korijena ne dokazuje ništa

Rezultat providera čuva se sirov. RevocationError drži dwRevocationResult DWORD točno kako ga je provider vratio, preferirajući grešku od opozvanog elementa kad postoji (CRYPT_E_REVOKED je $80092010), i trust bitmaska se nikad ne preodijeva u nativni greškovni kod. Preslikavanje u TPdfCmsRevocationReason namjerno je grubo: pcrrCertificateRevoked s pcvsInvalid za izričit opoziv, pcrrChainUntrusted kad lanac padne iz razloga nesrodnih opozivu, i pcrrUnknown za sve ostalo. Windows je možda pokušao OCSP umjesto CRL-a, pa se offline ili nepoznati rezultat ne prevodi u pcrrCrlExpired. OpenSSL CMS backend verifikacije može činiti te CRL-specifične razlike jer jedino evaluira CRL-ove koje mu Vi dodate, dok macOS SecTrust backend polja ostavlja na pcrrNone i nuli, što znači "nema detaljne dijagnostike", a ne "prošlo"

Gdje isključenje korijena staje

CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT legitimno preskače sidro, jer nitko ne objavljuje CRL koji opoziva korijen prema samom sebi. Zamka je odlučiti koji je element korijen. PDFium VCL isključuje zadnji element simple lanca samo kad ga njegov dwInfoStatus označava kao self-signed ($00000008) ili izričito CA-trusted ($00004000). Offline host često ne može dohvatiti nedostajućeg izdavatelja, pa lanac završava na međucertifikatu; tretirati završni element tog djelomičnog lanca kao korijen tiho bi izbacilo jedini certifikat čiji je opozivni status najvjerojatnije odsutan iz keša. Taj element ostaje u traženom skupu, nema providerovog odgovora, i rezultat ostaje pcvsIndeterminate

Kako se rezultati opoziva potpisa i timestampa drže odvojenima?

Kao odvojena polja koja se nikad ne pregaze. PAdES validator verificira detached CMS potpisa dokumenta i attached CMS RFC 3161 timestamp tokena u dva neovisna poziva, i v3.119.0 dao je svakomu vlastitu dijagnostiku na TPadesSignatureValidation: RevocationReason i NativeRevocationError za potpisnika, TimeStampRevocationReason i NativeTimeStampRevocationError za TSA. Opozvani TSA certifikat zato ne može glumiti opozvanog potpisnika, i pad timestampa ne briše rezultat integriteta koji je već uspostavljen. Kad je CheckRevocation False, ili validacija nikad nije došla do te faze, polja ostaju pcrrNone i 0, pa ih uvijek čitajte uz RevocationStatus i TimeStampRevocationStatus

Opoziv potpisa i timestampa ostaje odvojen u PDFium VCL-u: detached CMS potpisa dokumenta puni RevocationReason i NativeRevocationError, attached CMS RFC 3161 tokena puni TimeStampRevocationReason i NativeTimeStampRevocationError, a neprovjerene faze ostavljaju pcrrNone i nulu uz svoja statusna polja
Opozvani TSA certifikat zato ne može glumiti opozvanog potpisnika, a pad timestampa nikad ne briše rezultat integriteta koji je verifikacija potpisa već uspostavila
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;

Izvještaj dokazima slijedi isto pravilo. CSV izvoz dodaje revocationReason, nativeRevocationError i timestamp stupce kroz nativeTimeStampRevocationError na kraj postojećeg redoslijeda stupaca, pa stariji parseri i dalje rade, a JSON izvoz dodaje odgovarajuća polja ne mijenjajući značenje starih. Ako offline validacija i dalje dolazi natrag kao indeterminirana, trajni je popravak uzvodno: prikupite validacijski materijal u trenutku potpisivanja, kako opisuje dugoročne PDF potpise s RFC 3161 timestampovima i DSS-om, umjesto da se nadate da verificirajući stroj ima topli keš

Što testna matrica dokazuje, a što ne

Windows verifikacijska matrica prošla je 30 kontroliranih chain-API scenarija i jedan stvarni offline CMS smoke na svakoj Delphi i FPC Win32 i Win64 meti. Stvarni smoke verificira valjan potpis pod nepouzdanim privatnim CA-om, dok čisti i izričito opozvani ishodi dolaze iz stubanih CertGetCertificateChain odgovora umjesto iz instaliranih trust sidara ili živog dohvata. To je poštena granica koju vrijedi izreći: rukovanje zastavicama, izolacija grešaka i prolaz dokazima prikovani su, ali ono što keš opoziva konkretnog stroja sadrži određenog dana i dalje je Windowsova stvar, a prazan keš sada ispravno proizvodi "nepoznato" umjesto mrežnog zahtjeva ili lažnog "valjano"

Rukovanje offline opozivom, dijagnostika po polju i izvozi dokaza dio su API-ja validacije PDF potpisa u PDFium VCL-u za Delphi i C++Builder, uz OpenSSL i macOS backende za cross-platform postave