Artigo Técnico

Verificar assinaturas PDF no macOS com SecTrust no Delphi

O PDFium Delphi Component verifica assinaturas PDF no macOS por meio de TPdfKeychainCmsVerifier, um backend de verificação CMS construído sobre o Apple CMSDecoder e SecTrust, em vez de fazer parsing manual de CMS. ConfigureKeychainCmsVerifier o instala, e uma única chamada a CMSDecoderCopySignerStatus devolve o veredito da assinatura, um handle SecTrust e um código de resultado do certificado, exatamente o par de colunas que TPdfCmsVerifyResult já carregava no Windows

O cenário que forçou o trabalho é banal e comum. Um build Lazarus de um arquivo documental roda em um Mac, abre um contrato assinado e toda assinatura volta como pcsUnsupported. Nada está errado com o arquivo. A verificação de assinatura simplesmente não tinha backend fora do Windows, e o validator PAdES se recusava a adivinhar na ausência de um. A versão 3.111.0 do PDFiumPas abriu a seam com IPdfCmsVerifier e ConfigurePadesCmsVerifier; a versão 3.113.0 a preencheu no macOS. A parte interessante desse port não é o plumbing, mas os três pontos em que a API Apple não tem exatamente o mesmo formato da API Windows

Por que uma assinatura PDF cobre dois intervalos de bytes?

Porque uma assinatura não pode cobrir os bytes que a armazenam. A ISO 32000-1 §12.8.1 coloca o blob CMS SignedData na string /Contents do dicionário de assinatura e descreve a extensão assinada com /ByteRange, um conjunto de pares de offset e comprimento que cobre tudo dos dois lados desse buraco. Dois segmentos, um intervalo no meio, em qualquer plataforma

As plataformas discordam sobre como esses segmentos chegam à camada de criptografia, e a divergência custa memória. No Windows, CryptVerifyDetachedMessageSignature aceita um array de ponteiros e comprimentos, então os dois spans entram como estão no buffer e nada é duplicado. O Apple CMSDecoderSetDetachedContent aceita um único CFData e não tem uma forma multissegmento, então o backend macOS concatena os dois ranges em um buffer contíguo antes de decodificar. Isso é uma cópia completa adicional dos bytes assinados. Em um arquivo escaneado de 400 MB, é um pico real de memória, escala com o documento em vez de com a assinatura e não há uma API alternativa para usar. Dimensione o worker de batch de acordo, em vez de descobrir isso na máquina de um cliente

Uma chamada preenche duas colunas de TPdfCmsVerifyResult

CMSDecoderCopySignerStatus é excepcionalmente generosa para um entry point do Security.framework: uma chamada retorna o status do signer, um SecTrustRef para a cadeia que construiu e um OSStatus para a avaliação do certificado. Esses valores entram diretamente no record que o validator PAdES já consome, com o status do signer virando SignatureStatus, o resultado do certificado virando TrustStatus e os valores brutos preservados em SignatureError e TrustError, para que um ticket de suporte possa citar um número em vez de um adjetivo. Os chamadores nunca tocam em IPdfCmsVerifier; ValidatePadesCompliance e ValidatePadesTrust encaminham cada verificação pelo backend instalado, então o código que lê TPadesSignatureValidation é byte a byte o mesmo nas duas plataformas, como descrito no passo a passo sobre inspecionar dicionários de assinatura PDF e níveis PAdES no Delphi

CMSDecoderCopySignerStatus alimentando TPdfCmsVerifyResult no PDFium Delphi Component, em que uma única chamada ao framework Security devolve o status do signer, um SecTrustRef para a chain construída e um OSStatus de certificado, aterrissando como SignatureStatus e TrustStatus enquanto os valores brutos sobrevivem em SignatureError e TrustError
Uma chamada à API da Apple preenche as duas colunas de veredito que o validador PAdES já consome, então um ticket de suporte pode citar os números de status brutos preservados em vez de um adjetivo, e o código de leitura segue idêntico nas duas plataformas
uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // Assinatura e verificacao resolvem simbolos de framework diferentes, entao um
  // pode existir enquanto o outro nao
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

  // PadesCmsVerificationBackendName agora responde 'macOS Security.framework'
  if not PadesCmsVerificationAvailable then
    raise Exception.Create('No CMS verification backend is installed');
end;

Por que kCMSSignerInvalidCert reporta uma assinatura válida?

Porque a Apple atribui a esse valor um significado mais estreito do que o nome sugere: a assinatura foi verificada e somente a cadeia de certificados não pôde ser estabelecida. Por isso TPdfKeychainCmsVerifier mapeia kCMSSignerInvalidCert para pcvsValid na coluna SignatureStatus e deixa o problema do certificado aparecer por TrustStatus, onde um problema de cadeia pertence. Misturá-lo no veredito da assinatura faria o componente dizer a um operador que um documento não adulterado foi modificado, o pior falso alarme que um validator de assinaturas pode produzir

