Artigo Técnico

AIA e CRL via OpenSSL para assinaturas PDF em Delphi

O PDFium VCL agora pode completar uma cadeia de assinatura PDF e checar revocação pela rede no backend OpenSSL dele: quando o OnlineRetrieval está habilitado, o ConfigureSslCmsVerifier instala um verificador que baixa certificados intermediários faltantes das URLs caIssuers da AIA e CRLs dos pontos de distribuição de CRL, dentro de um orçamento fixo de tempo, requisições e bytes por chamada de verificação. Certificados baixados são apenas material de cadeia. A confiança continua vindo exclusivamente do store do sistema e das âncoras que você configurar

O vão que isso fecha aparece na primeira vez que você valida PDFs do mundo real num servidor Linux. Uma boa parcela dos signatários embute no CMS só o próprio certificado leaf deles, então o OpenSSL não alcança uma raiz, o TrustStatus volta inválido, e a revocação nunca roda porque a cadeia nunca se tornou confiável. 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 tinha efeito sobre ele. Uma coisa vale declarar de cara: o próprio motor PDFium não faz verificação de CMS nenhuma, então toda regra abaixo vive na camada PAdES do componente e no binding OpenSSL dele, onde você pode lê-la

Em que ordem o backend OpenSSL verifica, busca e checa?

Primeiro integridade, depois confiança, depois revocação, e a rede só é tocada entre os passos que precisam dela. O VerifyCmsWithSsl checa a assinatura CMS e os signed attributes (RFC 5652) com a avaliação de cadeia suprimida, e se isso falhar ele retorna imediatamente, antes mesmo de existir uma sessão de busca, então um documento com bytes quebrados não dispara requisição alguma para fora. Só se a cadeia então falhar e o OnlineRetrieval estiver ligado é que ele segue os links da AIA e verifica de novo. Os pontos de distribuição de CRL são buscados só quando a cadeia é confiável, porque uma CRL pendurada num caminho não confiável não prova nada. Os três vereditos ficam separados o tempo todo: uma assinatura válida com cadeia incompleta ainda é reportada como assinatura válida

Ordem do VerifyCmsWithSsl no backend OpenSSL do PDFium Component: a checagem de assinatura CMS roda com a avaliação de cadeia suprimida, então bytes quebrados nunca tocam a rede; o RetrieveIntermediates segue URLs caIssuers da AIA só depois de uma falha de cadeia com OnlineRetrieval habilitado, e o RetrieveCrls busca CRLs de pontos de distribuição num store separado quando a cadeia é confiável
Integridade, depois confiança, depois revocação: a rede só é tocada entre os passos que precisam dela, e uma CRL pendurada num caminho não confiável 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 padrã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;

Por que certificados baixados nunca entram no trust store?

Porque as URLs vêm do certificado sendo validado, e foi o signatário que as escolheu. A entrada caIssuers do authorityInfoAccess (RFC 5280 §4.2.2.1) é uma dica de onde o issuer mora, nada mais. Se o que quer que responda essa URL fosse para o store de âncoras de confiança, qualquer um poderia assinar com uma chave feita em casa, apontar a AIA para o próprio servidor e receber veredito verde. O RetrieveIntermediates portanto entrega cada certificado parseado ao CMS_add1_cert, que o coloca no conjunto untrusted dessa estrutura de CMS em particular, e o OpenSSL ainda precisa construir um caminho dele até uma âncora que você configurou ou que o store do sistema já tenha. Há uma razão mais silenciosa também: o argumento de certificados do CMS_verify não é um substituto pronto para os certificados embutidos no CMS, então adicionar ao próprio CMS é o caminho confiável

O loop de busca é deliberadamente estreito. O RetrieveIntermediates roda no máximo 4 rodadas, cada uma coletando URLs caIssuers de todo certificado agora no CMS, e para assim que uma rodada não adiciona nada ou o orçamento de tempo se esgota. Uma resposta precisa decodificar com o d2i_X509 como um único certificado DER que consome o corpo inteiro; bytes excedentes são rejeitados, e um bundle PKCS#7 só de certificados servido de uma URL .p7c é pulado em vez de desempacotado. O método de acesso OCSP na mesma extensão AIA é ignorado, já que esse backend não fala OCSP. Do lado da revocação, o RetrieveCrls lê só as URIs fullName de cada DistributionPoint (RFC 5280 §4.2.1.13) dos certificados do CMS e das âncoras configuradas, e as CRLs baixadas vão para um segundo X509_STORE independente com checagem de CRL de cadeia completa, então uma CRL ausente ou vencida muda o RevocationStatus sem nunca tocar o TrustStatus

// Condensado de VerifyCmsWithSsl (FPdfCryptoSsl.pas); configuração de BIO omitida.
// Toda 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 quebrada: 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ó untrusted
  // verifique a cadeia de novo contra o mesmo store de âncoras
