Article technique

PKCS#11 dans Delphi : piège de CK_ULONG et du packing

PDFiumPas signe des documents PAdES via un token PKCS#11 sous Windows, Linux et macOS, et deux faits de plateforme décident du bon fonctionnement de la liaison : CK_ULONG est l’unsigned long C, donc 4 octets sous Windows et 8 octets sous Linux et macOS, et les en-têtes PKCS#11 n’appliquent #pragma pack(1) que sous Windows, ce qui déplace chaque pointeur de la table de fonctions. Se tromper sur l’un ou l’autre et le module se charge encore, les appels reviennent encore, mais les nombres reçus sont incohérents. Voilà la forme du bug à attendre. Personne ne vous remet une erreur de l’éditeur de liens, car rien n’est lié : le module est un .so, un .dylib ou un .dll ouvert à l’exécution par chemin, et toute la surface est une structure de pointeurs de fonctions que vous castez puis appelez. Le compilateur ne sait pas à quoi ressemblait l’en-tête C de l’autre côté. Chaque divergence reste silencieuse jusqu’au crash

Pourquoi une liaison PKCS#11 échoue-t-elle avec des codes CKR aléatoires plutôt qu’une erreur claire ?

Parce qu’une divergence d’ABI ne produit aucune condition d’erreur ; elle produit une mauvaise adresse ou un mauvais offset, et le token répond consciencieusement à la question que cela finit par représenter. Aucune couche entre la déclaration de votre record et le module ne peut remarquer le désaccord. Deux modes d’échec distincts en résultent. Si le packing est incorrect, l’emplacement que vous lisez comme C_GetSlotList contient six octets d’un pointeur et deux du suivant ; l’appeler saute dans une mémoire non mappée ou, pire, au milieu d’une autre fonction. C’est la violation d’accès. Si CK_ULONG a la mauvaise largeur, les adresses sont correctes mais les données ne le sont pas : un paramètre de sortie var Count: CK_ULONG déclaré sur 4 octets reçoit 8 octets écrits par un module LP64, écrasant silencieusement les quatre octets suivants de votre frame de pile, et un template CK_ATTRIBUTE dont ValueLen se trouve au mauvais offset fait lire au module un champ de longueur dans votre pointeur Value. Le token renvoie alors un CKR_BUFFER_TOO_SMALL ou un CKR_ATTRIBUTE_VALUE_INVALID parfaitement légitime pour une question que vous n’avez jamais posée. Ces codes envoient les équipes chercher dans la configuration du token pendant des heures. Le bug se trouve quatre lignes plus haut dans une déclaration de type

CK_ULONG est l’unsigned long C, pas un type de largeur fixe

Les en-têtes PKCS#11 définissent CK_ULONG comme un unsigned long C, ce qui signifie que sa largeur suit le modèle de données de la plateforme plutôt que la spécification. Windows est LLP64, si bien que unsigned long reste sur 32 bits même dans un processus 64 bits. Linux et macOS sont LP64, il suit donc la largeur des pointeurs et devient 64 bits. C’est la ligne la plus lourde de conséquences dans toute l’unité, car en PKCS#11 presque chaque scalaire est un CK_ULONG : identifiants de slots, handles de session, handles d’objets, classes d’objets, types de clés, types d’attributs, types de mécanismes, longueurs de tampons et même la valeur de retour CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows est LLP64 : un unsigned long C y reste sur 32 bits
  CK_ULONG = LongWord;
{$ELSE}
  // Linux et macOS sont LP64 : unsigned long suit la largeur du pointeur
  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;

Donner un alias de tous ces types vers CK_ULONG plutôt que vers LongWord ou UInt64 directement est précisément le but de l’exercice. La condition apparaît ainsi une seule fois. En écrire un concrètement revient à poser une mine qu’un futur portage déclenchera, exactement à l’endroit que vous aurez oublié

Que fait pragma pack(1) à la table de fonctions PKCS#11 ?

