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