end;

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

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

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

  • Tempo: o UrlRetrievalTimeoutMs, cujo default é 15000 tanto no TPdfCmsVerifyOptions.Default quanto no TPadesTrustValidationOptions.Default; uma sessão criada com 0 cai para 30000, e o relógio começa quando a assinatura passa, cobrindo toda requisição posterior
  • Requisições: no máximo 8 por sessão, contadas antes de o transporte ser tentado, então um host morto ainda consome um slot
  • Bytes: 1 MiB por resposta e 4 MiB no total, com URLs de mais de 2048 caracteres recusadas antes de qualquer conexão
Os tetos rígidos de um TPdfCryptoFetchSession compartilhado pelos passos de AIA e CRL de uma chamada de verificação no PDFium Component: UrlRetrievalTimeoutMs tem default de 15000 ms com fallback de 30000 para zero, no máximo 8 requisições por sessão, 1 MiB por resposta e 4 MiB no total com respostas com falha ainda contando, e URLs de mais de 2048 caracteres recusadas
Os limites são constantes, não sugestões: bytes de uma resposta com falha ainda drenam o orçamento, e como cada assinatura e timestamp verifica separadamente o pior caso cresce com a contagem de assinaturas

A contabilidade é mais estrita do que parece à primeira vista. Bytes recebidos de uma resposta com falha ainda contam no total, então um servidor respondendo 404 com uma página grande não drena o orçamento de graça. A leitura que cruza 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 cara, porque o caminho da AIA caso contrário indexaria Data[0] de um array vazio. Só URLs http:// e https:// puras passam, sem redirects, cookies, credenciais ou descoberta automática de proxy, enquanto o HTTPS mantém suas checagens normais de certificado e hostname. A deduplicação de URLs tem escopo de uma chamada de propósito: a próxima validação precisa poder ver uma CRL recém-publicada. O orçamento também é por chamada, não por documento, e o ValidatePadesTrust verifica cada assinatura e cada token de timestamp separadamente, então o pior caso cresce com o número de assinaturas

Por que uma requisição WinHTTP que deu timeout ainda pode escrever na sua memória?

Porque retornar no timeout não cancela os callbacks já em voo. O transporte Windows comanda o WinHTTP de forma assíncrona e espera num evento pelo tempo restante da sessão, e quando essa espera desiste, a requisição 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 a essa altura pertence a uma função qualquer sem relação. O fix é propriedade, não timing: o evento e o buffer de leitura de 16 KB vivem num record no heap com duas referências, uma segurada pelo chamador e outra liberada só pelo callback final HANDLE_CLOSING, então o lado que terminar por último libera a memória

Por que uma requisição WinHTTP que deu timeout ainda pode escrever na memória: retornar no timeout deixa callbacks em voo, então o PDFium Component aponta a leitura assíncrona para um record THttpState alocado no heap cujo buffer de 16 KB e duas referências, uma segurada pelo chamador e uma liberada pelo callback final HANDLE_CLOSING, são liberados só quando o último lado termina
Uma conclusão tardia pode terminar a leitura depois que sua espera desistiu; propriedade no heap com duas referências significa que essa escrita cai em memória que ainda está viva
type
  PHttpState = ^THttpState;
  THttpState = record
    References: LongInt;               // chamador + callback final HANDLE_CLOSING
    Event: THandle;
    Status, Count: DWORD;
    Buffer: array[0..16383] of Byte;   // leituras assíncronas caem aqui, nunca numa stack
  end;

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

// No callback de status: HANDLE_CLOSING é a última notificação que o WinHTTP
// envia para a requisição, então ele solta a segunda referência
if Status = HttpHandleClosing then
begin
  ReleaseState(State);
  Exit;
end;

O que a libcurl precisa fornecer no FPC Unix

Um resolver assíncrono e um build thread-safe, ou a busca online fica desligada. No FPC Unix o transporte passa pela libcurl, a mesma dependência por trás do backend de timestamp 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 precisa setar, combinado com um resolver síncrono significa que um lookup de DNS pode simplesmente sobreviver ao timeout. A segunda armadilha é o shutdown: o curl_global_cleanup não espera pelas threads de DNS assíncronas, então uma vez que a libcurl foi inicializada o módulo fica mapeado até o processo sair, em vez de deixar uma thread em background correr para dentro de código descarregado. Quando 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 desse backend significa que CRLs atuais cobrindo a cadeia inteira foram achadas, configuradas ou baixadas, e nenhuma listou um certificado dela; nada mais. Não há OCSP, então uma CA que publica revocação só por OCSP deixa o resultado como não suportado, e uma falha de rede parece exatamente com uma CA que não publica nada. Note também que o psvdNoCrlsConfigured descreve só as CRLs que você configurou, então com busca online ele é uma dica, não uma previsão de falha. Quando uma trilha de auditoria precisa ser reproduzível sem acesso à rede, deixe o NetworkPolicy no default ptnpOffline dele: nenhuma sessão de busca é criada e o backend nunca abre uma conexão, o que casa com o contrato offline do lado da CryptoAPI descrito em checagens de revocação offline de assinaturas PDF no Windows

O código de busca, os orçamentos e os bindings de transporte vão como source com o componente PDFium para Delphi, então você pode confirmar exatamente quais URLs uma validação pode contatar e quanto ela pode baixar antes de habilitar o ptnpOnline num servidor que manuseia documentos não confiáveis