Il décale chaque pointeur de fonction dans CK_FUNCTION_LIST, car la table commence par un CK_VERSION de deux octets. Avec l’alignement naturel, le compilateur insère six octets de remplissage après cette version, et le premier pointeur de fonction tombe à l’offset 8. Avec le packing par octet, il n’y a aucun remplissage et il tombe à l’offset 2. Chaque entrée suivante hérite du même déplacement, raison pour laquelle une erreur de packing ne concerne pas un champ mais toute la table. Le piège est que les en-têtes PKCS#11 appliquent #pragma pack(1) sous Windows seulement. C’est une différence de plateforme, pas de module : deux builds de la même bibliothèque fournisseur divergent selon l’hôte dont ils proviennent. Notez aussi que le packing ne change rien aux structures dont tous les champs sont de largeur pointeur, ce qui constitue la majorité d’entre elles ; un test naïf qui ne touche que CK_SLOT_INFO réussira donc joyeusement tandis que la table sous-jacente sera déplacée de six octets

{$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;      // deux octets, raison du déplacement de la table
    C_Initialize: Pointer;    // offset 2 compacté, offset 8 aligné
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... la table est dans un ordre fixe ; déclarer le préfixe
    // jusqu’à C_Sign suffit pour atteindre tout ce qu’appelle ce backend
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Trois éléments de ce bloc comptent davantage qu’il n’y paraît. {$PACKRECORDS C} n’est pas la même chose que « aucune directive » : il indique à Free Pascal de suivre les règles d’alignement du compilateur C de la plateforme, précisément le contrat nécessaire sous Linux et macOS. La branche Delphi est un {$A1} inconditionnel parce que les builds Delphi de PDFiumPas ciblent Windows, tandis que FPC porte les builds Linux et macOS. Et la ligne de restauration à la fin n’est pas cosmétique : laissez l’unité compactée et chaque record déclaré ensuite changera silencieusement de disposition, exactement le type de défaut à effet lointain que le renforcement d’une liaison de composant PDFium contre les défauts d’ABI et de sûreté mémoire cherche à éliminer

Pkcs11AbiLayout : transformer la disposition en assertion

Pkcs11AbiLayout signale la disposition effectivement résolue par le build sous la forme d’une chaîne vérifiable comme ulong=4 attr=16 pss=12 table=2. Un build Windows 64 bits doit renvoyer exactement cela et une cible LP64 doit renvoyer ulong=8 attr=24 pss=24 table=8. Toute autre valeur signifie qu’un appel via la table de fonctions arriverait sur le mauvais slot ; cette fonction existe pour qu’un test puisse le dire explicitement, plutôt que de s’en remettre à un commentaire qui l’affirme

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;

// Au chargement, après que C_GetFunctionList a renvoyé la table :
// une version invraisemblable ou un point d’entrée nil signifie que le record
// a été disposé avec le mauvais packing ou la mauvaise largeur de CK_ULONG ; refuser le module
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;

Les quatre nombres ne sont pas arbitraires. attr est la taille de CK_ATTRIBUTE, qui contient un CK_ULONG, un pointeur et un CK_ULONG : 4 + 8 + 4 sous Windows x64 compacté, 8 + 8 + 8 aligné en LP64. pss est CK_RSA_PKCS_PSS_PARAMS, trois champs CK_ULONG, donc 12 ou 24. table est l’offset du premier pointeur de fonction et c’est la valeur qui détecte en premier une erreur de packing. Le test Delphi vérifie la chaîne sous {$IFDEF MSWINDOWS} ; la suite Lazarus vérifie la même chose. Un seul contrôle d’égalité couvre une disposition qui ne serait sinon vérifiable qu’en lisant côte à côte un en-tête C et un record Pascal et en vous faisant confiance. Le contrôle au chargement est la seconde moitié de la même idée. PDFiumPas résout uniquement C_GetFunctionList par nom via GetProcAddress ou GetProcedureAddress et prend tous les autres points d’entrée dans la table renvoyée par cet appel, comme le prévoit la spécification de base PKCS #11 de l’OASIS, tout en évitant les conventions de nommage propres à chaque fournisseur. Il vérifie ensuite la plausibilité du résultat. Une version majeure hors de 2 à 3, ou un C_Initialize, C_GetSlotList ou C_Sign nul, signifie que le record est mal aligné ; le module est abandonné plutôt qu’invoqué

Signer via la table : mécanismes, DigestInfo et C_Sign en deux passes

Une fois la disposition correcte, le travail de signature est réduit, car le contrat ICmsSigner que PDFiumPas demande à un backend de satisfaire comporte cinq méthodes, dont quatre ne font que renvoyer des OID et l’identifiant du signataire. Seule SignSignedAttrsDigest agit réellement : elle prend le digest SHA-256 de 32 octets des attributs signés et renvoie les octets de signature. L’assemblage CMS, l’ASN.1, l’horodatage RFC 3161 et DSS/LTV sont indépendants de la plateforme et déjà réalisés ; c’est la même répartition qui permet aux sessions de signature PAdES distantes avec un HSM ou un service de clé cloud de se brancher sur le même point d’extension. Trois détails de mécanisme vous coûteront une vérification échouée si vous les ignorez. CKM_RSA_PKCS applique le padding PKCS#1 v1.5 mais ne construit pas le DigestInfo, l’appelant doit donc préfixer lui-même le digest avec le préfixe DigestInfo SHA-256 de 19 octets de la RFC 8017 ; remettez le digest nu au token et vous obtenez une signature bien formée sur la mauvaise donnée. CKM_RSA_PKCS_PSS et CKM_ECDSA prennent le digest tel quel, mais CKM_ECDSA répond par la paire brute r||s, tandis que CMS attend la SEQUENCE ECDSA-Sig-Value de la RFC 3279 §2.2.3 ; PDFiumPas la convertit donc. Enfin, C_Sign est conçu en deux passes : appelez-le avec un tampon nil pour demander au token la longueur de la signature, puis une seconde fois avec un tampon de cette taille

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);
  // Journaliser ceci avant toute autre chose lorsqu’un token se comporte mal sur une nouvelle plateforme
  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;

