Artigo Técnico

Verificar assinaturas PDF com OpenSSL no PDFium VCL

O PDFium VCL trata a verificação CMS como um backend substituível atrás da interface IPdfCmsVerifier, por isso o validador PAdES pode correr em Windows através da CryptoAPI, em macOS através da Keychain, e em qualquer sítio onde haja OpenSSL através de ConfigureSslCmsVerifier. A interface é pequena. Três comportamentos do OpenSSL por baixo dela produzem respostas confiantes e erradas se a implementar de forma ingénua

A motivação é clara assim que uma aplicação Delphi sai do Windows. A validação de assinaturas é uma das poucas áreas em que a stack criptográfica da plataforma não é um detalhe de implementação: decide que certificados são fidedignos, que algoritmos existem, e o que a revogação significa. Fixe-a no código e o código não faz port. Abstraa-a mal e cada plataforma reporta uma resposta de forma diferente que o chamador não consegue comparar

O que a abstração tem realmente de transportar

Duas formas de verificação e três vereditos independentes. Uma assinatura PDF é destacada: o conteúdo assinado são os dois intervalos de bytes de cada lado do buraco /Contents, por isso o VerifyDetached recebe dois segmentos em vez de um buffer. Um token de timestamp é anexado, transportando o seu próprio conteúdo, por isso o VerifyAttached recebe apenas o DER

O resultado divide-se em três estados porque respondem a três perguntas diferentes e podem discordar. SignatureStatus diz se os bytes foram assinados pela chave no certificado do assinante. TrustStatus diz se esse certificado encadeia para algo em que confia. RevocationStatus diz se o certificado ainda era válido no momento relevante. Um documento com uma assinatura matematicamente perfeita de um certificado de que nunca ouviu falar é válido, não fidedigno e desconhecido, e colapsar isso num único booleano é a forma como os validadores acabam a mentir aos utilizadores

uses
  FPdfCrypto, FPdfCryptoSsl;

var
  Options: TPdfCmsVerifyOptions;
begin
  if not SslAvailable then
    raise Exception.Create('libcrypto not usable: ' + SslMissingSymbols);

  ConfigureSslTrustAnchors(LoadCorporateRoots);   // DER, pode estar vazio
  ConfigureSslCrls(LoadFreshCrls);                // DER, pode estar vazio
  ConfigureSslCmsVerifier;                        // instala o backend

  Writeln('backend  : ', PadesCmsVerificationBackendName);
  Writeln('library  : ', SslLibraryPath, ' ', SslLibraryVersion);
  Writeln('ABI      : ', SslAbiLayout);           // ulong=<n> long=<n>

  Options := TPdfCmsVerifyOptions.Default;
  Options.CheckRevocation := True;
  Options.CollectChainCertificates := True;
end;

SslAbiLayout parece uma curiosidade e não é. Todos os códigos de erro do OpenSSL e todas as flags de store cruzam a fronteira como um unsigned long C, que tem quatro bytes em Windows e oito em Linux e macOS. Declare-o como um tipo fixo de 32 bits e o código funciona em Windows, e depois lê silenciosamente metade de um valor em LP64. Reportar as larguras assumidas como uma string que se pode afirmar num teste transforma toda uma classe de deriva de ABI entre plataformas numa verificação de uma linha. Quem já trabalhou o mesmo problema com CK_ULONG num binding PKCS#11 reconhece-o de imediato; essa história está em empacotamento de structs PKCS#11 e largura do CK_ULONG

Porque é que a segunda passagem de verificação vê conteúdo vazio?

Porque o CMS_verify lê o BIO de conteúdo destacado até ao fim do ficheiro, e um BIO que já foi lido não é rebobinado por si. Verificar em duas passagens é um design razoável, primeiro a assinatura criptográfica sozinha com a avaliação da cadeia suprimida, depois a avaliação completa, e falha de um modo invulgarmente enganador se ambas as passagens partilharem um BIO

A segunda passagem recebe zero bytes de conteúdo. Em modo destacado isso não é um erro, porque um buffer de conteúdo vazio é uma entrada legal. O digest simplesmente não bate certo, e a falha aparece como uma falha de construção de cadeia em vez de uma falha de conteúdo, o que o manda inspecionar certificados e trust stores enquanto o problema real é uma posição de stream. Reconstrua o BIO de memória com BIO_new_mem_buf a cada passagem. Custa uma alocação e remove a possibilidade por inteiro

O que a flag no-verify suprime e não suprime

CMS_NO_SIGNER_CERT_VERIFY suprime a avaliação da cadeia, não a pesquisa do certificado do assinante. Internamente o OpenSSL resolve e anexa os certificados dos assinantes antes de consultar a flag, por isso depois de uma primeira passagem que a leve o assinante já está disponível e os seus identificadores de algoritmo podem ser lidos de imediato. Não é preciso correr uma segunda verificação completa só para obter o certificado do assinante, que é o que o nome da flag o tenta fazer assumir

Uma regra de posse acompanha isto. A referência do assinante pertence à estrutura CMS e não deve ser libertada independentemente. É válida enquanto a estrutura for, e libertá-la produz uma corrupção cujo sintoma aparece algures completamente diferente, normalmente durante a limpeza de um objeto não relacionado

Porque é que ligar a verificação de CRLs rejeita todas as assinaturas?

Porque o OpenSSL só verifica CRLs contra o que a store já guarda e não vai buscar nada por conta própria. Não segue pontos de distribuição de CRLs e não fala OCSP. Ponha X509_V_FLAG_CRL_CHECK numa store sem CRLs e todas as cadeias falham com a incapacidade de obter a CRL de um certificado. O resultado parece a verificação de revogação a funcionar e a encontrar problemas. É a verificação de revogação a nunca chegar a correr

