O PDFium VCL verifica a revogação de assinaturas PDF offline em Windows acrescentando CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY à passagem de revogação do CertGetCertificateChain, porque a flag cache-only usada para a construção da cadeia não cobre de todo a recolha de CRL ou OCSP. Desde a v3.119.1, uma chamada offline ao ValidatePadesTrust fica fora da rede, e um resultado limpo exige evidência de revogação real por certificado. O resto deste post é sobre porque é que as duas metades dessa frase precisavam de correção
A montagem que expõe o problema é banal. Um serviço de validação corre num anfitrião Windows fechado, o TPadesTrustValidationOptions.NetworkPolicy está em ptnpOffline (que é também a predefinição), e o operador espera que todas as respostas venham da cache de certificados local. Aí alguém repara em pedidos de saída para um ponto de distribuição da CA no log da firewall, ou num job em lote que se encrava durante os UrlRetrievalTimeoutMs completos de 15000 ms em cada assinatura. Nada no código pedia a rede. O Windows foi lá mesmo assim
Porque é que uma construção de cadeia offline ainda vai buscar CRLs em Windows?
Porque o CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL só restringe a recolha por URL que a construção da cadeia faz: fetches de emissor AIA, atualizações de raiz e de CTL. A documentação da Microsoft para o CertGetCertificateChain diz explicitamente que a flag não se aplica à verificação de revogação. A revogação tem o seu próprio interruptor, o CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY ($80000000), e sem ele os provedores de revogação ficam livres para descarregar uma CRL ou enviar um pedido OCSP mesmo que a chamada em redor pareça offline. O PDFium VCL faz agora OR dessa flag na passagem de revogação sempre que o OnlineRetrieval é False, por cima das flags de cadeia, do CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT e do CERT_CHAIN_REVOCATION_ACCUMULATIVE_TIMEOUT. Isso importa para mais do que a latência: um pedido OCSP diz ao respondedor que certificado está a olhar, que é precisamente 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 predefinição
Options.CheckTimeStamps := True;
// Offline significa agora offline também para revogação: 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 tem agora o seu próprio lugar de erro. A primeira chamada ao CertGetCertificateChain corre sem flags de revogação e alimenta o CertVerifyCertificateChainPolicy com a política de base, o que produz TrustStatus e TrustError. A segunda chamada acrescenta as flags de revogação. Antes da v3.119.1, uma falha dessa segunda chamada escrevia o GetLastError em TrustError, pelo que uma cadeia acabada de verificar como de confiança podia voltar a parecer de desconfiança porque um provedor de revogação soluçou. A correção lê o GetLastError de imediato e guarda-o em TPdfCmsVerifyResult.RevocationError, deixando o veredicto da primeira passagem quieto. E um retorno True da segunda chamada também não é tratado como sucesso; significa apenas que o Windows devolveu um contexto de cadeia que vale a pena inspecionar
O que prova realmente uma máscara de erro de confiança a zero?
Por si só, nada. Um TrustStatus.dwErrorStatus agregado a zero depois da passagem de revogação diz que nenhum bit de erro foi levantado, e uma cadeia em que nenhum elemento transportava informação de revogação nenhuma pode produzir exatamente isso. O código anterior mapeava «nenhum bit revogado, nenhum bit desconhecido, nenhum bit offline» diretamente para válido, que é a forma clássica de um validador reportar um certificado não verificado como limpo. A nova rotina ReadWinRevocationEvidence percorre todas as cadeias simples e todos os elementos, recusa estruturas cujo cbSize é pequeno demais para ler em segurança, e reporta êxito só quando existe pelo menos um elemento não raiz e todos esses elementos transportam um CERT_REVOCATION_INFO cujo dwRevocationResult é zero
// Condensado do passeio de evidência: um elemento só conta quando um
// provedor de revogação realmente 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 raiz não prova nada
O resultado do provedor fica bruto. O RevocationError guarda o DWORD dwRevocationResult exatamente como o provedor o devolveu, preferindo o erro do elemento revogado quando o há (o CRYPT_E_REVOKED é $80092010), e a bitmask de confiança nunca é vestida 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 razões alheias à revogação, e pcrrUnknown para todo o resto. O Windows pode ter tentado OCSP em vez de uma CRL, pelo que um resultado offline ou desconhecido não é traduzido em pcrrCrlExpired. O backend de verificação CMS OpenSSL consegue fazer essas distinções específicas de CRL porque só avalia CRLs que lhe entregam, enquanto o backend SecTrust do macOS deixa os campos em pcrrNone e zero, o que significa «sem diagnóstico detalhado», não «passou»
Onde pára a exclusão da raiz
O CERT_CHAIN_REVOCATION_CHECK_CHAIN_EXCLUDE_ROOT salta legitimamente a âncora, já que ninguém publica uma CRL que revogue uma raiz contra si própria. A armadilha está em decidir que elemento é a raiz. O PDFium VCL exclui o último elemento de uma cadeia simples apenas quando o seu dwInfoStatus o marca como autoassinado ($00000008) ou explicitamente de confiança CA ($00004000). Um anfitrião offline muitas vezes não consegue ir buscar um emissor em falta, pelo que a cadeia acaba num intermédio; tratar o elemento final dessa cadeia parcial como raiz deixaria cair em silêncio o único certificado cujo estado de revogação tem maior probabilidade de estar ausente da cache. Esse elemento fica no conjunto exigido, não tem resposta de provedor nenhuma, e o resultado fica pcvsIndeterminate
Como são mantidos separados os resultados de revogação da assinatura e do timestamp?
Como campos separados que nunca se sobrescrevem uns aos outros. 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 seus próprios diagnósticos em TPadesSignatureValidation: RevocationReason e NativeRevocationError para o assinante, TimeStampRevocationReason e NativeTimeStampRevocationError para o TSA. Um certificado TSA revogado não pode portanto fazer-se passar por um assinante revogado, e uma falha de timestamp não apaga um resultado de integridade que já tenha sido estabelecido. Quando o CheckRevocation é False, ou a validação nunca chegou a essa fase, os campos ficam pcrrNone e 0, por isso 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ência segue a mesma regra. A exportação CSV acrescenta revocationReason, nativeRevocationError e as colunas de timestamp até nativeTimeStampRevocationError no fim da ordem de colunas existente, pelo que os parsers antigos continuam a funcionar, e a exportação JSON acrescenta campos correspondentes sem mudar o que os antigos significam. Se a validação offline continua a voltar indeterminada, a correção duradoura está a montante: reúna o material de validação no momento da assinatura, como descrito em assinaturas PDF de longo prazo com timestamps RFC 3161 e DSS, em vez de esperar que a máquina verificadora tenha uma cache quente
O que a matriz de testes prova e o que não prova
A matriz de verificação Windows passou 30 cenários controlados da API de cadeias e um smoke real offline de CMS em cada destino Delphi e FPC Win32 e Win64. O smoke real verifica uma assinatura válida sob uma CA privada de desconfiança, enquanto os desfechos limpos e explicitamente revogados vêm de respostas simuladas do CertGetCertificateChain em vez de âncoras de confiança instaladas ou recolha ao vivo. É uma fronteira honesta que vale a pena enunciar: o tratamento das flags, o isolamento de erros e o passeio de evidência estão aferidos, mas o que a cache de revogação de uma máquina específica contém num dado dia continua a ser assunto do Windows, e uma cache vazia agora produz corretamente «desconhecido» em vez de um pedido de rede ou de um «válido» falso
O tratamento de revogação offline, os diagnósticos por campo e as exportações de evidência fazem parte da API de validação de assinaturas PDF no PDFium VCL for Delphi e C++Builder, ao lado dos backends OpenSSL e macOS para implantações multiplataforma