Artigo Técnico

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

O PDFiumPas assina documentos PAdES através de um token PKCS#11 em Windows, Linux e macOS, e dois factos da plataforma decidem se essa ligação funciona de todo: CK_ULONG é o unsigned long do C, portanto tem 4 bytes no Windows e 8 bytes em Linux e macOS, e os headers PKCS#11 aplicam #pragma pack(1) apenas no Windows, deslocando cada ponteiro da function table. Erre qualquer um dos dois e o módulo continua a carregar, as chamadas continuam a regressar e os números devolvidos são lixo. Essa é a forma do bug que deve esperar. Ninguém lhe entrega um erro de linker, porque nada é ligado: o módulo é um .so, .dylib ou .dll que abre em runtime por caminho e toda a superfície é um struct de ponteiros de função que converte e chama. O compilador não faz ideia de como era o header C do outro lado. Cada incompatibilidade é silenciosa até ser um crash

Porque é que uma ligação PKCS#11 falha com códigos CKR aleatórios em vez de um erro limpo?

Porque uma incompatibilidade de ABI não produz de todo uma condição de erro: produz um endereço ou offset errado e o token responde obedientemente à pergunta que isso acaba por representar. Não existe uma camada entre a declaração do seu record e o módulo que possa notar a divergência. Daí saem dois modos de falha distintos. Se o packing estiver errado, o slot que lê 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 ainda, para o meio de outra função. Essa é a violação de acesso. 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 escritos por um módulo LP64, sobrescrevendo silenciosamente os quatro bytes seguintes da sua stack frame, e um template CK_ATTRIBUTE cujo ValueLen esteja no offset errado faz o módulo ler um campo de comprimento a partir do seu ponteiro Value. O token devolve então um CKR_BUFFER_TOO_SMALL ou CKR_ATTRIBUTE_VALUE_INVALID perfeitamente legítimo para uma pergunta que nunca fez. Esses códigos enviam pessoas durante horas à procura de problemas na configuração do token. O bug está quatro linhas acima, numa declaração de tipo

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

CK_ULONG é definido pelos headers PKCS#11 como um unsigned long C, o que significa que a sua largura segue o modelo de dados da plataforma e não a especificação. O Windows é LLP64, pelo que unsigned long continua a ter 32 bits mesmo num processo de 64 bits. Linux e macOS são LP64, pelo que acompanha o ponteiro e passa a ter 64 bits. Esta é a linha com mais consequências em toda a unidade, porque em PKCS#11 praticamente todos os escalares são CK_ULONG: IDs de slots, handles de sessões, handles de objetos, classes de objetos, tipos de chaves, tipos de atributos, tipos de mecanismos, comprimentos de buffers e o próprio valor de retorno CK_RV

type
{$IFDEF MSWINDOWS}
  // O Windows é LLP64: um unsigned long C continua a ter 32 bits aqui
  CK_ULONG = LongWord;
{$ELSE}
  // Linux e macOS são 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;

Colocar todos esses aliases em CK_ULONG em vez de em LongWord ou UInt64 diretamente é o objetivo do exercício. Significa que a condição aparece exatamente uma vez. Escreva qualquer um deles de forma concreta e terá colocado uma mina que uma portabilidade futura pisará, precisamente no local que se esqueceu

O que faz pragma pack(1) à function table PKCS#11?

Desloca todos os ponteiros de função em CK_FUNCTION_LIST, porque a tabela começa com um CK_VERSION de dois bytes. Sob alinhamento natural, o compilador insere seis bytes de padding depois dessa versão, pelo que o primeiro ponteiro de função fica no offset 8. Sob packing por bytes, não há padding e fica no offset 2. Cada entrada seguinte herda o mesmo deslocamento, razão pela qual um erro de packing não é um problema de um campo, mas de toda a tabela. A armadilha é que os headers PKCS#11 aplicam #pragma pack(1) apenas no Windows. É uma diferença da plataforma, não do módulo: dois builds da mesma biblioteca do fornecedor discordam sobre isto consoante o host de onde vieram. Note também que o packing não altera nada em estruturas cujos campos são todos da largura de ponteiro, que são a maioria, pelo que um teste ingénuo que toque apenas em CK_SLOT_INFO passará tranquilamente enquanto a tabela por baixo está deslocada 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 razão pela qual a tabela se move
    C_Initialize: Pointer;    // offset 2 packed, offset 8 alinhado
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... a tabela tem uma ordem fixa; declarar o prefixo
    // até C_Sign basta para chegar a tudo o 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 parece. {$PACKRECORDS C} não é o mesmo que "nenhuma diretiva"; diz ao Free Pascal para seguir as regras de alinhamento do compilador C da plataforma, que é precisamente o contrato necessário em Linux e macOS. O ramo Delphi é {$A1} incondicional porque os builds Delphi do PDFiumPas têm como alvo o Windows, enquanto o FPC transporta os builds Linux e macOS. E a linha de restauro no fundo não é cosmética: deixe a unidade empacotada e todos os records declarados depois deste ponto mudam silenciosamente de layout, exatamente o tipo de defeito à distância que endurecer uma binding PDFium contra falhas de ABI e segurança de memória pretende eliminar

Pkcs11AbiLayout: transformar o layout numa asserção

