Artículo técnico

PKCS#11 en Delphi: CK_ULONG y el problema de packing

En Windows, Linux y macOS, PDFiumPas firma documentos PAdES contra un token PKCS#11, y hay dos detalles de plataforma que deciden si ese binding sirve para algo. El primero: CK_ULONG es el unsigned long de C, o sea 4 bytes en Windows y 8 en Linux y macOS. El segundo: los headers de PKCS#11 aplican #pragma pack(1) nada más en Windows, lo que corre de lugar todos los punteros de la tabla de funciones. Si cualquiera de los dos queda mal, el módulo igual carga, las llamadas igual regresan y los números que vuelven son basura. Esa es la pinta que tiene este bug y conviene esperarla así. Nadie le va a entregar un error del linker, porque aquí no se enlaza nada: el módulo es un .so, un .dylib o un .dll que se abre en tiempo de ejecución por ruta, y toda la superficie es un struct de punteros a función que uno castea y llama. El compilador no tiene la menor idea de cómo se veía el header de C del otro lado. Cada desajuste se queda callado hasta que se convierte en un crash

¿Por qué un binding PKCS#11 falla con códigos CKR aleatorios en lugar de dar un error claro?

Porque un desajuste de ABI ni siquiera produce una condición de error: produce una dirección o un offset equivocados, y el token contesta con toda obediencia la pregunta que salga de ahí. No hay ninguna capa entre la declaración de su record y el módulo que pueda notar el desacuerdo. De ahí salen dos modos de falla bien distintos. Con el packing mal, el espacio que usted lee como C_GetSlotList junta seis bytes de un puntero y dos del siguiente, así que llamarlo salta a memoria no mapeada o, peor todavía, a la mitad de otra función. Ese es el access violation. Con el ancho de CK_ULONG mal, las direcciones quedan bien pero los datos no: a un parámetro de salida var Count: CK_ULONG declarado de 4 bytes un módulo LP64 le escribe 8 y pisa sin avisar los cuatro bytes que siguen en el stack frame, y una plantilla CK_ATTRIBUTE con ValueLen en el offset equivocado hace que el módulo lea un campo de longitud desde el puntero Value. El token devuelve entonces un CKR_BUFFER_TOO_SMALL o un CKR_ATTRIBUTE_VALUE_INVALID impecable, para una pregunta que nunca se hizo. Con esos códigos la gente se pasa horas revisando la configuración del token. El bug estaba cuatro líneas más arriba, en una declaración de tipo

CK_ULONG es el unsigned long de C, no un tipo de ancho fijo

Los headers de PKCS#11 definen CK_ULONG como un unsigned long de C, así que su ancho lo fija el modelo de datos de la plataforma y no la especificación. Windows es LLP64: ahí el unsigned long se queda en 32 bits aunque el proceso sea de 64. Linux y macOS son LP64, de modo que acompaña al ancho del puntero y pasa a 64 bits. Es la línea más pesada de toda la unit, porque en PKCS#11 casi cualquier escalar es un CK_ULONG: IDs de slot, handles de sesión, handles de objeto, clases de objeto, tipos de clave, tipos de atributo, tipos de mecanismo, largos de buffer y el propio valor de retorno CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows es LLP64: ahí el unsigned long de C se queda en 32 bits
  CK_ULONG = LongWord;
{$ELSE}
  // Linux y macOS son LP64: unsigned long acompaña al ancho del puntero
  CK_ULONG = PtrUInt;
{$ENDIF}
  CK_RV = CK_ULONG;
  CK_FLAGS = CK_ULONG;
  CK_SLOT_ID = CK_ULONG;
  CK_SESSION_HANDLE = CK_ULONG;
  CK_OBJECT_HANDLE = CK_ULONG;
  CK_OBJECT_CLASS = CK_ULONG;
  CK_ATTRIBUTE_TYPE = CK_ULONG;
  CK_MECHANISM_TYPE = CK_ULONG;
  PCK_ULONG = ^CK_ULONG;

Que todos ellos sean alias de CK_ULONG y no de LongWord o UInt64 directamente es justo el punto del ejercicio: la condicional queda escrita en un solo lugar. Ponga cualquiera de ellos de forma concreta y lo que dejó ahí es una mina, que el próximo port va a pisar exactamente en el punto que usted olvidó

¿Qué le hace pragma pack(1) a la tabla de funciones de PKCS#11?

Corre de lugar todos los punteros de función de CK_FUNCTION_LIST, porque la tabla arranca con un CK_VERSION de dos bytes. Con alineación natural el compilador mete seis bytes de padding después de la versión, así que el primer puntero de función cae en el offset 8. Con packing a byte no hay padding y cae en el offset 2. Todas las entradas que siguen heredan ese mismo corrimiento, y por eso un error de packing no es un problema de un campo sino de la tabla entera. La trampa está en que los headers de PKCS#11 aplican #pragma pack(1) solo en Windows: es una diferencia de plataforma, no de módulo, y dos builds de la misma biblioteca del proveedor no se ponen de acuerdo según el host donde se armaron. Ojo también con otra cosa: el packing no cambia nada en las estructuras cuyos campos son todos del ancho de un puntero, que son la mayoría, así que una prueba ingenua que solo toque CK_SLOT_INFO va a pasar sin chistar mientras la tabla de abajo está corrida seis bytes

