Artigo Técnico

Obter AIA e CRL por OpenSSL para assinaturas PDF em Delphi

O PDFium VCL agora consegue completar uma cadeia de assinatura PDF e verificar a revocação pela rede no seu backend OpenSSL: quando o OnlineRetrieval está ativo, o ConfigureSslCmsVerifier instala um verificador que descarrega certificados intermédios em falta dos URLs caIssuers da AIA e CRLs dos pontos de distribuição de CRL, dentro de um orçamento fixo de tempo, pedidos e bytes por chamada de verificação. Os certificados descarregados são sempre e só material de cadeia. A confiança continua a vir exclusivamente da loja do sistema e das âncoras que configurar

O vão que isto fecha aparece à primeira vez que valida PDFs reais num servidor Linux. Uma grande parte dos assinantes incorpora no CMS só o certificado folha deles próprios, por isso o OpenSSL não chega a uma raiz, o TrustStatus volta inválido, e a revocação nunca corre porque a cadeia nunca se tornou de confiança. Antes da v3.121.0 o backend OpenSSL descrito em verificar assinaturas PDF com OpenSSL no PDFium VCL era estritamente offline e o OnlineRetrieval não fazia nada nele. Uma coisa vale a pena dizer de início: o motor PDFium em si não faz verificação CMS nenhuma, por isso todas as regras abaixo vivem na camada PAdES do componente e no binding OpenSSL dele, onde as pode ler

Em que ordem verifica, obtém e confere o backend OpenSSL?

Primeiro a integridade, depois a confiança, depois a revocação, e a rede só é tocada entre os passos que a precisam. O VerifyCmsWithSsl confere a assinatura CMS e os signed attributes (RFC 5652) com a avaliação de cadeia suprimida, e se isso falhar devolve logo, antes de sequer existir uma sessão de fetch, por isso um documento com bytes partidos não dispara pedido nenhum para fora. Só se a cadeia falhar depois e o OnlineRetrieval estiver ativo é que segue os links da AIA e verifica outra vez. Os pontos de distribuição de CRL só são obtidos quando a cadeia é de confiança, porque uma CRL pendurada num caminho sem confiança não prova nada. Os três veredictos mantêm-se separados do início ao fim: uma assinatura válida com uma cadeia incompleta continua a ser reportada como assinatura válida

Ordem do VerifyCmsWithSsl no backend OpenSSL do PDFium Component: a verificação da assinatura CMS corre com a avaliação de cadeia suprimida, por isso bytes partidos nunca tocam a rede; o RetrieveIntermediates segue URLs caIssuers da AIA só depois de uma falha de cadeia com OnlineRetrieval ativo, e o RetrieveCrls traz CRLs de pontos de distribuição para uma loja separada quando a cadeia é de confiança
Integridade, depois confiança, depois revocação: a rede só é tocada entre os passos que a precisam, e uma CRL pendurada num caminho sem confiança não prova nada
uses
  PDFium, FPdfCrypto, FPdfCryptoSsl, FPdfPades;

var
  Pdf: TPdf;
  Probe: TPdfCmsVerifyOptions;
  Diags: TPdfSslVerifyDiagnostics;
  Trust: TPadesTrustValidationOptions;
  Verdict: TPadesValidationResult;
  I: Integer;
begin
  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER; a única confiança extra
  ConfigureSslCmsVerifier;

  Probe := TPdfCmsVerifyOptions.Default;
  Probe.OnlineRetrieval := True;
  Probe.CheckRevocation := True;
  Diags := SslVerifyOptionsDiagnostics(Probe);
  if psvdOnlineRetrievalIgnored in Diags then
    Log('no HTTP transport or CMS_add1_cert: validation stays offline');

  Trust := TPadesTrustValidationOptions.Default;  // ptnpOffline por predefinição
  Trust.NetworkPolicy := ptnpOnline;
  Trust.CheckRevocation := True;
  Trust.UrlRetrievalTimeoutMs := 10000;           // por chamada de verificação

  Pdf := TPdf.Create(nil);
  try
    Pdf.FileName := 'signed-contract.pdf';
    Pdf.Active := True;
    Verdict := Pdf.ValidatePadesTrust(Trust);
    for I := 0 to High(Verdict.Signatures) do
      Log(Format('#%d trust=%d revocation=%d', [I,
        Ord(Verdict.Signatures[I].CertificateTrustStatus),
        Ord(Verdict.Signatures[I].RevocationStatus)]));
  finally
    Pdf.Free;
  end;
end;

Porque é que os certificados descarregados nunca vão para a loja de confiança?