Pkcs11AbiLayout comunica o layout que o build realmente resolveu como uma única string passível de asserção, no formato ulong=4 attr=16 pss=12 table=2. Um build Windows de 64 bits tem de comunicar exatamente isso e um alvo LP64 tem de comunicar ulong=8 attr=24 pss=24 table=8. Qualquer outra coisa significa que uma chamada através da function table cairia no slot errado, e a função existe para que um teste o diga em voz alta em vez de um comentário o 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 carregamento, depois de C_GetFunctionList devolver a tabela:
// uma versão implausível ou um ponto de entrada nil significa que o record foi disposto
// com o packing ou a largura CK_ULONG errados, por isso recuse o 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;

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 em packing no Windows x64, 8 + 8 + 8 alinhados em LP64. pss é CK_RSA_PKCS_PSS_PARAMS, três campos CK_ULONG, portanto 12 ou 24. table é o offset do primeiro ponteiro de função e é o valor que apanha primeiro um erro de packing. O caso de teste Delphi afirma a string sob {$IFDEF MSWINDOWS}; a suite Lazarus afirma o mesmo. Uma verificação de igualdade cobre um layout que, de outro modo, só poderia ser validado lendo um header C lado a lado com um record Pascal e confiando em si próprio. A verificação no carregamento é a segunda metade da mesma ideia. O PDFiumPas resolve apenas C_GetFunctionList por nome através de GetProcAddress ou GetProcedureAddress e retira todos os restantes pontos de entrada da tabela que essa chamada devolve, que é a forma prevista pela especificação base OASIS PKCS #11 para alcançar um módulo e evita nomes de símbolos específicos do fornecedor. Depois faz uma verificação de sanidade do que recebeu. 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 através dele

Assinar através da tabela: mecanismos, DigestInfo e o C_Sign em duas passagens

Quando o layout está correto, o trabalho de assinatura é pequeno, porque o contrato ICmsSigner que o PDFiumPas pede a um backend para satisfazer tem cinco métodos e quatro deles apenas devolvem OIDs e o identificador do signatário. Só SignSignedAttrsDigest faz alguma coisa: recebe o digest SHA-256 de 32 bytes dos atributos assinados e devolve os bytes da assinatura. A montagem CMS, ASN.1, timestamping RFC 3161 e DSS/LTV são todos independentes da plataforma e já estão feitos, que é a mesma divisão de trabalho que permite às sessões de assinatura PAdES remotas contra um HSM ou serviço de chaves cloud ligarem-se à mesma costura. Três detalhes de mecanismos custam-lhe uma verificação falhada se os ignorar. CKM_RSA_PKCS aplica padding PKCS#1 v1.5 mas não constrói o DigestInfo, pelo que o chamador acrescenta por si o prefixo DigestInfo SHA-256 de 19 bytes do RFC 8017; entregue o digest simples ao token e obterá uma assinatura bem formada sobre a coisa errada. CKM_RSA_PKCS_PSS e CKM_ECDSA recebem o digest como se apresenta, mas CKM_ECDSA responde com o par r||s cru e o CMS precisa da SEQUENCE ECDSA-Sig-Value do RFC 3279 §2.2.3, pelo que o PDFiumPas faz a conversão. E C_Sign é de propósito em duas passagens: chame-o com um buffer nil para pedir ao token o comprimento da assinatura 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);
  // Registe isto antes de qualquer outra coisa quando um token se comportar mal numa 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;

Há algumas coisas pequenas que vale a pena saber antes do primeiro token. Os módulos são colocados em cache pelo caminho porque C_Initialize ocorre uma vez por processo e por módulo, e uma chamada repetida responde CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), que o PDFiumPas trata como sucesso partindo do princípio de que outra parte do host já inicializou a mesma biblioteca. As strings do token, como a descrição do slot e a etiqueta do token, são campos de largura fixa preenchidos com espaços e não terminados em NUL, pelo que têm de ser aparadas a partir do fim. 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 um resultado de pesquisa vazio e absolutamente nenhum erro

O que é verificado e onde termina a garantia?

Seja claro sobre a fronteira, porque é mais estreita do que a descrição da funcionalidade sugere. O que é verificado hoje no PDFiumPas é que o layout da ABI corresponde campo a campo aos headers C nos dois ramos, que um módulo ausente ou impossível de carregar degrada para uma falha comunicada e não para um crash e que tanto o toolchain Delphi como o FPC compilam a unidade. Os percursos de token reais — C_Login, pesquisa de objetos e C_Sign contra hardware — não foram exercitados, porque a máquina de desenvolvimento não tem qualquer módulo PKCS#11 instalado. Faça primeiro o bring-up com SoftHSM2 e confirme Pkcs11AbiLayout antes de ligar um token físico, para que um problema de ABI e um problema do token nunca tenham de ser diagnosticados ao mesmo tempo. Vale a pena nomear mais uma assimetria. O lado da assinatura é agora multiplataforma; o lado da verificação não é. A verificação CMS dentro do PDFiumPas continua protegida por {$IFDEF MSWINDOWS} e devolve pcsUnsupported nos outros alvos, sem um ponto de injeção de providers equivalente ao backend de assinatura. Assim, um serviço Linux pode produzir uma assinatura PAdES B-B sobre uma chave guardada no token e ainda não pode verificar a própria saída na mesma máquina. Planeie a verificação no Windows ou num validador externo até essa lacuna fechar

A lição generaliza-se para além de PKCS#11. Qualquer record Pascal que replique uma struct C com packing condicional precisa de três coisas: um alias condicional para o escalar variável da plataforma, para que a decisão da largura exista num único local, diretivas de packing que envolvam as declarações e sejam restauradas depois e uma função de runtime que comunique o layout resolvido como algo que um teste possa afirmar. Comentários que dizem que um struct corresponde ao seu header não valem nada; SizeOf e um offset de campo impresso no arranque valem muito. O backend PKCS#11, o backend CNG e o resto da stack de assinatura são distribuídos no PDFium Component for Delphi and C++Builder, onde a canalização ABI já está condicionada para que o seu código possa ficar do lado do token do problema