Artículo técnico

Firmar PAdES con identidad del Keychain de macOS en Delphi

PDFium VCL firma documentos PAdES con una clave privada guardada en el Keychain de macOS mediante un backend que resuelve cada símbolo de Security y CoreFoundation en tiempo de ejecución con dlopen y dlsym. Nada queda enlazado en tiempo de enlace, lo que significa que un nombre de símbolo mal escrito se manifiesta como KeychainAvailable devolviendo False y KeychainMissingSymbols nombrando al culpable, y no como un error del enlazador o un cuelgue

Esa elección la forzó una restricción incómoda, y la forma de manejarla se generaliza. La unidad se escribió en una máquina sin SDK de macOS, así que cada nombre de símbolo del framework y cada constante salieron de la documentación y ninguno se pudo comprobar contra un header. La respuesta equivocada a esa situación es escribir el código con cuidado y esperar. La correcta es organizar las cosas para que los errores inevitables se anuncien en la forma más localizable posible

Por qué el enlace dinámico es la decisión correcta incluso en la plataforma de destino

Porque convierte una clase de fallo que para el programa en una clase de fallo que se reporta solo. Una referencia a framework enlazada estáticamente que está mal falla en tiempo de enlace en el destino y no enlaza en ningún otro sitio. Una enlazada dinámicamente que está mal produce un backend no disponible y una lista de nombres sin resolver, y la primera ejecución en un Mac convierte la pregunta de por qué esto no está disponible en una sola línea que nombra una errata

Hay un segundo beneficio que paga a diario en lugar de una vez. Como la unidad no enlaza frameworks, compila en todas las plataformas, así que la compilación ordinaria de Windows sigue comprobando su sintaxis, sus tipos y su cláusula uses. Una unidad que solo compila en una plataforma que nadie del equipo tiene es una unidad sin ningún compilador mirándola, y se pudre en silencio con cada refactor de un tipo compartido

uses
  FPdfCrypto, FPdfCryptoMac;

var
  Options: TPadesSignerOptions;
begin
  if not KeychainAvailable then
    raise Exception.Create('Keychain backend unavailable, unresolved: ' +
      KeychainMissingSymbols);

  ConfigureKeychainSignerProvider;   // instalarlo como backend de firma PAdES
  ConfigureKeychainCmsVerifier;      // y como backend de verificación

  Writeln('signer backend  : ', PadesCryptoBackendName);
  Writeln('verify backend  : ', PadesCmsVerificationBackendName);

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, mayúsculas o minúsculas
  Options.PaddingScheme := psRsaPss;
end;

Dos clases de símbolo exportado, dos formas de leerlos

Este es el detalle más confuso de todo el enlace, y equivocarse en el sentido compila limpiamente y falla en tiempo de ejecución. CoreFoundation y Security exportan dos cosas categóricamente distintas a través de la misma llamada a dlsym, y el código tiene que saber cuál es cuál

Las constantes nombradas, como las claves de clase de elemento del keychain y los singletons booleanos de CoreFoundation, son variables exportadas cuyo contenido es el CFStringRef o CFBooleanRef que usted quiere. dlsym devuelve la dirección de esa variable, así que hay que desreferenciar una vez para obtener el valor. Las estructuras de tablas de callbacks, como los callbacks de clave y valor del diccionario, son estructuras exportadas, y dlsym devuelve la dirección de la estructura, que es precisamente el puntero que la función de creación del diccionario espera. Desreferencie esa y estará pasando la primera palabra de máquina de la estructura como si fuera un puntero

Ninguno de los dos errores produce un error de compilación, y ninguno produce un error claro en ejecución. Se obtiene un puntero basura que falla en algún punto aguas abajo. La manera de volver imposible confundir la distinción es dejar de confiar en recordarla: dos funciones auxiliares, una que enlaza y desreferencia y otra que enlaza sin desreferenciar, de modo que el punto de llamada declara qué clase de símbolo está pidiendo y la auxiliar impone el resto

