O PDFium VCL checa a revogação de assinaturas PDF offline no Windows adicionando CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY à passada de revogação do CertGetCertificateChain, porque a flag cache-only usada para construção da cadeia não cobre recuperação de CRL ou OCSP de forma alguma. Desde a v3.119.1 uma chamada offline de ValidatePadesTrust fica fora da rede, e um resultado limpo exige evidência de revogação real por certificado. O resto deste post é sobre por que as duas metades dessa frase precisavam de conserto
O cenário que expõe o problema é banal. Um serviço de validação roda num host Windows travado, TPadesTrustValidationOptions.NetworkPolicy é ptnpOffline (que também é o padrão), e o operador espera que toda resposta venha do cache local de certificados. Aí alguém nota requisições de saída para um distribution point de CA no log do firewall, ou um job em lote que trava pelo UrlRetrievalTimeoutMs completo de 15000 ms em cada assinatura. Nada no código pediu a rede. O Windows foi até lá mesmo assim
Por que uma construção de cadeia offline ainda busca CRLs no Windows?
Porque CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL só restringe a recuperação de URL que a construção da cadeia faz: fetches de emissor AIA, atualizações de root e CTL. A documentação da Microsoft para o CertGetCertificateChain diz explicitamente que a flag não se aplica à checagem de revogação. A revogação tem um switch próprio, CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), e sem ele os providers de revogação ficam livres para baixar uma CRL ou mandar uma requisição OCSP embora a chamada em volta pareça offline. O PDFium VCL agora faz OR dessa flag na passada de revogação sempre que OnlineRetrieval é False, por cima das flags de cadeia, CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT e CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Isso importa para mais que latência: uma requisição OCSP diz ao responder qual certificado você está olhando, que é exatamente o que um validador air-gapped deveria evitar
uses
PDFium, FPdfCrypto, FPdfPades;
var
Options: TPadesTrustValidationOptions;
Report: TPadesValidationResult;
begin
Options := TPadesTrustValidationOptions.Default; // ptnpOffline, 15000 ms
Options.CheckRevocation := True; // False por padrão
Options.CheckTimeStamps := True;
// Offline agora significa offline para revogação também: respostas CRL e OCSP
// em cache apenas, nenhum checkpoint pcvstOnlineRetrieval é levantado
Report := Pdf.ValidatePadesTrust(Options);
end;
Duas construções de cadeia, dois campos de erro
O backend Windows constrói a cadeia duas vezes, e cada construção agora tem o próprio slot de erro. A primeira chamada de CertGetCertificateChain roda sem flags de revogação e alimenta o CertVerifyCertificateChainPolicy com a política base, o que produz TrustStatus e TrustError. A segunda chamada adiciona as flags de revogação. Antes da v3.119.1 uma falha dessa segunda chamada gravava o GetLastError no TrustError, então uma cadeia acabava de ser verificada como confiável podia voltar parecendo não confiável porque um provider de revogação deu um tropeço. O conserto lê o GetLastError imediatamente e o guarda em TPdfCmsVerifyResult.RevocationError, deixando o veredito da primeira passada em paz. E um retorno True da segunda chamada também não é tratado como sucesso; só significa que o Windows devolveu um contexto de cadeia que vale inspecionar
O que uma máscara de erro de confiança zero prova de fato?
Por si só, nada. Um TrustStatus.dwErrorStatus agregado de zero depois da passada de revogação diz que nenhum bit de erro foi levantado, e uma cadeia em que elemento nenhum carregava informação de revogação pode produzir exatamente isso. O código antigo mapeava "nenhum bit de revogado, nenhum bit de desconhecido, nenhum bit de offline" direto para válido, que é o jeito clássico de um validador reportar um certificado não checado como limpo. A nova rotina ReadWinRevocationEvidence percorre toda simple chain e todo elemento, rejeita estruturas cujo cbSize é pequeno demais para ler com segurança, e reporta sucesso só quando existe ao menos um elemento não root e todo elemento assim carrega um CERT_REVOCATION_INFO cujo dwRevocationResult é zero
// Condensado da caminhada de evidência: um elemento conta só quando um
// provider de revogação de fato respondeu por ele
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); // uma cadeia só de root não prova nada
O resultado do provider fica cru. O RevocationError guarda o DWORD dwRevocationResult exatamente como o provider o devolveu, preferindo o erro do elemento revogado quando houver um (CRYPT_E_REVOKED é $80092010), e a bitmask de confiança nunca é fantasiada de código de erro nativo. O mapeamento para TPdfCmsRevocationReason é deliberadamente grosseiro: pcrrCertificateRevoked com pcvsInvalid para revogação explícita, pcrrChainUntrusted quando a cadeia falhou por motivos não relacionados a revogação, e pcrrUnknown para todo o resto. O Windows pode ter tentado OCSP em vez de uma CRL, então um resultado offline ou desconhecido não é traduzido para pcrrCrlExpired. O backend de verificação CMS OpenSSL pode fazer aquelas distinções específicas de CRL porque só avalia CRLs que você entrega a ele, enquanto o backend SecTrust do macOS deixa os campos em pcrrNone e zero, o que significa "sem diagnóstico detalhado", não "passou"
Onde a exclusão do root para
O CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT legitimamente pula a âncora, já que ninguém publica uma CRL que revoga um root contra ele mesmo. A armadilha é decidir qual elemento é o root. O PDFium VCL exclui o último elemento de uma simple chain só quando o dwInfoStatus dele o marca como self-signed ($00000008) ou explicitamente CA-trusted ($00004000). Um host offline muitas vezes não consegue buscar um emissor ausente, então a cadeia termina num intermediate; tratar o elemento final dessa cadeia parcial como um root descartaria em silêncio o único certificado cujo status de revogação tem mais chance de estar ausente do cache. Esse elemento fica no conjunto exigido, não tem resposta de provider, e o resultado fica pcvsIndeterminate
Como os resultados de revogação de assinatura e timestamp ficam separados?
Como campos separados que nunca se sobrescrevem. O validador PAdES verifica o CMS destacado da assinatura do documento e o CMS anexado do token de timestamp RFC 3161 em duas chamadas independentes, e a v3.119.0 deu a cada um os próprios diagnósticos no TPadesSignatureValidation: RevocationReason e NativeRevocationError para o signatário, TimeStampRevocationReason e NativeTimeStampRevocationError para a TSA. Um certificado de TSA revogado portanto não pode se passar por um signatário revogado, e uma falha de timestamp não apaga um resultado de integridade já estabelecido. Quando CheckRevocation é False, ou a validação nunca chegou a essa etapa, os campos ficam pcrrNone e 0, então sempre os leia ao lado de 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;
O relatório de evidências segue a mesma regra. A exportação CSV anexa revocationReason, nativeRevocationError e as colunas de timestamp até nativeTimeStampRevocationError no fim da ordem de colunas existente, então parsers mais velhos seguem funcionando, e a exportação JSON adiciona campos correspondentes sem mudar o que os antigos significam. Se a validação offline continua voltando indeterminada, o conserto duradouro é a montante: junte o material de validação na hora da assinatura, como descrito em assinaturas PDF de longo prazo com timestamps RFC 3161 e DSS, em vez de torcer para que a máquina verificadora tenha um cache quente
O que a matriz de testes prova e o que não prova
A matriz de verificação Windows passou em 30 cenários controlados da API de cadeias e um smoke real de CMS offline em cada alvo Delphi e FPC Win32 e Win64. O smoke real verifica uma assinatura válida sob uma CA privada não confiável, enquanto resultados limpos e explicitamente revogados vêm de respostas stubbed de CertGetCertificateChain em vez de trust anchors instaladas ou recuperação ao vivo. Essa é uma fronteira honesta que vale declarar: o tratamento de flags, o isolamento de erros e a caminhada de evidência estão cravados, mas o que o cache de revogação de uma máquina específica contém num dado dia ainda é assunto do Windows, e um cache vazio agora produz corretamente "desconhecido" em vez de uma requisição de rede ou um "válido" falso
O tratamento de revogação offline, os diagnósticos por campo e as exportações de evidências fazem parte da API de validação de assinaturas PDF no PDFium VCL para Delphi e C++Builder, ao lado dos backends OpenSSL e macOS para implantações multiplataforma