Roteamento de MapSignerStatus em TPdfKeychainCmsVerifier em que kCMSSignerInvalidCert significa que a assinatura verificou e só a chain de certificados falhou, mapeando para pcvsValid na coluna SignatureStatus enquanto o problema da chain aparece via TrustStatus, lido como um par ordenado que separa a integridade do documento da política do operador
A Apple dá a kCMSSignerInvalidCert um significado mais estreito do que o nome sugere, então o backend macOS mantém o veredito de assinatura limpo e deixa a chain não confiável aparecer via TrustStatus, porque fundir as duas coisas reportaria um documento intacto como modificado
function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // A assinatura foi verificada e somente a cadeia falhou, algo que o
  // status de confianca informa por conta propria
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Leia os dois status como um par ordenado e a lógica de relatório se escreve sozinha. SignatureStatus = pcvsValid junto com TrustStatus = pcvsInvalid descreve um documento cujos bytes estão intactos e cujo emissor não é confiável para este Mac: uma âncora ausente no Keychain, um intermediário expirado ou uma cadeia que não pode ser completada offline. Essa é uma questão de política do operador, não de integridade do documento, e a distinção é exatamente a que está por trás da maioria dos casos na nota sobre por que validators rejeitam assinaturas PAdES criptograficamente sólidas

Onde o macOS verifica revogação de fato?

Dentro da avaliação de confiança, por isso TPdfCmsVerifyResult.RevocationStatus segue TrustStatus em vez de carregar um veredito próprio. SecPolicyCreateRevocation produz uma policy, essa policy se junta a SecPolicyCreateBasicX509 no array passado a CMSDecoderCopySignerStatus e o trabalho de OCSP ou CRL acontece onde a cadeia é construída. Nenhuma resposta separada volta, portanto reportar uma seria inventá-la. O array em si carrega uma regra de ownership que vale nomear: CFArrayCreate retém as duas policies, então as duas referências locais são liberadas imediatamente depois, enquanto o caso de uma única policy ignora o array e passa a policy diretamente, uma forma que a API também aceita

A operação offline é uma flag explícita, não um acidente de conectividade. Quando TPdfCmsVerifyOptions.OnlineRetrieval é False, o backend adiciona kSecRevocationNetworkAccessDisabled, confinando a avaliação a respostas já em cache na máquina, e o callback de checkpoint ainda dispara pcvstCryptographicSignature, pcvstChainBuild e pcvstRevocationCheck na mesma ordem informada pelo backend Windows. O código da aplicação configura tudo isso por meio do record de opções de nível mais alto

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // somente respostas em cache
  Options.CheckTimeStamps:= True;

  Stream:= TFileStream.Create('contract.pdf', fmOpenRead or fmShareDenyWrite);
  try
    Report:= ValidatePadesTrust(Stream, Options);
  finally
    Stream.Free;
  end;

  if Report.SignatureCount= 0 then
    Log('No signature dictionary in this document')
  else if Report.Signatures[0].CmsSignatureStatus <> pcsValid then
    Log('Document integrity failed')
  else if Report.Signatures[0].CertificateTrustStatus <> pcsValid then
    Log('Bytes intact, chain not trusted on this Mac');
end;

Get versus copy: a release que falha em outro lugar

SecTrustGetCertificateAtIndex tem semântica de get e a referência retornada nunca deve ser liberada, enquanto CMSDecoderCopySignerCert e SecCertificateCopyData, algumas linhas adiante na mesma rotina, têm semântica de copy e precisam ser liberadas. Core Foundation codifica toda a regra em um verbo do nome da função, e o sistema de tipos não impõe nada disso. Libere a referência emprestada e nada dá errado no ponto da chamada: o trust object simplesmente se torna inconsistente e o crash chega depois, em algum lugar sem relação visível com cadeias de certificados

Semântica get versus copy do Core Foundation no loop de chain de certificados do macOS no PDFium Delphi Component, em que SecTrustGetCertificateAtIndex devolve uma referência emprestada que jamais deve ser liberada, enquanto SecCertificateCopyData e CMSDecoderCopySignerCert devolvem referências próprias, liberadas via CFRelease num bloco finally
O verbo no nome da função é todo o contrato de posse: liberar uma referência de trust emprestada deixa o call site limpo, mas o crash chega mais tarde num lugar sem nenhuma conexão visível com chains de certificados
ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Semantica get: esta referencia e emprestada e nao e liberada aqui
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Semantica copy: esta referencia e propria e precisa ser devolvida
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

O que o verifier garante quando nenhum backend responde?

Que a resposta é unsupported, nunca um sucesso silencioso. Quando ConfigurePadesCmsVerifier não instalou nada e o default da plataforma não pode ajudar, TPdfCmsVerifyResult volta com todas as colunas como indisponíveis e o validator PAdES mapeia isso para pcsUnsupported, então um build sem backend de criptografia reporta honestamente, em vez de afirmar qualquer coisa sobre a assinatura. O binding macOS é deliberadamente conservador na mesma direção: Security.framework e CoreFoundation são acessados por dlopen e dlsym, então um framework ausente ou um nome de símbolo que esse binding tenha errado aparece como KeychainVerificationAvailable retornando False com KeychainMissingSymbols nomeando o culpado, não como falha de link e não como veredito errado. É a mesma postura fail-closed que o componente adota ao procurar a biblioteca nativa, descrita no artigo sobre carregar a biblioteca nativa PDFium em qualquer alvo

Verificação de assinatura é a parte de uma stack PDF em que estar errado silenciosamente é pior do que estar indisponível de forma explícita, e o macOS oferece uma API generosa o bastante para facilitar os dois resultados. Concatene os byte ranges e aceite a cópia, mantenha o veredito da assinatura e o da cadeia em colunas separadas, respeite os verbos get e copy e deixe um backend ausente dizer que está ausente. Se você está levando um workflow documental Delphi ou Free Pascal para o Mac e precisa de assinatura e validação PAdES nos dois lados, o PDFium Delphi Component distribui o backend Keychain ao lado do Windows por trás de uma única interface