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

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

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

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