O backend por isso só define a flag quando ConfigureSslCrls realmente forneceu pelo menos uma CRL. Sem nenhuma, RevocationStatus volta como pcvsUnsupported, que é uma declaração honesta de que a pergunta não foi respondida. Pela mesma razão OnlineRetrieval não tem efeito neste backend e nenhum checkpoint pcvstOnlineRetrieval é emitido: não há caminho de fetching de que reportar progresso

Diagrama do verificador CMS OpenSSL do PDFium VCL com três armadilhas: um BIO de conteúdo partilhado lido até ao fim do ficheiro deixa a segunda passagem de verificação com zero bytes, o CMS_NO_SIGNER_CERT_VERIFY suprime a avaliação da cadeia mas não a pesquisa do assinante, e a verificação de CRLs numa store vazia rejeita todas as cadeias sem a revogação alguma vez correr
Cada armadilha produz um veredito errado e confiante: uma posição de stream passa por uma falha de confiança, a flag no-verify suprime menos do que o nome sugere, e a revogação que nunca correu parece uma revogação que encontrou problemas

Esta é uma posição de design que vale a pena defender em geral. Um validador que não consegue verificar a revogação deve dizê-lo. Reportar um certificado não verificado como não revogado é a forma mais comum de as ferramentas de validação de assinaturas induzirem os seus utilizadores em erro, e é exatamente a classe de confusão explorada em porque os validadores rejeitam assinaturas PAdES

// Os checkpoints deixam uma UI mostrar que fase está a correr, e dizem-lhe
// que fases um backend realmente efetua
type
  TSignatureProbe = class
    procedure Checkpoint(Stage: TPdfCmsVerifyStage);
  end;

procedure TSignatureProbe.Checkpoint(Stage: TPdfCmsVerifyStage);
begin
  case Stage of
    pcvstCryptographicSignature: Status('checking the signature');
    pcvstChainBuild:             Status('building the certificate chain');
    pcvstOnlineRetrieval:        Status('fetching validation data');
    pcvstRevocationCheck:        Status('checking revocation');
  end;
end;

// Leia os três vereditos separadamente; podem discordar
if Result.SignatureStatus = pcvsValid then
  case Result.TrustStatus of
    pcvsValid:         Report('signed and trusted');
    pcvsInvalid:       Report('signed, chain rejected');
    pcvsUnsupported,
    pcvsIndeterminate: Report('signed, trust not established');
  end;
if Result.RevocationStatus = pcvsUnsupported then
  Report('revocation was not checked on this backend');

Vincular a uma biblioteca que não pode fixar

O OpenSSL renomeou os seus acessores de stack entre 1.0 e 1.1, por isso a mesma função lógica tem dois nomes de exportação possíveis consoante a build que o anfitrião calhe ter. O binding resolve primeiro o nome mais recente e cai no mais antigo, e só regista um símbolo em falta quando nenhum resolve. Essa é a forma certa para qualquer binding dinâmico contra uma biblioteca que não distribui: prefira os nomes atuais, tolere os históricos, e reporte apenas a ausência genuína

SslMissingSymbols é o que transforma um carregamento falhado num evento diagnosticável. Um resultado não vazio num anfitrião que claramente tem a libcrypto instalada significa que a versão instalada é mais antiga do que a API a que esta build se destina, que é uma conversa de suporte completamente diferente de uma biblioteca em falta. ConfigureSslLibraryPath cobre o outro caso comum, um anfitrião com várias builds OpenSSL onde a do caminho de pesquisa predefinido não é a que quer

Escolher um backend por plataforma

O arranjo prático é selecionar no arranque e registar qual respondeu. Em Windows, o backend de plataforma integra-se com as stores de certificados que uma empresa já gere, que é normalmente o que se quer. Em macOS o backend Keychain encaixa no mesmo raciocínio e está descrito em verificar assinaturas com SecTrust no macOS. O OpenSSL é a opção portátil, e é também a escolha certa quando precisa de uma política de validação idêntica entre plataformas em vez de uma que siga o trust store de cada plataforma

Diagrama PDFium VCL da abstração IPdfCmsVerifier a transportar o VerifyDetached sobre os dois intervalos de bytes à volta do buraco Contents e o VerifyAttached para tokens de timestamp, os três vereditos independentes SignatureStatus, TrustStatus e RevocationStatus, e backends por plataforma selecionados no arranque através de CryptoAPI, SecTrust ou ConfigureSslCmsVerifier
A interface transporta duas formas de verificação e três vereditos porque respondem a perguntas diferentes e podem discordar, e o backend instalado é registado ao lado de cada veredito para que os resultados guardados possam ser reproduzidos

Qualquer que seja o que instalar, registe PadesCmsVerificationBackendName ao lado de cada veredito que guardar. Um resultado de validação armazenado sem o backend que o produziu não pode ser reproduzido mais tarde, porque os três valores de estado significam coisas subtilmente diferentes consoante a stack que respondeu. A camada de inspeção de assinaturas por cima de tudo isto, incluindo como os níveis PAdES são reportados, está coberta em inspecionar assinaturas digitais PDF e níveis PAdES

Tudo isto é distribuído como código-fonte com o componente PDF Delphi PDFium, o que aqui importa mais do que habitual: para um validador de assinaturas, poder ler exatamente que flags um backend define e que verificações salta não é um extra simpático, é a única forma de saber o que uma marca de verificação verde na sua aplicação realmente afirma