Porque os URLs vêm do certificado que está a ser validado, e foi o assinante que os escolheu. A entrada caIssuers da authorityInfoAccess (RFC 5280 §4.2.2.1) é uma dica sobre onde vive o issuer, nada mais. Se o que quer que responda a esse URL fosse para a loja de âncoras de confiança, qualquer um podia assinar com uma chave feita em casa, apontar a AIA ao servidor dele, e receber um veredicto verde. O RetrieveIntermediates passa portanto cada certificado analisado ao CMS_add1_cert, que o coloca no conjunto não confiável desta estrutura CMS em particular, e o OpenSSL ainda tem de construir um caminho dele até uma âncora que tenha configurado ou que a loja do sistema já tenha. Há uma razão mais discreta também: o argumento de certificados do CMS_verify não é um substituto imediato dos certificados incorporados no CMS, por isso acrescentar ao próprio CMS é o caminho fiável

O loop de obtenção é deliberadamente estreito. O RetrieveIntermediates corre no máximo 4 rondas, cada uma a recolher URLs caIssuers de todos os certificados agora no CMS, e pára logo que uma ronda não acrescente nada ou o orçamento de tempo se gaste. Uma resposta tem de decodificar com d2i_X509 como um único certificado DER que consuma o corpo inteiro; bytes a mais no fim são rejeitados, e um pacote PKCS#7 só de certificados servido de um URL .p7c é saltado em vez de desembrulhado. O método de acesso OCSP na mesma extensão AIA é ignorado, porque este backend não fala OCSP. Do lado da revocação, o RetrieveCrls lê só os URIs fullName de cada DistributionPoint (RFC 5280 §4.2.1.13) dos certificados do CMS e das âncoras configuradas, e as CRLs descarregadas vão para uma segunda X509_STORE independente com verificação de CRL de cadeia completa, por isso uma CRL em falta ou stale muda o RevocationStatus sem nunca tocar no TrustStatus

// Condensado de VerifyCmsWithSsl (FPdfCryptoSsl.pas); montagem do BIO omitida.
// Cada chamada _CMS_verify recebe um content BIO fresco
if _CMS_verify(Cms, nil, nil, Bio, nil,
  CMS_NO_SIGNER_CERT_VERIFY or CMS_BINARY) <> 1 then
  Exit;                                   // assinatura partida: rede nenhuma
if Options.OnlineRetrieval and SslCapabilities.OnlineRetrieval then
  FetchSession := TPdfCryptoFetchSession.Create(Options.UrlRetrievalTimeoutMs);

Store := BuildStore(False, nil, RevocationChecked, CrlsMalformed);
if (_CMS_verify(Cms, nil, Store, Bio, nil, CMS_BINARY) <> 1) and
   (FetchSession <> nil) then
begin
  RetrieveIntermediates(Cms, FetchSession);  // CMS_add1_cert, só não confiável
  // verificar a cadeia outra vez contra a mesma loja de âncoras
end;

if Options.CheckRevocation and (FetchSession <> nil) and
   (Result.TrustStatus = pcvsValid) then
  RetrievedCrls := RetrieveCrls(Cms, FetchSession);
// segunda loja, separada: CRLs configuradas mais as obtidas
Store := BuildStore(True, RetrievedCrls, RevocationChecked, CrlsMalformed);

Quanto custa no máximo uma chamada de verificação?

Um teto fixo, imposto por um TPdfCryptoFetchSession que os passos AIA e CRL de uma única chamada de verificação partilham. Os limites são constantes em FPdfCryptoHttp, não sugestões:

  • Tempo: UrlRetrievalTimeoutMs, que vale 15000 por predefinição tanto em TPdfCmsVerifyOptions.Default como em TPadesTrustValidationOptions.Default; uma sessão criada com 0 recua para 30000, e o relógio começa quando a assinatura passa, cobrindo todos os pedidos posteriores
  • Pedidos: no máximo 8 por sessão, contados antes de o transporte ser tentado, por isso um host morto gasta uma fatia à mesma
  • Bytes: 1 MiB por resposta e 4 MiB no total, com URLs de mais de 2048 caracteres recusados antes de qualquer ligação
Os tetos rígidos de um TPdfCryptoFetchSession partilhado pelos passos AIA e CRL de uma chamada de verificação no PDFium Component: UrlRetrievalTimeoutMs vale 15000 ms por predefinição com recurso a 30000 no zero, no máximo 8 pedidos por sessão, 1 MiB por resposta e 4 MiB no total com respostas falhadas a contar à mesma, e URLs de mais de 2048 caracteres recusados
Os limites são constantes, não sugestões: os bytes de uma resposta falhada continuam a gastar o orçamento, e como cada assinatura e cada timestamp verificam em separado o pior caso cresce com o número de assinaturas