{$IFDEF FPC}
  {$IFDEF MSWINDOWS}{$PACKRECORDS 1}{$ELSE}{$PACKRECORDS C}{$ENDIF}
{$ELSE}
  {$A1}
{$ENDIF}

  CK_VERSION = record
    Major: Byte;
    Minor: Byte;
  end;

  CK_ATTRIBUTE = record
    AttrType: CK_ATTRIBUTE_TYPE;
    Value: Pointer;
    ValueLen: CK_ULONG;
  end;

  CK_FUNCTION_LIST = record
    Version: CK_VERSION;      // dos bytes, y el motivo de que la tabla se corra
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... la tabla va en orden fijo; declarar el prefijo
    // hasta C_Sign alcanza para llegar a todo lo que llama este backend
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}

Hay tres cosas en ese bloque que pesan más de lo que aparentan. {$PACKRECORDS C} no es lo mismo que no poner ninguna directiva: le dice a Free Pascal que siga las reglas de alineación del compilador de C de la plataforma, que es exactamente el contrato que hace falta en Linux y macOS. La rama de Delphi lleva un {$A1} incondicional porque los builds Delphi de PDFiumPas apuntan a Windows, mientras que FPC se encarga de los de Linux y macOS. Y la línea de restauración del final no es adorno: si la unit queda empaquetada, todo record declarado más abajo también cambia de layout en silencio, que es justo la clase de defecto a distancia que busca eliminar endurecer un binding de componente PDFium frente a fallas de ABI y de seguridad de memoria

Pkcs11AbiLayout: convertir el layout en una aserción

Pkcs11AbiLayout informa el layout que el build resolvió de verdad, en una sola string que se puede afirmar y que tiene la forma ulong=4 attr=16 pss=12 table=2. Un build de Windows de 64 bits tiene que informar exactamente eso, y un target LP64 tiene que informar ulong=8 attr=24 pss=24 table=8. Cualquier otra cosa quiere decir que una llamada por la tabla de funciones caería en el slot equivocado, y la función existe para que una prueba unitaria lo diga en voz alta en vez de un comentario que asegura que todo está en orden

function Pkcs11AbiLayout: string;
var
  Table: CK_FUNCTION_LIST;
begin
  Result := 'ulong=' + IntToStr(SizeOf(CK_ULONG)) +
    ' attr=' + IntToStr(SizeOf(CK_ATTRIBUTE)) +
    ' pss=' + IntToStr(SizeOf(CK_RSA_PKCS_PSS_PARAMS)) +
    ' table=' + IntToStr(NativeUInt(@Table.C_Initialize) - NativeUInt(@Table));
end;

// En la carga, una vez que C_GetFunctionList devolvió la tabla:
// una versión inverosímil o un entry point en nil significa que el record quedó
// armado con el packing o el ancho de CK_ULONG equivocados, así que se rechaza
if (FList^.Version.Major < 2) or (FList^.Version.Major > 3) or
  not Assigned(FList^.C_Initialize) or not Assigned(FList^.C_GetSlotList) or
  not Assigned(FList^.C_Sign) then
begin
  FList := nil;
  Exit;
end;

Los cuatro números no salieron al azar. attr es el tamaño de CK_ATTRIBUTE, que lleva un CK_ULONG, un puntero y otro CK_ULONG: 4 + 8 + 4 empaquetado en Windows x64, 8 + 8 + 8 alineado en LP64. pss es CK_RSA_PKCS_PSS_PARAMS, tres campos CK_ULONG, o sea 12 o 24. table es el offset del primer puntero de función, y es el valor que primero delata un error de packing. El caso de prueba de Delphi afirma la string bajo {$IFDEF MSWINDOWS}, y la suite de Lazarus afirma lo mismo. Una comparación de igualdad cubre un layout que, si no, solo se podría verificar leyendo un header de C al lado de un record de Pascal y confiando en la propia vista. El chequeo en tiempo de carga es la otra mitad de la misma idea. PDFiumPas resuelve por nombre únicamente C_GetFunctionList, vía GetProcAddress o GetProcedureAddress, y saca todos los demás entry points de la tabla que devuelve esa llamada: así es como la especificación base PKCS #11 de OASIS quiere que se llegue a un módulo, y de paso esquiva los nombres de símbolo propios de cada proveedor. Después revisa que lo que llegó tenga sentido. Una versión mayor fuera del rango 2 a 3, o un C_Initialize, un C_GetSlotList o un C_Sign en nil, quiere decir que el record quedó desalineado, y el módulo se descarta en lugar de llamarlo

Firmar por la tabla: mecanismos, DigestInfo y el C_Sign de dos pasadas

