Artículo técnico

Verificar firmas PDF en macOS con SecTrust y Delphi

El componente PDFium para Delphi verifica firmas PDF en macOS mediante TPdfKeychainCmsVerifier, un backend de verificación CMS construido sobre Apple CMSDecoder y SecTrust en lugar de sobre un CMS analizado a mano. ConfigureKeychainCmsVerifier lo instala, y una sola llamada a CMSDecoderCopySignerStatus devuelve el veredicto de la firma, un handle SecTrust y un código de resultado del certificado, exactamente la pareja de columnas que TPdfCmsVerifyResult ya llevaba en Windows

El escenario que obligó a hacer el trabajo es aburrido y habitual. Un build de Lazarus de un archivo documental se ejecuta en un Mac, abre un contrato firmado y todas las firmas vuelven como pcsUnsupported. El archivo no tiene ningún problema. La verificación de firmas sencillamente no tenía backend fuera de Windows y el validador PAdES se negaba a adivinar en su ausencia. La versión 3.111.0 de PDFiumPas abrió la costura con IPdfCmsVerifier y ConfigurePadesCmsVerifier; la versión 3.113.0 la completó en macOS. Lo interesante de ese port no es la fontanería, sino los tres puntos donde la API de Apple no tiene la misma forma que la de Windows

¿Por qué una firma PDF cubre dos rangos de bytes?

Porque una firma no puede cubrir los bytes que la contienen. ISO 32000-1 §12.8.1 coloca el blob CMS SignedData en la string /Contents del diccionario de firma y describe el alcance firmado con /ByteRange, un conjunto de pares de offset y longitud que cubre todo a ambos lados de ese hueco. Dos segmentos y un hueco en medio, en cualquier plataforma

Las plataformas no están de acuerdo sobre cómo llegan esos segmentos a la capa criptográfica y esa diferencia cuesta memoria. En Windows, CryptVerifyDetachedMessageSignature acepta un array de punteros y longitudes, así que ambos spans se pasan tal cual están en el buffer y no se duplica nada. Apple CMSDecoderSetDetachedContent acepta un único CFData y no tiene una forma multis segmento, por lo que el backend macOS concatena los dos rangos en un buffer contiguo antes de decodificar. Eso es una segunda copia completa de los bytes firmados. En un archivo escaneado de 400 MB es un pico de memoria real, escala con el documento y no con la firma y no hay otra API a la que recurrir. Dimensione el worker por lotes teniendo esto en cuenta en lugar de descubrirlo en la máquina de un cliente

Una llamada rellena dos columnas de TPdfCmsVerifyResult

CMSDecoderCopySignerStatus es inusualmente generosa para un entry point de Security.framework: una llamada devuelve el estado del firmante, un SecTrustRef para la cadena que ha construido y un OSStatus para la evaluación del certificado. Esos valores caen directamente en el record que ya consume el validador PAdES, con el estado del firmante convertido en SignatureStatus, el resultado del certificado en TrustStatus y los valores sin procesar conservados en SignatureError y TrustError, para que un ticket de soporte pueda citar un número en lugar de un adjetivo. Los callers nunca tocan directamente IPdfCmsVerifier: ValidatePadesCompliance y ValidatePadesTrust dirigen cada verificación mediante el backend que esté instalado, así que el código que lee TPadesSignatureValidation es idéntico byte a byte en ambas plataformas, como se explica en el recorrido sobre inspeccionar diccionarios de firma PDF y niveles PAdES en Delphi

CMSDecoderCopySignerStatus alimentando a TPdfCmsVerifyResult en el PDFium Delphi Component, donde una sola llamada al framework Security devuelve el estado del firmante, un SecTrustRef para la cadena construida y un OSStatus de certificado, aterrizando como SignatureStatus y TrustStatus mientras los valores raw sobreviven en SignatureError y TrustError
Una sola llamada a la API de Apple llena ambas columnas de veredicto que el validador PAdES ya consume, así que un ticket de soporte puede citar los números de estado raw preservados en lugar de un adjetivo, y el código de lectura queda idéntico en ambas plataformas
uses
  FPdfCrypto, FPdfCryptoMac, FPdfPades;

