Artículo técnico

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

PDFiumPas firma documentos PAdES mediante un token PKCS#11 en Windows, Linux y macOS, y dos hechos de plataforma deciden si ese binding funciona: CK_ULONG es el unsigned long de C, por lo que ocupa 4 bytes en Windows y 8 en Linux y macOS, y las cabeceras PKCS#11 aplican #pragma pack(1) solo en Windows, lo que desplaza cada puntero de la tabla de funciones. Equivóquese en cualquiera de los dos y el módulo seguirá cargándose, las llamadas seguirán devolviendo valores y los números que regresen serán basura. Esa es la forma del bug que debe esperar. Nadie le entrega un error de linker, porque no se enlaza nada: el módulo es un .so, .dylib o .dll que se abre durante la ejecución mediante una ruta, y toda la superficie es un struct de punteros a funciones que usted convierte y llama. El compilador no sabe cómo era la cabecera C del otro lado. Cada desajuste permanece silencioso hasta convertirse en un crash

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

Porque un desajuste de ABI no produce en absoluto una condición de error, sino una dirección o un offset incorrectos, y el token responde obedientemente a cualquier pregunta que resulte ser esa. No hay una capa entre la declaración de su record y el módulo que pueda notar la discrepancia. De ahí salen dos modos de fallo distintos. Si el packing es incorrecto, la ranura que usted lee como C_GetSlotList contiene seis bytes de un puntero y dos del siguiente, y llamarla salta a memoria no mapeada o, peor, al centro de otra función. Esa es la violación de acceso. Si CK_ULONG tiene una anchura incorrecta, las direcciones están bien pero los datos no: un parámetro de salida var Count: CK_ULONG declarado con 4 bytes recibe 8 bytes escritos por un módulo LP64 y sobrescribe silenciosamente los cuatro bytes siguientes de su stack frame, mientras que una plantilla CK_ATTRIBUTE cuyo ValueLen está en el offset equivocado hace que el módulo lea un campo de longitud desde su puntero Value. El token devuelve entonces un CKR_BUFFER_TOO_SMALL o CKR_ATTRIBUTE_VALUE_INVALID perfectamente legítimo para una pregunta que nunca hizo. Esos códigos envían a la gente a buscar durante horas en la configuración del token. El bug está cuatro líneas más arriba, en una declaración de tipo

CK_ULONG es el unsigned long de C, no un tipo de anchura fija

Las cabeceras PKCS#11 definen CK_ULONG como un unsigned long de C, lo que significa que su anchura sigue el modelo de datos de la plataforma en lugar de la especificación. Windows es LLP64, así que unsigned long sigue siendo de 32 bits incluso dentro de un proceso de 64 bits. Linux y macOS son LP64, por lo que sigue la anchura de los punteros y se convierte en 64 bits. Esta es la línea más importante de toda la unidad, porque en PKCS#11 prácticamente todos los escalares son 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, longitudes de buffer y el propio valor de retorno CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows es LLP64: un unsigned long de C sigue siendo de 32 bits
  CK_ULONG = LongWord;
{$ELSE}
  // Linux y macOS son LP64: unsigned long sigue la anchura de los punteros
  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;

La idea es crear alias para todos ellos hacia CK_ULONG en lugar de hacia LongWord o UInt64 directamente. Así la condición aparece exactamente una vez. Escriba cualquiera de ellos de forma concreta y habrá colocado una mina que pisará el próximo port, y la pisará precisamente en el lugar que olvidó

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

