O PDFium VCL faz checagem de revogação offline de assinaturas PDF no Windows adicionando CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ao passo de revogação do CertGetCertificateChain, porque a flag cache-only usada na construção da cadeia não cobre retrieval de CRL ou OCSP. Uma chamada offline de ValidatePadesTrust fica, portanto, fora da rede, e um resultado limpo exige evidência de revogação real de cada certificado. O resto deste post é sobre por que cada metade dessa frase exige trabalho deliberado
A armadilha aparece num setup comum. Um serviço de validação roda em um host Windows travado, TPadesTrustValidationOptions.NetworkPolicy é ptnpOffline (que também é o default), e o operador espera que toda resposta venha do cache local de certificados. Monte o passo de revogação só com a flag de construção de cadeia e logo alguém nota requisições de saída para um distribution point da CA no log do firewall, ou um job em lote que fica preso pelos UrlRetrievalTimeoutMs completos 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 o URL retrieval que a construção de cadeia faz: fetches de issuer AIA, atualizações de root e CTL. A documentação da Microsoft do 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 revocation providers ficam livres para baixar uma CRL ou enviar uma requisição OCSP mesmo que a chamada em volta pareça offline. O PDFium VCL faz OR dessa flag no passo 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, exatamente o que um validador air-gapped deve 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 vale para revogação também: só respostas de CRL e OCSP
// em cache, sem checkpoint pcvstOnlineRetrieval levantado
Report := Pdf.ValidatePadesTrust(Options);
end;
Duas construções de cadeia, dois campos de erro
O backend do Windows constrói a cadeia duas vezes, e cada construção tem seu próprio slot de erro. A primeira chamada de CertGetCertificateChain roda sem flags de revogação e alimenta o CertVerifyCertificateChainPolicy com a policy base, que produz TrustStatus e TrustError. A segunda chamada adiciona as flags de revogação. Se uma falha dessa segunda chamada escrevesse o GetLastError no TrustError, uma cadeia acabada de verificar como confiável poderia voltar parecendo não confiável porque um revocation provider deu uma falhada. O PDFium VCL lê o GetLastError imediatamente e 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; significa apenas que o Windows devolveu um chain context que vale inspecionar
O que uma máscara de erro de confiança zero prova de fato?
Sozinho, nada. Um TrustStatus.dwErrorStatus agregado zero depois do passo de revogação diz que nenhum bit de erro foi levantado, e uma cadeia em que nenhum elemento carregava informação de revogação pode produzir exatamente isso. Mapear "nenhum bit revoked, nenhum bit unknown, nenhum bit offline" direto para válido é o caminho clássico de um validador reportar um certificado não checado como limpo. A rotina ReadWinRevocationEvidence do PDFium VCL percorre cada simple chain e cada elemento, rejeita estruturas cujo cbSize é pequeno demais para leitura segura, e só reporta sucesso quando existe ao menos um elemento não root e cada um desses elementos 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?
Em campos separados que nunca se sobrescrevem. O validador PAdES verifica o CMS detached da assinatura do documento e o CMS attached do token de timestamp RFC 3161 em duas chamadas independentes, e cada um tem os próprios diagnósticos em TPadesSignatureValidation: RevocationReason e NativeRevocationError para o signer, TimeStampRevocationReason e NativeTimeStampRevocationError para o TSA. Um certificado de TSA revogado não pode, portanto, se passar por um signer 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 fase, os campos ficam pcrrNone e 0, então leia-os sempre junto 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. O export CSV anexa revocationReason, nativeRevocationError e as colunas de timestamp até nativeTimeStampRevocationError no fim da ordem de colunas existente, então parsers antigos continuam funcionando, e o export JSON adiciona campos correspondentes sem mudar o significado dos antigos. O que o cache de revogação de uma máquina específica contém num dado dia é problema do Windows, e um cache vazio produz "unknown" em vez de uma requisição de rede ou um "válido" falso. Se a validação offline vive voltando indeterminada, o conserto duradouro está 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 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