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
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 emTPdfCmsVerifyOptions.Defaultcomo emTPadesTrustValidationOptions.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
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
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