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 IPdfCmsVerifierValidatePadesCompliance 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

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

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

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