PDFium VCL verifica offline le revoche delle firme PDF su Windows aggiungendo CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY al passaggio di revoca di CertGetCertificateChain, perché il flag cache-only usato per la costruzione della catena non copre affatto il recupero di CRL o OCSP. Una chiamata offline a ValidatePadesTrust resta quindi fuori dalla rete, e un risultato pulito richiede prove di revoca concrete per ogni certificato. Il resto di questo post parla del perché ogni metà di quella frase richiede un lavoro deliberato
La trappola compare in un allestimento ordinario. Un servizio di validazione gira su un host Windows bloccato, TPadesTrustValidationOptions.NetworkPolicy vale ptnpOffline (che è anche il default), e l'operatore si aspetta che ogni risposta venga dalla cache dei certificati locale. Costruite il passaggio di revoca con il solo flag di chain-building e prima o poi qualcuno nota richieste in uscita verso un punto di distribuzione della CA nel log del firewall, o un job batch che si ferma per l'intero UrlRetrievalTimeoutMs di 15000 ms su ogni firma. Nulla nel codice chiedeva la rete. Windows ci è andato lo stesso
Perché una costruzione di catena offline scarica comunque le CRL su Windows?
Perché CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL limita soltanto il recupero di URL che fa la costruzione della catena: fetch di issuer AIA, aggiornamenti di root e CTL. La documentazione Microsoft di CertGetCertificateChain dice esplicitamente che il flag non si applica alla verifica di revoca. La revoca ha il suo interruttore, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), e senza di esso i provider di revoca sono liberi di scaricare una CRL o inviare una richiesta OCSP anche se la chiamata circostante sembra offline. PDFium VCL mette quel flag in OR nel passaggio di revoca ogni volta che OnlineRetrieval è False, sopra i flag di catena, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT e CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Conta per più della latenza: una richiesta OCSP dice al responder quale certificato state guardando, che è esattamente ciò che un validator air-gapped dovrebbe evitare
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 significa offline anche per le revoche: CRL e OCSP
// in cache, nessun checkpoint pcvstOnlineRetrieval viene sollevato
Report := Pdf.ValidatePadesTrust(Options);
end;
Due costruzioni di catena, due campi di errore
Il backend Windows costruisce la catena due volte, e ogni costruzione possiede il proprio slot di errore. La prima chiamata a CertGetCertificateChain gira senza flag di revoca e alimenta CertVerifyCertificateChainPolicy con la policy di base, che produce TrustStatus e TrustError. La seconda chiamata aggiunge i flag di revoca. Se un fallimento di quella seconda chiamata scrivesse GetLastError in TrustError, una catena appena verificata come fidata potrebbe tornare con l'aria di non esserlo perché un provider di revoca ha avuto un singhiozzo. PDFium VCL legge GetLastError subito e lo memorizza in TPdfCmsVerifyResult.RevocationError, lasciando in pace il verdetto della prima passata. E un ritorno True dalla seconda chiamata non viene trattato come successo nemmeno lui; significa solo che Windows ha restituito un contesto di catena che vale la pena ispezionare
Che cosa dimostra davvero una maschera di errore di fiducia a zero?
Da sola, niente. Un TrustStatus.dwErrorStatus aggregato a zero dopo il passaggio di revoca dice che nessun bit di errore è stato alzato, e una catena in cui nessun elemento porta affatto informazioni di revoca può produrre esattamente quello. Mappare "nessun bit revoked, nessun bit unknown, nessun bit offline" direttamente su valido è il modo classico con cui un validator segnala un certificato non verificato come pulito. La routine ReadWinRevocationEvidence di PDFium VCL percorre ogni catena semplice e ogni elemento, respinge le strutture il cui cbSize è troppo piccolo da leggere in sicurezza, e riporta successo solo quando esiste almeno un elemento non root e ciascun elemento di questo tipo porta una CERT_REVOCATION_INFO il cui dwRevocationResult è zero
// Condensato dalla passeggiata delle prove: un elemento conta solo quando un
// provider di revoca ha davvero risposto per lui
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); // una catena di soli root non dimostra nulla
Il risultato del provider viene conservato grezzo. RevocationError contiene il DWORD dwRevocationResult esattamente come il provider l'ha restituito, preferendo l'errore dell'elemento revocato quando c'è (CRYPT_E_REVOKED è $80092010), e la bitmask di fiducia non viene mai vestita da codice di errore nativo. La mappatura su TPdfCmsRevocationReason è deliberamente grossolana: pcrrCertificateRevoked con pcvsInvalid per la revoca esplicita, pcrrChainUntrusted quando la catena è fallita per ragioni non legate alla revoca, e pcrrUnknown per tutto il resto. Windows potrebbe aver provato OCSP invece di una CRL, quindi un risultato offline o sconosciuto non viene tradotto in pcrrCrlExpired. Il backend di verifica CMS OpenSSL può fare quelle distinzioni specifiche da CRL perché valuta solo CRL che gli passate voi, mentre il backend SecTrust di macOS lascia i campi a pcrrNone e zero, che significa "nessuna diagnostica dettagliata", non "superato"
Dove si ferma l'esclusione della root
CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT salta legittimamente l'ancora, visto che nessuno pubblica una CRL che revochi una root contro sé stessa. La trappola è decidere quale elemento sia la root. PDFium VCL esclude l'ultimo elemento di una catena semplice solo quando il suo dwInfoStatus lo marca come self-signed ($00000008) o esplicitamente CA-trusted ($00004000). Un host offline spesso non riesce a scaricare un issuer mancante, quindi la catena finisce su un intermedio; trattare l'elemento finale di quella catena parziale come una root butterebbe in silenzio via l'unico certificato il cui stato di revoca è più probabile che manchi nella cache. Quell'elemento resta nell'insieme richiesto, non ha risposta dal provider, e il risultato resta pcvsIndeterminate
Come si tengono separati i risultati di revoca di firma e timestamp?
Come campi separati che non si sovrascrivono mai a vicenda. Il validator PAdES verifica il CMS staccato della firma del documento e il CMS allegato del token timestamp RFC 3161 in due chiamate indipendenti, e ciascuno ha le proprie diagnostiche su TPadesSignatureValidation: RevocationReason e NativeRevocationError per il firmatario, TimeStampRevocationReason e NativeTimeStampRevocationError per la TSA. Un certificato TSA revocato quindi non può fingersi un firmatario revocato, e un fallimento del timestamp non cancella un risultato di integrità già stabilito. Quando CheckRevocation è False, o la validazione non è mai arrivata a quello stadio, i campi restano pcrrNone e 0, quindi leggeteli sempre accanto a RevocationStatus e 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;
Il report delle prove segue la stessa regola. L'export CSV accoda revocationReason, nativeRevocationError e le colonne del timestamp fino a nativeTimeStampRevocationError in fondo all'ordine di colonne esistente, così i parser più vecchi continuano a funzionare, e l'export JSON aggiunge campi corrispondenti senza cambiare il significato dei vecchi. Ciò che la cache di revoca di una data macchina contiene in un dato giorno è affare di Windows, e una cache vuota produce "unknown" invece di una richiesta di rete o di un falso "valid". Se la validazione offline continua a tornare indeterminata, la correzione duratura è a monte: raccogliete il materiale di validazione al momento della firma, come descritto in firme PDF di lungo termine con timestamp RFC 3161 e DSS, invece di sperare che la macchina che verifica abbia una cache calda
La gestione offline delle revoche, le diagnostiche per campo e gli export delle prove fanno parte dell'API di validazione delle firme PDF in PDFium VCL per Delphi e C++Builder, accanto ai backend OpenSSL e macOS per i rilasci multipiattaforma