Artigo Técnico

Verificar assinaturas PDF no macOS com SecTrust em Delphi

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

O cenário que obrigou a este trabalho é banal e comum. Um build Lazarus de um arquivo documental corre num Mac, abre um contrato assinado e todas as assinaturas regressam como pcsUnsupported. Nada está errado com o ficheiro. A verificação de assinaturas simplesmente não tinha backend fora do Windows e o validador PAdES recusava-se a adivinhar na ausência de um. A versão 3.111.0 do PDFiumPas abriu a costura com IPdfCmsVerifier e ConfigurePadesCmsVerifier; a versão 3.113.0 preencheu-a no macOS. A parte interessante desse port não é a canalização, mas os três locais em que a API Apple não tem a mesma forma que a do Windows

Porque é que uma assinatura PDF cobre dois intervalos de bytes?

Porque uma assinatura não pode cobrir os bytes que a contêm. 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 cobrem tudo dos dois lados desse buraco. Dois segmentos, uma lacuna no meio, em todas as plataformas

As plataformas discordam sobre como esses segmentos chegam à camada criptográfica e a discordância custa memória. No Windows, CryptVerifyDetachedMessageSignature aceita um array de ponteiros e comprimentos, pelo que ambos os spans entram tal como estão no buffer e nada é duplicado. O CMSDecoderSetDetachedContent da Apple aceita um único CFData e não tem uma forma multissegmento, pelo que o backend macOS concatena os dois intervalos num buffer contíguo antes de descodificar. Isso é uma segunda cópia completa dos bytes assinados. Num arquivo digitalizado de 400 MB, é um pico de memória real, escala com o documento e não com a assinatura e não há uma API alternativa a usar. Dimensione o worker de batch de acordo, em vez de descobrir isto na máquina de um cliente

Uma chamada preenche duas colunas de TPdfCmsVerifyResult

CMSDecoderCopySignerStatus é invulgarmente generosa para um ponto de entrada do Security.framework: uma chamada devolve o estado do signatário, um SecTrustRef para a cadeia que construiu e um OSStatus para a avaliação do certificado. Estes valores entram diretamente no record que o validador PAdES já consome, com o estado do signatário a tornar-se SignatureStatus, o resultado do certificado a tornar-se TrustStatus e os valores crus 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 diretamente em IPdfCmsVerifier — ValidatePadesCompliance e ValidatePadesTrust encaminham todas as verificações através do backend que estiver instalado, pelo que o código que lê TPadesSignatureValidation é byte a byte igual nas duas plataformas, como descrito no guia sobre inspecionar dicionários de assinaturas PDF e níveis PAdES em Delphi

CMSDecoderCopySignerStatus a alimentar TPdfCmsVerifyResult no PDFium Delphi Component, em que uma única chamada ao Security framework devolve o estado do signatário, um SecTrustRef para a cadeia construída e um OSStatus do certificado, a entrar como SignatureStatus e TrustStatus enquanto os valores crus sobrevivem em SignatureError e TrustError
Uma única chamada à API da Apple preenche as duas colunas de veredicto que o validador PAdES já consome, pelo que um ticket de suporte pode citar os números de estado crus preservados em vez de um adjetivo, e o código de leitura mantém-se idêntico nas duas plataformas
uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // A assinatura e a verificação resolvem símbolos de framework diferentes, por isso um
  // pode existir enquanto o outro não existe
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

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

Porque é que kCMSSignerInvalidCert comunica 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 apenas não foi possível estabelecer a cadeia de certificados. Por isso TPdfKeychainCmsVerifier mapeia kCMSSignerInvalidCert para pcvsValid na coluna SignatureStatus e deixa o problema do certificado surgir através de TrustStatus, onde pertence um problema de cadeia. Fundi-lo no veredicto da assinatura faria o componente dizer a um operador que um documento não adulterado tinha sido modificado, que é o pior falso alarme que um validador de assinaturas pode produzir

Encaminhamento de MapSignerStatus em TPdfKeychainCmsVerifier, em que kCMSSignerInvalidCert significa que a assinatura verificou e só a cadeia de certificados falhou, mapeado para pcvsValid na coluna SignatureStatus enquanto o problema da cadeia aflora através de 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, pelo que o backend macOS mantém o veredicto da assinatura limpo e deixa a cadeia não confiada aflorar através de TrustStatus, porque fundir as duas reportaria um documento não adulterado como modificado
function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // A assinatura foi verificada e apenas a cadeia falhou, algo que o estado
  // de confiança comunica por si próprio
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Leia os dois estados como um par ordenado e a lógica de comunicação escreve-se sozinha. SignatureStatus = pcvsValid juntamente com TrustStatus = pcvsInvalid descreve um documento cujos bytes estão intactos e cujo emissor este Mac específico não confia: uma âncora ausente no Keychain, um intermediate expirado ou uma cadeia que não pode ser completada offline. É uma questão de política operacional, não de integridade do documento, e a distinção é precisamente a que está por trás da maioria dos casos na nota sobre porque os validadores rejeitam assinaturas PAdES criptograficamente sólidas