Diagrama del backend de Keychain de macOS de PDFium VCL resolviendo símbolos de Security y CoreFoundation mediante dlsym: kSecClass es una variable exportada que BindConstant desreferencia una vez para obtener el valor CFStringRef, mientras que kCFTypeDictionaryKeyCallBacks es una estructura exportada que BindStruct pasa por dirección, y mezclar las dos reglas produce punteros basura aguas abajo
Una llamada a dlsym devuelve dos cosas categóricamente distintas: la dirección de una variable que contiene un CFTypeRef y la dirección de una estructura de callbacks. Dos auxiliares toman la decisión de desreferenciar o no en el punto de enlace en lugar de en memoria
// Variable exportada: dlsym da la dirección de una variable que contiene
// el CFTypeRef, así que hay que desreferenciar una vez
FSecClassKey := BindConstant(SecurityLib, 'kSecClass');

// Estructura exportada: dlsym da la dirección DE la estructura, que es
// lo que la API quiere. No desreferencie
FKeyCallbacks := BindStruct(CoreFoundationLib,
  'kCFTypeDictionaryKeyCallBacks');

¿Por qué una firma RSA-PSS necesita dos fallbacks separados?

Porque el algoritmo puede faltar de dos maneras independientes, y solo una de ellas es una cuestión de versión. La constante del algoritmo de firma de digest PSS apareció en macOS 10.13, así que en un sistema más viejo el símbolo simplemente no está y el enlace recibe nil. Esa es la comprobación de versión. Aparte, en un sistema donde la constante existe, una clave concreta puede seguir rechazándolo, y el framework responde esa pregunta mediante SecKeyIsAlgorithmSupported para esa clave. Una clave respaldada en hardware o una clave con atributos restrictivos puede declinar PSS mientras una clave de software en la misma máquina lo acepta

Ambos caminos tienen que llevar al mismo fallback: cambiar a PKCS#1 v1.5. Y la parte crítica es que el fallback tiene que cambiar también el identificador de algoritmo escrito en la estructura CMS, no solo la llamada de firma. Emitir un identificador de algoritmo PSS mientras se produce de verdad una firma v1.5 da un documento que cualquier verificador rechaza de plano, lo que es estrictamente peor que informar de que PSS no está soportado. Un downgrade es aceptable; una discrepancia entre lo que declara y lo que hizo no lo es, y esa es una regla general del código de firmas y no una manía de macOS. Las implicaciones a nivel de firma están expuestas en firmar PDFs con PAdES B-B

Cadena de decisión que muestra por qué la firma RSA-PSS en el backend de Keychain de PDFium VCL necesita dos fallbacks independientes: dlsym devuelve nil para la constante de firma de digest en versiones de macOS anteriores a 10.13, SecKeyIsAlgorithmSupported puede declinar una clave respaldada en hardware, y ambas puertas desembocan en el mismo downgrade a PKCS#1 v1.5 cuyo identificador de algoritmo CMS tiene que cambiar con él
PSS puede no estar disponible dos veces, una por versión de macOS y otra por clave, y solo la puerta de la versión es una pregunta del sistema. Ambas puertas desembocan en el mismo downgrade a v1.5, y el identificador CMS lo sigue

Codificación de firmas ECDSA, y un giro que merece mención

El camino de curva elíptica no necesita conversión alguna en macOS, y eso es lo contrario de lo que exige un enlace PKCS#11. El algoritmo de firma de digest para ECDSA del framework Security devuelve la firma ya en forma X9.62 DER, que es exactamente lo que CMS quiere. Un token PKCS#11 devuelve en cambio el par P1363 bruto de ancho fijo, que hay que recodificar antes de meterlo en una estructura de firma

