Artículo técnico

Firmar PAdES con identidad de Keychain de macOS en Delphi

PDFium VCL firma documentos PAdES con una clave privada guardada en el Keychain de macOS a través de un backend que resuelve cada símbolo de Security y CoreFoundation en runtime con dlopen y dlsym. Nada queda enlazado en tiempo de link, lo que significa que un nombre de símbolo mal escrito aparece como KeychainAvailable devolviendo False y KeychainMissingSymbols nombrando al culpable, en lugar de como un error del linker o un crash

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 nada pudo comprobarse contra un header. La respuesta equivocada a esa situación es escribir el código con cuidado y rezar. La correcta es disponer que los errores inevitables se anuncien a sí mismos en la forma más localizable posible

Por qué el binding dinámico es la llamada correcta incluso en la plataforma objetivo

Porque convierte una clase de falla que detiene el programa en una clase de falla que se reporta sola. Una referencia a framework enlazada estáticamente que está mal falla en tiempo de link en el target y nunca enlaza en otro lado. Una enlazada dinámicamente que está mal produce un backend indisponible y una lista de nombres sin resolver, y la primera corrida en un Mac convierte la pregunta de por qué esto no está disponible en una sola línea que nombra un typo

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 revisando su sintaxis, sus tipos y su uses clause. Una unidad que solo compila en una plataforma que nadie del equipo tiene es una unidad sin compilador que la mire, y se degrada 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;   // instala el backend signer de PAdES
  ConfigureKeychainCmsVerifier;      // y el backend de verificación

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

  Options := TPadesSignerOptions.Default;
  Options.CertificateThumbprint := 'B1 3F 9C ...';   // SHA-1, cualquier casing
  Options.PaddingScheme := psRsaPss;
end;

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

Este es el detalle más confuso de todo el binding, y tomarlo al revés compila limpio y falla en runtime. 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 item 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 el primer machine word de la estructura como si fuera un puntero

Ninguno de los dos errores produce un error de compilación, y ninguno produce un error de runtime claro. Usted recibe un puntero basura que falla en algún punto aguas abajo. La forma de volver la distinción imposible de equivocar 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 el auxiliar impone el resto

Diagrama del backend Keychain de macOS de PDFium VCL resolviendo símbolos de Security y CoreFoundation vía 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 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 binding en lugar de en memoria
// Variable exportada: dlsym da la dirección de una variable que contiene
// el CFTypeRef, así que desreferencie 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 digest-signing PSS apareció en macOS 10.13, así que en un sistema más viejo el símbolo simplemente no está y el binding recibe nil. Esa es la comprobación de versión. Aparte, en un sistema donde la constante existe, una clave específica puede igual rechazarlo, y el framework responde esa pregunta vía SecKeyIsAlgorithmSupported para esa clave. Una clave respaldada por hardware o una clave con atributos restrictivos puede declinar PSS mientras una clave de software en la misma máquina lo acepta

Ambas rutas deben 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 realmente produce una firma v1.5 arroja un documento que todo verificador rechaza de plano, lo que es estrictamente peor que reportar que PSS no está soportado. Un downgrade es aceptable, un desfase entre lo que declara y lo que hizo no lo es, y esa es una regla general para código de firmas y no una rareza de macOS. Las implicaciones a nivel de firma están trazadas en firmar PDFs con PAdES B-B

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

Codificación de firmas ECDSA, y una inversión que vale anotar

La ruta de curvas elípticas no necesita conversión alguna en macOS, y eso es lo contrario de lo que exige un binding PKCS#11. El algoritmo de digest-signing del framework Security para ECDSA 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 crudo de ancho fijo, que hay que re-codificar antes de que entre en una estructura de firma

Así que dos backends que implementan la misma interfaz necesitan tratamiento opuesto para el mismo algoritmo, y ninguno está mal. Este es precisamente el tipo de diferencia que una abstracción tiene que absorber en lugar de 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 quien que llama termina cargando un condicional por backend. La misma forma aparece en la historia de 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 signer PAdES de PDFium VCL: el framework Security del Keychain de macOS devuelve X9.62 DER que CMS acepta con conversión cero, mientras que un token PKCS#11 devuelve el par P1363 crudo de ancho fijo que hay que re-codificar, 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 un token PKCS#11 entrega P1363 crudo, así que la conversión vive dentro del provider y quienes llaman jamás ven un condicional por backend
// La interfaz de provider es la misma en toda plataforma, así que la
// selección es una decisión de arranque y no una por 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');

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

Reglas de conteo de referencias que viven a tres líneas de distancia

El manejo 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 gets un certificado desde un trust object devuelve una referencia prestada que no debe liberarse. Las funciones que copy un certificado de firmante o copian sus datos devuelven referencias propias que sí deben liberarse. Tres llamadas en secuencia, dos reglas de ownership, y liberar la prestada no falla en esa línea. Corrompe un retain count y tumba algo sin relación más tarde

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

Lo que este backend no afirma

Nunca ha corrido en macOS al momento de escribir esto, y decirlo sin rodeos es más útil que una seguridad implícita. Lo que es demostrablemente cierto es más angosto 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 runtime con las fallas enumeradas, 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 corrida en un Mac o funciona o produce una lista de nombres por arreglar

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

La idea transferible aquí va de dónde se coloca el riesgo, no de macOS. Cuando deba escribir código contra una interfaz que no puede verificar, elija la construcción donde los errores son más baratos de localizar. El binding dinámico con una lista explícita de nombres sin resolver convierte veinte supuestos inverificables en una línea de diagnóstico. Ambos backends vienen como fuente con el PDFium Delphi component, 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