Artigo Técnico

PKCS#11 no Delphi: CK_ULONG e a armadilha do packing

O PDFiumPas assina documentos PAdES por meio de um token PKCS#11 no Windows, Linux e macOS, e dois fatos de plataforma decidem se esse binding funciona: CK_ULONG é o unsigned long de C, portanto tem 4 bytes no Windows e 8 bytes no Linux e macOS, e os headers PKCS#11 aplicam #pragma pack(1) somente no Windows, deslocando cada ponteiro da function table. Erre qualquer um dos dois e o módulo ainda carrega, as chamadas ainda retornam e os números recebidos são lixo. Esse é o formato do bug que você deve esperar. Ninguém entrega um erro de linker, porque nada é linkado: o módulo é um .so, .dylib ou .dll aberto em runtime pelo caminho, e toda a superfície é um struct de function pointers que você converte e chama. O compilador não sabe como era o header C do outro lado. Toda divergência permanece silenciosa até virar crash

Por que um binding PKCS#11 falha com códigos CKR aleatórios em vez de um erro claro?

Porque um mismatch de ABI não produz uma condição de erro; produz um endereço ou offset errado, e o token responde obedientemente à pergunta que acabou sendo feita. Não há camada entre sua declaração de record e o módulo que possa perceber a divergência. Surgem dois modos de falha distintos. Se o packing estiver errado, o slot lido como C_GetSlotList contém seis bytes de um ponteiro e dois do seguinte, e chamá-lo salta para memória não mapeada ou, pior, para o meio de outra função. Essa é a access violation. Se CK_ULONG tiver a largura errada, os endereços estão corretos, mas os dados não: um parâmetro de saída var Count: CK_ULONG declarado com 4 bytes recebe 8 bytes gravados por um módulo LP64, sobrescrevendo silenciosamente os quatro bytes seguintes do seu stack frame, e um template CK_ATTRIBUTE cujo ValueLen está no offset errado faz o módulo ler um campo de tamanho do seu ponteiro Value. O token então retorna um CKR_BUFFER_TOO_SMALL ou CKR_ATTRIBUTE_VALUE_INVALID perfeitamente legítimo para uma pergunta que você nunca fez. Esses códigos mandam as pessoas procurar configuração do token por horas. O bug está quatro linhas acima, numa declaração de tipo

CK_ULONG é o unsigned long de C, não um tipo de largura fixa

CK_ULONG é definido pelos headers PKCS#11 como um unsigned long de C, o que significa que sua largura acompanha o modelo de dados da plataforma, não a especificação. O Windows é LLP64, então unsigned long permanece de 32 bits mesmo em um processo de 64 bits. Linux e macOS são LP64, então ele acompanha o ponteiro e vira 64 bits. Esta é a linha de maior consequência em toda a unit, porque em PKCS#11 praticamente todo escalar é um CK_ULONG: IDs de slot, handles de sessão, handles de objeto, classes de objeto, tipos de chave, tipos de atributo, tipos de mecanismo, comprimentos de buffer e o próprio valor de retorno CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows e LLP64: o unsigned long de C continua com 32 bits
  CK_ULONG = LongWord;
{$ELSE}
  // Linux e macOS sao LP64: unsigned long segue a largura do ponteiro
  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;

O ponto é criar aliases de todos eles para CK_ULONG, em vez de usar LongWord ou UInt64 diretamente. Assim a condição aparece exatamente uma vez. Escreva qualquer um deles de forma concreta e você terá criado uma mina que uma portabilidade futura pisará, justamente no ponto que você esqueceu

O que pragma pack(1) faz com a function table do PKCS#11?