Quelques détails plus petits méritent d’être connus avant votre premier token. Les modules sont mis en cache par chemin parce que C_Initialize n’est appelé qu’une fois par processus et par module, et qu’un appel répété renvoie CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), que PDFiumPas traite comme une réussite en supposant qu’une autre partie de l’hôte a déjà initialisé la même bibliothèque. Les chaînes du token, comme la description du slot et le libellé du token, sont des champs de largeur fixe complétés par des espaces, pas terminés par NUL ; il faut donc les rogner par la fin. Enfin, CKO_CERTIFICATE vaut 1, pas 2 — 0 vaut CKO_DATA et 2 CKO_PUBLIC_KEY. Écrire cette constante de mémoire est une erreur qui produit un résultat de recherche vide sans la moindre erreur

Ce qui est vérifié et l’endroit où la garantie s’arrête

Il faut bien cerner la frontière, car elle est plus étroite que ne le suggère la description de la fonctionnalité. Ce qui est vérifié aujourd’hui dans PDFiumPas, c’est que la disposition ABI correspond champ par champ aux en-têtes C sur les deux branches, qu’un module absent ou impossible à charger se dégrade en échec signalé plutôt qu’en crash et que les toolchains Delphi et FPC compilent l’unité. Les vrais chemins du token — C_Login, recherche d’objets, C_Sign sur matériel — n’ont pas été exercés, car l’hôte de développement ne possède aucun module PKCS#11 installé. Mettez d’abord SoftHSM2 en place et confirmez Pkcs11AbiLayout avant de brancher un token physique, afin de ne jamais devoir diagnostiquer en même temps un problème d’ABI et un problème de token. Une autre asymétrie mérite d’être nommée. Le côté signature est désormais multiplateforme ; le côté vérification ne l’est pas. La vérification CMS dans PDFiumPas reste protégée par {$IFDEF MSWINDOWS} et renvoie pcsUnsupported ailleurs, sans point d’injection de fournisseur équivalent à celui du backend de signature. Un service Linux peut donc produire une signature PAdES B-B avec une clé détenue par un token et ne peut pas encore vérifier sa propre sortie sur la même machine. Planifiez la vérification sous Windows ou avec un validateur externe jusqu’à la résolution de cette lacune

La leçon dépasse PKCS#11. Tout record Pascal qui reproduit une structure C dont le packing est conditionnel a besoin de trois choses : un alias conditionnel pour le scalaire dont la plateforme fait varier la largeur, afin que cette décision n’existe qu’à un seul endroit ; des directives de packing qui encadrent les déclarations et sont restaurées ensuite ; et une fonction d’exécution qui signale la disposition résolue sous une forme vérifiable par un test. Les commentaires affirmant qu’un struct correspond à son en-tête ne valent rien ; un SizeOf et un offset de champ affichés au démarrage valent beaucoup. Le backend PKCS#11, le backend CNG et le reste de la pile de signature sont livrés dans le PDFium Component for Delphi and C++Builder, où la plomberie ABI est déjà conditionnée afin que votre code puisse rester du côté token du problème