Así que dos backends que implementan la misma interfaz necesitan tratamiento opuesto para el mismo algoritmo, y ninguno de los dos está equivocado. Este es exactamente el tipo de diferencia que una abstracción tiene que absorber y no exponer: la capa PAdES le pide a un provider que firme, y las convenciones de codificación se quedan dentro del provider. Si se filtran hacia arriba, cada llamador acaba cargando un condicional por backend. La misma forma aparece en la historia de la firma remota descrita en sesiones de firma PAdES remota contra un HSM

Comparación de la codificación de firmas ECDSA entre dos backends del firmante PAdES de PDFium VCL: el framework Security del Keychain de macOS devuelve X9.62 DER que CMS acepta sin conversión alguna, mientras que un token PKCS#11 devuelve el par P1363 bruto de ancho fijo que hay que recodificar, así que ResolvePadesSigner mantiene las convenciones de codificación dentro del provider
La misma interfaz ECDSA necesita tratamiento opuesto por backend: Security entrega DER terminado mientras que un token PKCS#11 entrega P1363 bruto, así que la conversión vive dentro del provider y los llamadores nunca ven un condicional por backend
// La interfaz del provider es la misma en todas las plataformas, así que
// la selección es una decisión de arranque y no de cada llamada
{$IFDEF DARWIN}
  if KeychainAvailable then
    ConfigureKeychainSignerProvider;
{$ENDIF}
{$IFDEF MSWINDOWS}
  // El provider CNG de Windows lo instala la unidad de plataforma
{$ENDIF}

if not PadesCryptoAvailable then
  raise Exception.Create('no signing backend on this platform');

// A partir de aquí el código de firma es neutral a la plataforma
Signer := ResolvePadesSigner(Options);

Reglas de recuento de referencias que están a tres líneas de distancia

La gestión de memoria de Core Foundation sigue convenciones de nombres, y la trampa aquí es que funciones con convenciones distintas aparecen una al lado de la otra en el mismo bloque corto. Una función que obtiene un certificado de un objeto de confianza devuelve una referencia prestada que no debe liberarse. Las funciones que copian un certificado de firmante o copian sus datos devuelven referencias propias que sí deben liberarse. Tres llamadas seguidas, dos reglas de propiedad, y liberar la prestada no falla en esa línea. Corrompe un retain count y tira abajo algo sin relación más tarde

La mitigación es leer el verbo en el nombre de cada función del framework antes de escribir la limpieza, todas las veces, sin excepción. Es el equivalente de CoreFoundation a comprobar si una API devuelve una copia o una vista, y el coste de equivocarse es un cuelgue intermitente en lugar de un error

Lo que este backend no afirma

Nunca se ha ejecutado en macOS al escribir esto, y decirlo sin rodeos es más útil que una garantía implícita. Lo que es demostrablemente cierto es más estrecho y sigue siendo valioso: la unidad compila en Windows como parte de la compilación diaria, cada símbolo del framework se enlaza por nombre en tiempo de ejecución con los fallos enumerados, y la lógica de selección de algoritmos, incluidos ambos fallbacks de PSS, es Pascal ordinario que se puede revisar y razonar. La primera ejecución en un Mac funcionará o producirá una lista de nombres que corregir

La contraparte de verificación, que usa el decodificador CMS de más alto nivel en lugar de montar la estructura CMS a mano, está cubierta en verificar firmas PDF en macOS con SecTrust, y comparte la misma infraestructura de enlace y el mismo enfoque de diagnóstico

La idea transferible de aquí va de dónde se coloca el riesgo, no de macOS. Cuando tiene que escribir código contra una interfaz que no puede verificar, elija la construcción donde los errores son más baratos de localizar. El enlace dinámico con una lista explícita de nombres sin resolver convierte veinte suposiciones inverificables en una línea de diagnóstico. Ambos backends se envían como fuente con el componente PDFium para Delphi, así que si un nombre de símbolo necesita corrección, es un cambio de una línea en su propio árbol y no un ticket de soporte