Ele desloca cada function pointer em CK_FUNCTION_LIST, porque a tabela começa com um CK_VERSION de dois bytes. Com alinhamento natural, o compilador insere seis bytes de padding depois dessa versão, então o primeiro function pointer cai no offset 8. Com packing em bytes, não há padding, então ele cai no offset 2. Toda entrada seguinte herda o mesmo deslocamento, por isso um erro de packing não é um problema de um campo, mas da tabela inteira. A armadilha é que os headers PKCS#11 aplicam #pragma pack(1) somente no Windows. É uma diferença de plataforma, não de módulo: dois builds da mesma biblioteca de fornecedor discordam dependendo do host de onde vieram. Observe também que packing não muda nada em estruturas cujos campos são todos de largura de ponteiro, que são a maioria, então um teste ingênuo que toque apenas em CK_SLOT_INFO passará alegremente enquanto a tabela abaixo está deslocada em 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;      // dois bytes, e a razao de a tabela se mover
    C_Initialize: Pointer;    // offset 2 packed, offset 8 alinhado
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... a tabela tem ordem fixa; declarar o prefixo
    // ate C_Sign basta para alcancar tudo que este backend chama
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Três coisas nesse bloco importam mais do que parecem. {$PACKRECORDS C} não é o mesmo que "nenhuma diretiva"; ele instrui o Free Pascal a seguir as regras de alinhamento do compilador C da plataforma, que é exatamente o contrato necessário no Linux e no macOS. O branch Delphi é incondicional {$A1} porque os builds Delphi do PDFiumPas têm como alvo o Windows, enquanto o FPC carrega os builds de Linux e macOS. E a linha de restauração no final não é cosmética: deixe a unit empacotada e todo record declarado depois desse ponto muda de layout silenciosamente, exatamente o tipo de defeito à distância que fortalecer um binding de componente PDFium contra falhas de ABI e segurança de memória pretende eliminar

Pkcs11AbiLayout: transformando o layout em uma asserção

Pkcs11AbiLayout informa o layout que o build realmente resolveu como uma única string que pode ser afirmada, no formato ulong=4 attr=16 pss=12 table=2. Um build Windows de 64 bits precisa reportar exatamente isso, e um alvo LP64 precisa reportar ulong=8 attr=24 pss=24 table=8. Qualquer outra coisa significa que uma chamada pela function table cairia no slot errado, e a função existe para que um teste possa dizer isso em voz alta, em vez de um comentário afirmar

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;

// No load, depois que C_GetFunctionList devolveu a tabela:
// uma versao implausivel ou um entry point nil significa que o record foi
// disposto com packing ou largura de CK_ULONG errados, portanto recuse o modulo
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;

Os quatro números não são arbitrários. attr é o tamanho de CK_ATTRIBUTE, que contém um CK_ULONG, um ponteiro e um CK_ULONG: 4 + 8 + 4 empacotado no Windows x64, 8 + 8 + 8 alinhado em LP64. pss é CK_RSA_PKCS_PSS_PARAMS, três campos CK_ULONG, portanto 12 ou 24. table é o offset do primeiro function pointer e é o valor que detecta primeiro um erro de packing. O caso de teste Delphi afirma a string sob {$IFDEF MSWINDOWS}; a suíte Lazarus afirma a mesma coisa. Uma única checagem de igualdade cobre um layout que, de outro modo, só poderia ser verificado lendo um header C lado a lado com um record Pascal e confiando em si mesmo. A verificação no load é a segunda metade da mesma ideia. O PDFiumPas resolve somente C_GetFunctionList por nome via GetProcAddress ou GetProcedureAddress e pega todos os outros entry points da tabela retornada por essa chamada, que é como a especificação base PKCS #11 da OASIS pretende que um módulo seja acessado e evita nomes de símbolos específicos por fornecedor. Depois ele faz um sanity check do que voltou. Uma versão principal fora de 2 a 3, ou um C_Initialize, C_GetSlotList ou C_Sign nil, significa que o record está desalinhado, e o módulo é descartado em vez de ser chamado

Assinando pela tabela: mecanismos, DigestInfo e o C_Sign em duas passagens

