Articolo tecnico

Verifica offline delle revoche delle firme PDF su Windows

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. Dalla v3.119.1 una chiamata offline a ValidatePadesTrust resta fuori dalla rete, e un risultato pulito richiede prove di revoca concrete per ogni certificato. Il resto di questo post parla di perché entrambe le metà di quella frase avevano bisogno di una correzione

L'allestimento che espone il problema è 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. 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 la costruzione della catena fa: 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 ora mette quel flag in OR nel passaggio di revoca ogni volta che OnlineRetrieval è False, oltre ai 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

Due interruttori indipendenti presidiano la validazione offline delle firme PDF su Windows: CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL limita solo la costruzione della catena come fetch di issuer AIA e di root, mentre la revoca richiede CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, perché senza di esso i provider scaricano comunque CRL e inviano richieste OCSP che rivelano quale certificato si sta validando
PDFium VCL mette il flag cache-only delle revoche in OR nel passaggio di revoca ogni volta che OnlineRetrieval è False, così una validazione di fiducia ptnpOffline resta fuori dalla rete per entrambi i passaggi
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 ora significa offline anche per le revoche: risposte CRL e OCSP
  // in cache soltanto, 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 ora 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. Prima della v3.119.1 un fallimento di quella seconda chiamata scriveva GetLastError in TrustError, così una catena appena verificata come fidata poteva tornare dai con l'aria di non esserlo perché un provider di revoca aveva singhiozzato. La correzione 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 nemmeno trattato come successo; 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 portava affatto informazioni di revoca può produrre esattamente quello. Il codice di prima mappava "nessun bit revoked, nessun bit unknown, nessun bit offline" direttamente su valido, che è il modo classico con cui un validator segnala un certificato non verificato come pulito. La nuova routine ReadWinRevocationEvidence 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

La passeggiata delle prove che ReadWinRevocationEvidence compie su ogni elemento di catena Windows in Delphi: pRevocationInfo deve essere presente, cbSize deve essere abbastanza grande da poter essere letto, dwRevocationResult deve essere zero, e l'elemento finale viene escluso solo se marcato self-signed o CA-trusted, così una maschera di errore di fiducia a zero non può più nascondere un certificato non verificato
Un verdetto pulito richiede almeno un elemento non root e una risposta da ogni elemento richiesto, con RevocationError che conserva il DWORD grezzo del provider come CRYPT_E_REVOKED
// 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 la v3.119.0 ha dato a ciascuno 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

Revoca di firma e timestamp restano separate in PDFium VCL: il CMS staccato della firma del documento riempie RevocationReason e NativeRevocationError, il CMS allegato del token RFC 3161 riempie TimeStampRevocationReason e NativeTimeStampRevocationError, e gli stadi non verificati lasciano pcrrNone e zero accanto ai loro campi di stato
Un certificato TSA revocato quindi non può fingersi un firmatario revocato, e un fallimento del timestamp non cancella mai un risultato di integrità che la verifica della firma ha già stabilito
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. 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

Che cosa dimostra e non dimostra la matrice di test

La matrice di verifica Windows ha superato 30 scenari controllati dell'API delle catene e un smoke reale offline CMS su ciascun target Delphi e FPC Win32 e Win64. Lo smoke reale verifica una firma valida sotto una CA privata non fidata, mentre gli esiti puliti ed esplicitamente revocati vengono da risposte stub di CertGetCertificateChain invece che da trust anchor installati o recupero dal vivo. È un confine onesto che vale la pena dichiarare: la gestione dei flag, l'isolamento degli errori e la passeggiata delle prove sono chiodati, ma ciò che la cache di revoca di una macchina particolare contiene in un dato giorno resta affare di Windows, e una cache vuota ora produce correttamente "unknown" invece di una richiesta di rete o di un falso "valid"

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