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

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

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

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