Con el layout en orden, el trabajo de firma es poco, porque el contrato ICmsSigner que PDFiumPas le pide a un backend tiene cinco métodos y cuatro se limitan a devolver OIDs y el identificador del firmante. El único que hace algo es SignSignedAttrsDigest: recibe el digest SHA-256 de 32 bytes de los atributos firmados y devuelve los bytes de la firma. El armado de CMS, ASN.1, el timestamping de RFC 3161 y DSS/LTV son independientes de la plataforma y ya están resueltos, que es el mismo reparto de tareas que deja enchufar las sesiones remotas de firma PAdES contra un HSM o un servicio de claves en la nube en esa misma costura. Hay tres detalles de mecanismo que, si se saltan, salen caros en forma de verificación fallida. CKM_RSA_PKCS aplica el padding PKCS#1 v1.5 pero no arma el DigestInfo, así que el caller antepone por su cuenta el prefijo DigestInfo SHA-256 de 19 bytes de RFC 8017; si le pasa el digest pelado al token, la firma sale bien formada pero sobre otra cosa. CKM_RSA_PKCS_PSS y CKM_ECDSA toman el digest tal como se los entregan, pero CKM_ECDSA contesta con el par r||s crudo y CMS necesita el SEQUENCE ECDSA-Sig-Value de RFC 3279 §2.2.3, así que PDFiumPas hace la conversión. Y C_Sign es de dos pasadas por diseño: primero se lo llama con un buffer nil para preguntarle al token cuánto mide la firma, y después de nuevo con un buffer de ese tamaño

var
  Options: TPdfPkcs11Options;
  Provider: IPdfPkcs11SignerProvider;
  Slot: TPdfPkcs11Slot;
begin
  Options := TPdfPkcs11Options.Default;
  Options.ModulePath := '/usr/lib/softhsm/libsofthsm2.so';
  Options.Pin := ReadOperatorPin;
  Options.CertificateLabel := 'Signing Certificate';

  if not Pkcs11ModuleAvailable(Options.ModulePath) then
    raise Exception.Create('No usable PKCS#11 module at ' + Options.ModulePath);
  // Loguear esto antes que nada cuando un token se porta mal en otra plataforma
  Writeln('PKCS#11 ABI layout: ' + Pkcs11AbiLayout);

  Provider := ConfigurePkcs11SignerProvider(Options);
  for Slot in Provider.EnumerateSlots do
    if Slot.TokenPresent then
      Writeln(Slot.SlotID, ' ', Slot.TokenLabel);
end;

Vale la pena saber un par de cosas chicas antes del primer token. Los módulos se cachean por ruta, porque C_Initialize corre una sola vez por proceso y por módulo, y una segunda llamada responde CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), que PDFiumPas toma como éxito asumiendo que otra parte del host ya inicializó esa misma biblioteca. Las strings del token, como la descripción del slot y la etiqueta, son campos de ancho fijo rellenados con espacios y no terminan en NUL, así que hay que recortarlas por la cola. Y CKO_CERTIFICATE es 1, no 2: el 0 es CKO_DATA y el 2 es CKO_PUBLIC_KEY. Escribir esa constante de memoria es de los errores que devuelven una búsqueda vacía y ningún error

Qué se verifica y dónde termina la garantía

Conviene dejar claro el límite, porque es más angosto de lo que sugiere la descripción de la funcionalidad. Hoy en PDFiumPas está verificado que el layout de ABI coincide campo por campo con los headers de C en las dos ramas, que un módulo ausente o imposible de cargar termina en una falla informada y no en un crash, y que las toolchains de Delphi y de FPC compilan la unit. Los caminos reales del token — C_Login, la búsqueda de objetos, C_Sign contra hardware — no se ejercitaron, porque la máquina de desarrollo no tiene instalado ningún módulo PKCS#11. Levante primero SoftHSM2 y confirme Pkcs11AbiLayout antes de conectar un token físico, así nunca le toca diagnosticar un problema de ABI y uno del token al mismo tiempo. Falta nombrar una asimetría más: el lado de la firma ya es multiplataforma, el de la verificación no. La verificación CMS dentro de PDFiumPas sigue encerrada en {$IFDEF MSWINDOWS} y devuelve pcsUnsupported en los demás destinos, y no tiene un punto de inyección de provider equivalente al del backend de firma. O sea que un servicio en Linux puede producir una firma PAdES B-B con una clave que vive en el token y todavía no puede revisar su propia salida en esa misma máquina. Planifique el paso de verificación en Windows o en un validador externo hasta que se cierre esa brecha

La lección sirve mucho más allá de PKCS#11. Todo record de Pascal que espeje un struct de C empaquetado de forma condicional necesita tres cosas: un alias condicional para el escalar que cambia de tamaño según la plataforma, de modo que la decisión de ancho viva en un único lugar; directivas de packing que encierren las declaraciones y se restauren después; y una función de runtime que informe el layout resuelto como algo que una prueba pueda afirmar. Los comentarios que juran que un struct coincide con su header no valen nada; un SizeOf y un offset de campo impresos al arrancar valen muchísimo. El backend PKCS#11, el backend CNG y el resto del stack de firma vienen en el componente PDFium para Delphi y C++Builder, donde el andamiaje del ABI ya viene condicionado para que su código se quede del lado del token del problema