Onde verifica realmente o macOS a revogação?

Dentro da avaliação de confiança, razão pela qual TPdfCmsVerifyResult.RevocationStatus segue TrustStatus em vez de transportar um veredicto próprio. SecPolicyCreateRevocation produz uma policy, essa policy junta-se a SecPolicyCreateBasicX509 no array passado a CMSDecoderCopySignerStatus e o trabalho de OCSP ou CRL acontece no local onde a cadeia é construída. Não regressa nenhuma resposta separada, pelo que comunicá-la seria inventá-la. O próprio array transporta uma pequena regra de ownership que merece ser nomeada: CFArrayCreate retém ambas as policies, pelo que as duas referências locais são libertadas imediatamente depois, enquanto o caso de uma única policy ignora o array e passa diretamente a policy, uma forma que a API também aceita

A operação offline é um sinalizador explícito e não um acidente de conectividade. Quando TPdfCmsVerifyOptions.OnlineRetrieval é False, o backend acrescenta kSecRevocationNetworkAccessDisabled, confinando a avaliação às respostas já colocadas em cache na máquina, e o callback de checkpoint continua a disparar pcvstCryptographicSignature, pcvstChainBuild e pcvstRevocationCheck pela mesma ordem em que o backend Windows os comunica. O código da aplicação define tudo isto através do record de opções de nível superior

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // apenas 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 noutro lugar

SecTrustGetCertificateAtIndex tem semântica get e a referência que devolve nunca deve ser libertada, enquanto CMSDecoderCopySignerCert e SecCertificateCopyData, algumas linhas abaixo na mesma rotina, têm semântica copy e têm de ser libertadas. O Core Foundation codifica a regra inteira num verbo do nome da função e o sistema de tipos não impõe nada disso. Liberte a referência emprestada e nada correrá mal no ponto da chamada: o objeto trust torna-se simplesmente inconsistente e o crash chega mais tarde, num local sem ligação visível às cadeias de certificados

Semântica get versus copy do Core Foundation no ciclo da cadeia de certificados macOS do PDFium Delphi Component, em que SecTrustGetCertificateAtIndex devolve uma referência emprestada que nunca deve ser libertada, enquanto SecCertificateCopyData e CMSDecoderCopySignerCert devolvem referências próprias libertadas através de CFRelease num bloco finally
O verbo no nome da função é o contrato de ownership inteiro: libertar uma referência trust emprestada deixa o ponto de chamada limpo, mas o crash chega mais tarde algures sem ligação visível às cadeias de certificados
ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Semântica get: esta referência é emprestada e não é libertada aqui
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Semântica copy: esta é própria e tem de ser devolvida
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

Que garantia dá o verificador quando nenhum backend responde?

Que a resposta não é suportada, nunca uma aprovação silenciosa. Quando ConfigurePadesCmsVerifier não instalou nada e o default da plataforma não pode ajudar, TPdfCmsVerifyResult regressa com todas as colunas definidas como indisponíveis e o validador PAdES mapeia isso para pcsUnsupported, pelo que um build sem backend criptográfico comunica honestamente em vez de afirmar qualquer coisa sobre a assinatura. A binding macOS é deliberadamente conservadora na mesma direção: Security.framework e CoreFoundation são alcançados através de dlopen e dlsym, pelo que um framework ausente ou um nome de símbolo que esta binding tenha escrito mal aparece como KeychainVerificationAvailable a devolver False com KeychainMissingSymbols a nomear o culpado, não como uma falha de link e não como um veredicto errado. É a mesma postura fail-closed que o componente adota quando procura a biblioteca nativa, descrita no artigo sobre carregar a biblioteca nativa PDFium em qualquer alvo

A verificação de assinaturas é 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 suficiente para tornar os dois resultados fáceis de alcançar. Concatene os intervalos de bytes e aceite a cópia, mantenha o veredicto da assinatura e o veredicto da cadeia em colunas separadas, respeite os verbos get e copy e deixe um backend ausente dizê-lo. Se está a mover 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 do Windows por trás de uma interface única