procedure InstallMacVerifier;
begin
  // La firma y la verificación resuelven símbolos de framework distintos,
  // así que uno puede estar presente mientras el otro no
  if not KeychainVerificationAvailable then
    raise Exception.CreateFmt('Security.framework symbols missing: %s',
      [KeychainMissingSymbols]);

  ConfigureKeychainCmsVerifier;

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

¿Por qué kCMSSignerInvalidCert informa de una firma válida?

Porque Apple asigna a ese valor un significado más estrecho de lo que su nombre sugiere: la firma se verificó y solo la cadena de certificados no pudo establecerse. Por eso TPdfKeychainCmsVerifier asigna kCMSSignerInvalidCert a pcvsValid en la columna SignatureStatus y deja que el problema del certificado aparezca mediante TrustStatus, donde corresponde un problema de cadena. Fusionarlo con el veredicto de la firma haría que el componente dijera a un operador que un documento intacto ha sido modificado, la peor falsa alarma que puede lanzar un validador de firmas

Enrutamiento de MapSignerStatus en TPdfKeychainCmsVerifier donde kCMSSignerInvalidCert significa que la firma verificó bien y solo falló la cadena de certificados, mapeando a pcvsValid en la columna SignatureStatus mientras el problema de la cadena sale a la luz por TrustStatus, leído como un par ordenado que separa la integridad del documento de la política del operador
Apple le da a kCMSSignerInvalidCert un significado más estrecho de lo que su nombre sugiere, así que el backend de macOS mantiene limpio el veredicto de firma y deja que la cadena no confiable salga por TrustStatus, porque fundir ambas cosas reportaría un documento intacto como modificado
function MapSignerStatus(Status: LongWord): TPdfCmsVerifyStatus;
begin
  case Status of
  kCMSSignerValid:
    Result:= pcvsValid;
  // La firma se verificó y solo falló la cadena, que el estado de confianza
  // informa por separado
  kCMSSignerInvalidCert:
    Result:= pcvsValid;
  kCMSSignerInvalidSignature, kCMSSignerUnsigned:
    Result:= pcvsInvalid;
  else
    Result:= pcvsIndeterminate;
  end;
end;

Lea los dos estados como una pareja ordenada y la lógica de información aparece sola. SignatureStatus = pcvsValid junto con TrustStatus = pcvsInvalid describe un documento cuyos bytes están intactos y cuyo emisor no es de confianza para ese Mac concreto: falta un anchor en el Keychain, un intermedio ha caducado o una cadena no se puede completar offline. Es una cuestión de política del operador, no de integridad del documento, y esa distinción es exactamente la que está detrás de muchos de los casos de la nota sobre por qué los validadores rechazan firmas PAdES criptográficamente sólidas

¿Dónde comprueba macOS realmente la revocación?

Dentro de la evaluación de confianza, y por eso TPdfCmsVerifyResult.RevocationStatus sigue a TrustStatus en lugar de llevar un veredicto propio. SecPolicyCreateRevocation produce una policy, esa policy se une a SecPolicyCreateBasicX509 en el array que se pasa a CMSDecoderCopySignerStatus y el trabajo de OCSP o CRL se realiza cuando se construye la cadena. No vuelve ninguna respuesta separada, así que informar de una significaría inventarla. El array lleva además una pequeña regla de ownership que conviene nombrar: CFArrayCreate retiene ambas policies, por lo que las dos referencias locales se liberan inmediatamente después, mientras que el caso de una sola policy omite el array y pasa la policy directamente, una forma que la API también acepta

La operación offline es un flag explícito, no un accidente de conectividad. Cuando TPdfCmsVerifyOptions.OnlineRetrieval es False, el backend añade kSecRevocationNetworkAccessDisabled, limita la evaluación a las respuestas ya almacenadas en caché en la máquina y el callback de checkpoint sigue disparándose con pcvstCryptographicSignature, pcvstChainBuild y pcvstRevocationCheck en el mismo orden que informa el backend de Windows. El código de la aplicación configura todo esto mediante el record de opciones de más alto nivel