Desplaza cada puntero de función en CK_FUNCTION_LIST, porque la tabla comienza con un CK_VERSION de dos bytes. Con alineación natural, el compilador inserta seis bytes de padding después de esa versión, así que el primer puntero de función cae en el offset 8. Con packing de bytes no hay padding y cae en el offset 2. Cada entrada posterior hereda el mismo desplazamiento, por eso un error de packing no afecta a un solo campo, sino a toda la tabla. La trampa es que las cabeceras PKCS#11 aplican #pragma pack(1) solo en Windows. Es una diferencia de plataforma, no de módulo: dos builds de la misma biblioteca de proveedor discrepan según de qué host procedan. Tenga en cuenta también que el packing no cambia nada en estructuras cuyos campos son todos de anchura de puntero, que son la mayoría, por lo que una prueba ingenua que solo toque CK_SLOT_INFO pasará felizmente mientras la tabla inferior esté desplazada 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 por el que se mueve la tabla
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... la tabla tiene un orden fijo; declarar el prefijo
    // hasta C_Sign basta 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 importan más de lo que parece. {$PACKRECORDS C} no equivale a «ninguna directiva»; indica a Free Pascal que siga las reglas de alineación del compilador C de la plataforma, exactamente el contrato que se necesita en Linux y macOS. La rama de Delphi es un {$A1} incondicional porque los builds Delphi de PDFiumPas apuntan a Windows, mientras que FPC lleva los builds de Linux y macOS. Y la línea de restauración del final no es cosmética: deje la unidad empaquetada y cualquier record declarado después cambiará de layout silenciosamente, justo el tipo de defecto de acción a distancia que pretende eliminar reforzar un binding de componente PDFium frente a fallos de ABI y seguridad de memoria

Pkcs11AbiLayout: convertir el layout en una aserción

Pkcs11AbiLayout informa del layout que realmente resolvió el build como una string que se puede afirmar, con la forma ulong=4 attr=16 pss=12 table=2. Un build Windows de 64 bits debe informar exactamente eso, y un destino LP64 debe informar ulong=8 attr=24 pss=24 table=8. Cualquier otra cosa significa que una llamada a través de la tabla de funciones aterrizaría en la ranura equivocada, y la función existe para que una prueba pueda decirlo en voz alta en lugar de dejar que lo diga un comentario

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;

// Durante la carga, después de que C_GetFunctionList haya devuelto la tabla:
// una versión inverosímil o un entry point nil significa que el record se ha dispuesto
// con el packing o la anchura de CK_ULONG incorrectos, así que se rechaza el módulo
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 son arbitrarios. attr es el tamaño de CK_ATTRIBUTE, que contiene un CK_ULONG, un puntero y un CK_ULONG: 4 + 8 + 4 con packing en Windows x64, 8 + 8 + 8 alineado en LP64. pss es CK_RSA_PKCS_PSS_PARAMS, tres campos CK_ULONG, por lo que mide 12 o 24. table es el offset del primer puntero de función y es el valor que detecta primero un error de packing. El caso de prueba Delphi afirma la string bajo {$IFDEF MSWINDOWS}; la suite de Lazarus afirma lo mismo. Una comprobación de igualdad cubre un layout que, de otro modo, solo se podría verificar leyendo una cabecera C junto a un record Pascal y confiando en uno mismo. La comprobación durante la carga es la segunda mitad de la misma idea. PDFiumPas solo resuelve C_GetFunctionList por nombre mediante GetProcAddress o GetProcedureAddress y toma todos los demás entry points de la tabla que devuelve esa llamada, que es la forma prevista por la especificación base PKCS #11 de OASIS para llegar a un módulo y evita los nombres de símbolos por proveedor. Después comprueba con sensatez lo que ha recibido. Una versión principal fuera de 2 a 3, o un C_Initialize, C_GetSlotList o C_Sign nil, significa que el record está desalineado y el módulo se descarta en lugar de llamarlo

Firmar a través de la tabla: mecanismos, DigestInfo y el C_Sign en dos pasadas

