Artigo Técnico

Revogação offline de assinaturas PDF em Windows no Delphi

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

Dois interruptores independentes guardam a validação offline de assinaturas PDF em Windows: o CERT_CHAIN_CACHE_ONLY_URL_RETRIEVAL restringe apenas a construção da cadeia, como fetches de emissor AIA e de raiz, enquanto a revogação precisa do CERT_CHAIN_REVOCATION_CHECK_CACHE_ONLY, porque sem ele os provedores continuam a descarregar CRLs e a enviar pedidos OCSP que revelam que certificado está a ser validado
O PDFium VCL faz OR da flag cache-only de revogação na passagem de revogação sempre que o OnlineRetrieval é False, pelo que uma validação de confiança ptnpOffline fica fora da rede em ambas as passagens
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

O passeio de evidência que o ReadWinRevocationEvidence executa em cada elemento de cadeia Windows em Delphi: o pRevocationInfo tem de estar presente, o cbSize tem de ser grande o suficiente para ler, o dwRevocationResult tem de ser zero, e o elemento final é excluído apenas quando marcado como autoassinado ou de confiança CA, pelo que uma máscara de erro de confiança a zero já não pode esconder um certificado não verificado
Um veredicto limpo exige pelo menos um elemento não raiz e uma resposta de cada elemento exigido, com o RevocationError a conservar o DWORD bruto do provedor, como CRYPT_E_REVOKED
// 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

A revogação da assinatura e do timestamp ficam separadas no PDFium VCL: o CMS destacado da assinatura do documento preenche RevocationReason e NativeRevocationError, o CMS anexado do token RFC 3161 preenche TimeStampRevocationReason e NativeTimeStampRevocationError, e as fases não verificadas deixam pcrrNone e zero junto dos seus campos de estado
Um certificado TSA revogado não pode portanto fazer-se passar por um assinante revogado, e uma falha de timestamp nunca apaga um resultado de integridade que a verificação da assinatura já tenha estabelecido
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