var
  Options: TPadesTrustValidationOptions;
  Report: TPadesValidationResult;
  Stream: TFileStream;
begin
  Options:= TPadesTrustValidationOptions.Default;
  Options.CheckRevocation:= True;
  Options.NetworkPolicy:= ptnpOffline;   // solo respuestas en caché
  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 frente a copy: el release que falla en otro sitio

SecTrustGetCertificateAtIndex tiene semántica get y la referencia que devuelve nunca debe liberarse, mientras que CMSDecoderCopySignerCert y SecCertificateCopyData, situadas unas líneas más abajo en la misma rutina, tienen semántica copy y sí deben liberarse. Core Foundation codifica toda la regla en un verbo del nombre de la función y el sistema de tipos no aplica nada de ella. Libere la referencia prestada y no ocurrirá nada en el punto de llamada: el objeto trust sencillamente se volverá inconsistente y el crash llegará después, en un lugar sin relación visible con las cadenas de certificados

Semántica get contra copy de Core Foundation en el bucle de la cadena de certificados de macOS del PDFium Delphi Component, donde SecTrustGetCertificateAtIndex devuelve una referencia prestada que nunca debe liberarse mientras que SecCertificateCopyData y CMSDecoderCopySignerCert devuelven referencias propias que se liberan con CFRelease en un bloque finally
El verbo en el nombre de la función es todo el contrato de propiedad: liberar una referencia de confianza prestada deja limpio el sitio de la llamada, pero el crash llega más tarde en algún punto sin conexión visible con las cadenas de certificados
ChainCount:= _SecTrustGetCertificateCount(Trust);
SetLength(Result.ChainCertificates, ChainCount);
for I:= 0 to ChainCount- 1 do
begin
  // Semántica get: esta referencia es prestada y no se libera aquí
  Cert:= _SecTrustGetCertificateAtIndex(Trust, I);
  if Cert= nil then
    Continue;
  // Semántica copy: esta referencia es propiedad nuestra y debe devolverse
  CertData:= _SecCertificateCopyData(Cert);
  if CertData= nil then
    Continue;
  try
    Result.ChainCertificates[I]:= CFDataToBytes(CertData);
  finally
    _CFRelease(CertData);
  end;
end;

¿Qué garantiza el verifier cuando ningún backend responde?

Que la respuesta es unsupported, nunca un pass silencioso. Cuando ConfigurePadesCmsVerifier no ha instalado nada y el valor predeterminado de la plataforma no puede ayudar, TPdfCmsVerifyResult vuelve con todas las columnas como no disponibles y el validador PAdES lo asigna a pcsUnsupported, de modo que un build sin backend criptográfico informa honestamente en lugar de afirmar algo sobre la firma. El binding macOS es deliberadamente conservador en la misma dirección: Security.framework y CoreFoundation se alcanzan mediante dlopen y dlsym, así que un framework ausente o un nombre de símbolo incorrecto en este binding aparece como KeychainVerificationAvailable devolviendo False con KeychainMissingSymbols nombrando al culpable, no como un fallo de link y no como un veredicto incorrecto. Es la misma postura fail-closed que adopta el componente al buscar la biblioteca nativa, descrita en el artículo sobre cargar la biblioteca nativa de PDFium en cualquier destino

La verificación de firmas es la parte de una pila PDF donde equivocarse en silencio es peor que estar no disponible de forma explícita, y macOS ofrece una API generosa que facilita llegar a ambos resultados. Concatene los rangos de bytes y acepte la copia, mantenga separados el veredicto de la firma y el de la cadena, respete los verbos get y copy y deje que un backend ausente lo diga. Si está trasladando un flujo documental Delphi o Free Pascal al Mac y necesita firmar y validar PAdES en ambos lados, el componente PDFium para Delphi incluye el backend Keychain junto al de Windows tras una única interfaz