Com o layout correto, o trabalho de assinatura é pequeno, porque o contrato ICmsSigner que o PDFiumPas pede ao backend tem cinco métodos e quatro deles apenas retornam OIDs e o identificador do signer. Somente SignSignedAttrsDigest faz algo: recebe o digest SHA-256 de 32 bytes dos atributos assinados e retorna os bytes da assinatura. A montagem CMS, ASN.1, timestamping RFC 3161 e DSS/LTV são independentes da plataforma e já estão prontos, a mesma divisão de responsabilidades que permite que sessões remotas de assinatura PAdES contra um HSM ou serviço de chave na nuvem se conectem à mesma seam. Três detalhes de mecanismo custarão uma verificação falha se você os ignorar. CKM_RSA_PKCS aplica padding PKCS#1 v1.5, mas não constrói o DigestInfo, então o chamador acrescenta o prefixo DigestInfo SHA-256 de 19 bytes da RFC 8017; entregue o digest puro ao token e obterá uma assinatura bem formada sobre a coisa errada. CKM_RSA_PKCS_PSS e CKM_ECDSA recebem o digest como apresentado, mas CKM_ECDSA responde com o par bruto r||s, enquanto CMS precisa da SEQUENCE ECDSA-Sig-Value da RFC 3279 §2.2.3, então o PDFiumPas converte. E C_Sign é de duas passagens por design: chame-o com um buffer nil para pedir o comprimento da assinatura ao token e depois novamente com um buffer desse tamanho

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);
  // Registre isso antes de qualquer outra coisa quando um token se comportar mal em uma plataforma nova
  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 conhecer algumas coisas menores antes do primeiro token. Módulos são armazenados em cache pelo caminho porque C_Initialize ocorre uma vez por processo por módulo, e uma chamada repetida responde CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), que o PDFiumPas trata como sucesso, supondo que outra parte do host já inicializou a mesma biblioteca. Strings de token, como a descrição do slot e o label do token, são campos de largura fixa preenchidos com espaços, não terminados em NUL, então precisam ser aparados pela cauda. E CKO_CERTIFICATE é 1, não 2 — 0 é CKO_DATA e 2 é CKO_PUBLIC_KEY. Escrever essa constante de memória é um erro que produz resultado de busca vazio e nenhum erro

O que é verificado e onde a garantia termina

Seja claro sobre o limite, porque ele é mais estreito que a descrição do recurso sugere. O que hoje é verificado no PDFiumPas é que o layout da ABI corresponde campo a campo aos headers C nos dois branches, que um módulo ausente ou impossível de carregar degrada para uma falha reportada, em vez de crash, e que as toolchains Delphi e FPC compilam a unit. Os caminhos reais do token — C_Login, busca de objetos e C_Sign contra hardware — não foram exercitados, porque o host de desenvolvimento não tem módulo PKCS#11 instalado. Suba primeiro o SoftHSM2 e confirme Pkcs11AbiLayout antes de conectar um token físico, para que um problema de ABI e um problema do token nunca precisem ser diagnosticados ao mesmo tempo. Uma assimetria adicional merece ser nomeada. O lado de assinatura agora é cross-platform; o lado de verificação não é. A verificação CMS dentro do PDFiumPas ainda está protegida por {$IFDEF MSWINDOWS} e retorna pcsUnsupported em outros alvos, sem um ponto de injeção de provider equivalente ao backend de assinatura. Assim, um serviço Linux pode produzir uma assinatura PAdES B-B sobre uma chave mantida por token, mas ainda não consegue conferir a própria saída na mesma máquina. Planeje a verificação no Windows ou em um validator externo até essa lacuna ser fechada

A lição se generaliza além de PKCS#11. Todo record Pascal que espelha um struct C empacotado condicionalmente precisa de três coisas: um alias condicional para o escalar variável por plataforma, para que a decisão de largura exista em um único lugar; diretivas de packing que delimitem as declarações e sejam restauradas depois; e uma função de runtime que informe o layout resolvido como algo que um teste possa afirmar. Comentários dizendo que um struct corresponde ao header não valem nada; SizeOf e um offset de campo impresso na inicialização valem muito. O backend PKCS#11, o backend CNG e o restante da stack de assinatura fazem parte do PDFium Component for Delphi and C++Builder, em que o plumbing de ABI já está condicionado para que seu código possa permanecer do lado do token do problema