A contabilidade é mais estrita do que parece à primeira vista. Bytes recebidos de uma resposta falhada contam para o total à mesma, por isso um servidor que responda 404 com uma página grande não pode gastar o orçamento de graça. A leitura que atravessa o limite por resposta aborta o download em vez de passar um corpo truncado ao parser ASN.1, e um HTTP 200 com corpo vazio é rejeitado de imediato, porque o caminho AIA de outra forma indexaria Data[0] de uma matriz vazia. Só URLs http:// e https:// simples passam, sem redirects, cookies, credenciais nem descoberta automática de proxy, enquanto o HTTPS mantém as suas verificações normais de certificado e hostname. A eliminação de duplicados de URL tem o âmbito de uma chamada de propósito: a validação seguinte tem de conseguir ver uma CRL acabada de publicar. O orçamento também é por chamada, não por documento, e o ValidatePadesTrust verifica cada assinatura e cada token de timestamp em separado, por isso o pior caso cresce com o número de assinaturas

Porque pode um pedido WinHTTP expirado ainda escrever na sua memória?

Porque devolver ao expirar o tempo não cancela os callbacks já em voo. O transporte Windows move o WinHTTP assincronamente e espera por um evento com o tempo restante da sessão, e quando essa espera desiste, o pedido ainda pode completar uma leitura e sinalizar depois. Aponte a leitura assíncrona para um buffer na stack e essa conclusão tardia escreve num frame que entretanto pertence a qualquer função alheia. A correção é posse, não tempos: o evento e o buffer de leitura de 16 KB vivem num registo na heap com duas referências, uma segura por quem chama e outra libertada só pelo callback final HANDLE_CLOSING, por isso o lado que acabar por último liberta a memória

Porque é que um pedido WinHTTP expirado ainda pode escrever em memória: devolver ao expirar deixa callbacks em voo, por isso o PDFium Component aponta a leitura assíncrona para um registo THttpState alocado na heap cujo buffer de 16 KB e duas referências, uma segura por quem chama e uma libertada pelo callback final HANDLE_CLOSING, só são libertados quando o último lado acaba
Uma conclusão tardia pode acabar a leitura depois de a sua espera ter desistido; posse na heap com duas referências significa que essa escrita aterra em memória que ainda está viva
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // quem chama + callback final HANDLE_CLOSING
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // leituras assíncronas aterram aqui, nunca na stack
  end;

procedure ReleaseState(State: PHttpState);
begin
  if InterlockedDecrement(State.References) = 0 then
  begin
    CloseHandle(State.Event);
    Dispose(State);
  end;
end;

// No callback de estado: HANDLE_CLOSING é a última notificação que o WinHTTP
// manda para o pedido, por isso larga a segunda referência
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

O que o libcurl tem de fornecer em FPC Unix

Um resolver assíncrono e uma build thread-safe, ou a obtenção online fica desligada. Em FPC Unix o transporte passa pelo libcurl, a mesma dependência por trás do backend de timestamps libcurl para alvos não Windows, e o binding recusa qualquer biblioteca cuja máscara de features não tenha nem CURL_VERSION_ASYNCHDNS nem CURL_VERSION_THREADSAFE. A razão é que o CURLOPT_NOSIGNAL, que uma biblioteca dentro do processo de outra pessoa tem de definir, combinado com um resolver síncrono significa que uma procura DNS pode simplesmente sobreviver ao timeout. A segunda armadilha é o encerramento: o curl_global_cleanup não espera pelas threads de DNS assíncronas, por isso uma vez inicializado o libcurl o módulo fica mapeado até o processo sair em vez de deixar uma thread em segundo plano correr para código descarregado. Quando qualquer um dos requisitos falha, o SslCapabilities.OnlineRetrieval é False e o SslVerifyOptionsDiagnostics reporta psvdOnlineRetrievalIgnored em vez de fingir que a rede foi consultada

O que o resultado garante e o que não garante

Um RevocationStatus válido deste backend significa que CRLs atuais cobrindo a cadeia inteira foram encontradas, configuradas ou descarregadas, e nenhuma listava um certificado delas; nada mais. Não há OCSP, por isso uma CA que publique a revocação só por OCSP deixa o resultado como não suportado, e uma falha de rede parece-se exatamente com uma CA que não publica nada. Note também que o psvdNoCrlsConfigured descreve só as CRLs que configurou, por isso com obtenção online é uma dica, não uma previsão de falha. Quando um trilho de auditoria tem de ser reproduzível sem acesso à rede, deixe o NetworkPolicy na predefinição ptnpOffline: nenhuma sessão de fetch é criada e o backend nunca abre ligação, o que bate certo com o contrato offline do lado do CryptoAPI descrito nas verificações offline de revocação de assinaturas PDF no Windows

O código de obtenção, os orçamentos e os bindings de transporte viajam como fonte com o componente PDFium para Delphi, por isso pode confirmar exatamente que URLs uma validação pode contactar e quanto pode descarregar antes de ativar o ptnpOnline num servidor que trata documentos não confiáveis