Una vez correcto el layout, el trabajo de firma es pequeño, porque el contrato ICmsSigner que PDFiumPas pide a un backend tiene cinco métodos y cuatro se limitan a devolver OID y el identificador del firmante. Solo SignSignedAttrsDigest hace algo: recibe el digest SHA-256 de 32 bytes de los atributos firmados y devuelve los bytes de firma. El ensamblado CMS, ASN.1, el timestamping RFC 3161 y DSS/LTV son independientes de la plataforma y ya están hechos, la misma separación de responsabilidades que permite que las sesiones remotas de firma PAdES contra un HSM o un servicio de claves cloud se conecten al mismo punto. Tres detalles de mecanismos le costarán una verificación fallida si los omite. CKM_RSA_PKCS aplica padding PKCS#1 v1.5 pero no construye DigestInfo, por lo que el caller antepone la cabecera DigestInfo SHA-256 de 19 bytes de RFC 8017; entregue el digest desnudo al token y obtendrá una firma bien formada sobre el objeto equivocado. CKM_RSA_PKCS_PSS y CKM_ECDSA reciben el digest tal cual, pero CKM_ECDSA responde con la pareja raw r||s y CMS necesita la SEQUENCE ECDSA-Sig-Value de RFC 3279 §2.2.3, así que PDFiumPas la convierte. Y C_Sign es de dos pasadas por diseño: llámelo con un buffer nil para pedir al token la longitud de la firma y después otra vez 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);
  // Registrar esto antes que nada cuando un token falla en una plataforma nueva
  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;

Conviene conocer algunas cosas menores antes del primer token. Los módulos se almacenan en caché por ruta porque C_Initialize se ejecuta una vez por proceso y módulo, y una llamada repetida responde CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), que PDFiumPas trata como éxito suponiendo que otra parte del host ya inicializó la misma biblioteca. Las strings del token, como la descripción del slot y la etiqueta del token, son campos de ancho fijo rellenos con espacios, no terminados en NUL, por lo que hay que recortarlos por la cola. Y CKO_CERTIFICATE es 1, no 2: 0 es CKO_DATA y 2 es CKO_PUBLIC_KEY. Escribir esa constante de memoria es un error que produce un resultado de búsqueda vacío y ningún error

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

Conviene dejar claro el límite, porque es más estrecho de lo que sugiere la descripción de la función. Lo que se verifica hoy en PDFiumPas es que el layout de ABI coincide campo por campo con las cabeceras C en ambas ramas, que un módulo ausente o imposible de cargar se degrada a un fallo informado y no a un crash y que las toolchains de Delphi y FPC compilan la unidad. Las rutas de token reales — C_Login, búsqueda de objetos y C_Sign contra hardware — no se han ejercitado porque el host de desarrollo no tiene instalado ningún módulo PKCS#11. Empiece con SoftHSM2 y confirme Pkcs11AbiLayout antes de conectar un token físico, para no diagnosticar a la vez un problema de ABI y uno del token. Conviene nombrar una asimetría más. El lado de firma ya es multiplataforma; el de verificación no. La verificación CMS dentro de PDFiumPas sigue protegida por {$IFDEF MSWINDOWS} y devuelve pcsUnsupported en otros destinos, y no tiene un punto de inyección de provider equivalente al backend de firma. Así, un servicio Linux puede producir una firma PAdES B-B con una clave guardada en un token y todavía no puede comprobar su propia salida en la misma máquina. Planifique la verificación en Windows o en un validador externo hasta que se cierre esa brecha

La lección se generaliza más allá de PKCS#11. Cualquier record Pascal que refleje un struct C empaquetado de forma condicional necesita tres cosas: un alias condicional para el escalar cuyo tamaño depende de la plataforma, para que la decisión de anchura exista en un único lugar; directivas de packing que enmarquen las declaraciones y se restauren después; y una función de runtime que informe del layout resuelto como algo que una prueba pueda afirmar. Los comentarios que aseguran que un struct coincide con su cabecera no valen nada; SizeOf y un offset de campo impreso al arrancar valen mucho. El backend PKCS#11, el backend CNG y el resto de la pila de firma se incluyen en el componente PDFium para Delphi y C++Builder, donde la fontanería ABI ya está condicionada para que su código pueda quedarse en el lado del token del problema