Technisch artikel

PKCS#11 in Delphi: CK_ULONG en de packing-valkuil

PDFiumPas ondertekent PAdES-documenten via een PKCS#11-token op Windows, Linux en macOS, en twee platformfeiten bepalen of die binding überhaupt werkt: CK_ULONG is het C-unsigned long, dus 4 bytes op Windows en 8 bytes op Linux en macOS, en de PKCS#11-headers gebruiken #pragma pack(1) alleen op Windows, waardoor elke pointer in de functietabel verschuift. Zit één van beide fout, dan laadt de module nog steeds, retourneren de calls nog steeds iets en zijn de getallen die terugkomen rommel. Dat is de bugvorm die je moet verwachten. Niemand geeft je een linkerfout, want er wordt niets gelinkt: de module is een .so, .dylib of .dll die je tijdens runtime op pad opent, en het volledige oppervlak is een struct met function pointers die je cast en aanroept. De compiler weet niet hoe de C-header aan de andere kant eruitzag. Elke mismatch blijft stil tot hij crasht

Waarom faalt een PKCS#11-binding met willekeurige CKR-codes in plaats van een nette fout?

Omdat een ABI-mismatch helemaal geen foutconditie produceert maar een verkeerd adres of een verkeerde offset, en de token plichtsgetrouw antwoord geeft op welke vraag dat uiteindelijk blijkt te zijn. Er is geen laag tussen je recorddeclaratie en de module die het verschil kan opmerken. Er komen twee verschillende faalmodi uit. Als de packing verkeerd is, bevat de slot die je als C_GetSlotList leest zes bytes van de ene pointer en twee van de volgende, en hem aanroepen springt naar niet-gemapte memory of erger nog naar het midden van een andere functie. Dat is de access violation. Als CK_ULONG de verkeerde breedte heeft, zijn de adressen goed maar de data niet: een out-parameter var Count: CK_ULONG die als 4 bytes breed is gedeclareerd krijgt van een LP64-module 8 bytes geschreven, waardoor stilletjes de volgende vier bytes van je stackframe worden overschreven, en in een CK_ATTRIBUTE-template waarvan ValueLen op de verkeerde offset staat, leest de module een lengteveld uit je Value-pointer. De token retourneert dan volkomen legitiem CKR_BUFFER_TOO_SMALL of CKR_ATTRIBUTE_VALUE_INVALID voor een vraag die je nooit hebt gesteld. Die codes sturen mensen urenlang naar de tokenconfiguratie op zoek. De bug zit vier regels hoger in een typedefinitie

CK_ULONG is de C unsigned long en geen type met vaste breedte

CK_ULONG is door de PKCS#11-headers gedefinieerd als een C-unsigned long, dus de breedte volgt het datamodel van het platform en niet de specificatie. Windows is LLP64, dus unsigned long blijft ook in een 64-bit-proces 32-bit. Linux en macOS zijn LP64, dus het volgt de pointer en wordt 64-bit. Dit is de belangrijkste regel in de hele unit, omdat in PKCS#11 praktisch elke scalar een CK_ULONG is: slot-ID's, session-handles, object-handles, objectclasses, keytypes, attributetypes, mechanismetypes, bufferlengtes en de return value CK_RV zelf

type
{$IFDEF MSWINDOWS}
  // Windows is LLP64: een C unsigned long blijft daar 32-bit
  CK_ULONG = LongWord;
{$ELSE}
  // Linux en macOS zijn LP64: unsigned long volgt daar de pointerbreedte
  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;

Elke daarvan aliasen naar CK_ULONG en niet rechtstreeks naar LongWord of UInt64 is precies het punt van de oefening. De conditional verschijnt dan exact één keer. Schrijf een type concreet uit en je hebt een landmijn gelegd waar een toekomstige port op stapt, precies op de plek die je bent vergeten

Wat doet pragma pack(1) met de PKCS#11-functietabel?

Het verschuift elke function pointer in CK_FUNCTION_LIST, omdat de tabel begint met een CK_VERSION van twee bytes. Bij natuurlijke alignment voegt de compiler na die versie zes paddingbytes toe, zodat de eerste function pointer op offset 8 komt. Bij byte packing is er geen padding en komt hij op offset 2. Elke volgende entry erft dezelfde verschuiving, en daarom is een packingfout geen probleem van één veld maar van de hele tabel. De valkuil is dat de PKCS#11-headers #pragma pack(1) alleen op Windows gebruiken. Het is een platformverschil en geen moduleverschil: twee builds van dezelfde vendorlibrary verschillen hierover afhankelijk van de host waarop ze vandaan komen. Merk ook op dat packing niets verandert voor structuren waarvan alle velden pointerbreed zijn, wat voor de meeste geldt, dus een naïeve test die alleen CK_SLOT_INFO aanraakt, slaagt vrolijk terwijl de onderliggende tabel zes bytes verschoven is

{$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;      // twee bytes, en de reden dat de tabel verschuift
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... de tabel staat in een vaste volgorde; de prefix
    // tot en met C_Sign is genoeg om alles te bereiken wat deze backend aanroept
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Drie dingen in dat blok zijn belangrijker dan ze lijken. {$PACKRECORDS C} is niet hetzelfde als "geen directive"; het zegt Free Pascal de alignmentregels van de C-compiler op het platform te volgen, precies het contract dat je op Linux en macOS nodig hebt. De Delphitak is onvoorwaardelijk {$A1} omdat Delphi-builds van PDFiumPas op Windows zijn gericht, terwijl FPC de Linux- en macOS-builds draagt. En de restore-regel onderaan is niet cosmetisch: laat de unit packed en elke record die daarna wordt gedeclareerd verandert stilletjes van layout, precies het soort action-at-a-distance-defect dat een PDFium-componentbinding hardenen tegen ABI- en memory-safetyfouten moet elimineren

Pkcs11AbiLayout: de layout omzetten in een assertion

Pkcs11AbiLayout rapporteert de layout die de build werkelijk heeft geresolved als één assertable string in de vorm ulong=4 attr=16 pss=12 table=2. Een 64-bit Windows-build moet precies dat rapporteren, en een LP64-target moet ulong=8 attr=24 pss=24 table=8 rapporteren. Alles anders betekent dat een call door de functietabel op de verkeerde slot zou landen, en de functie bestaat zodat een unit test dat hardop kan zeggen in plaats van een commentaar het te laten beweren

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;

// Bij het laden, nadat C_GetFunctionList de tabel heeft teruggegeven:
// een onwaarschijnlijke versie of een nil-entrypoint betekent dat het record
// met de verkeerde packing of CK_ULONG-breedte is aangelegd, dus weiger de 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;

De vier getallen zijn niet willekeurig. attr is de grootte van CK_ATTRIBUTE, met een CK_ULONG, een pointer en een CK_ULONG: 4 + 8 + 4 packed op Windows x64 en 8 + 8 + 8 aligned op LP64. pss is CK_RSA_PKCS_PSS_PARAMS, drie CK_ULONG-velden, dus 12 of 24. table is de offset van de eerste function pointer en het is de waarde die een packingfout het eerst vangt. De Delphi-testcase assert de string onder {$IFDEF MSWINDOWS}; de Lazartussuite assert hetzelfde. Eén equality check dekt een layout af die anders alleen verifieerbaar zou zijn door een C-header naast een Pascal-record te leggen en jezelf te vertrouwen. De load-time-check is de tweede helft van hetzelfde idee. PDFiumPas resolveert alleen C_GetFunctionList op naam via GetProcAddress of GetProcedureAddress en haalt elk ander entrypoint uit de tabel die die call retourneert, precies zoals de OASIS-basisspecificatie voor PKCS #11 bedoelt dat een module wordt bereikt, en omzeilt zo vendor-specifieke symboolnamen. Daarna sanity-checkt het wat terugkwam. Een major version buiten 2 tot 3 of een nil C_Initialize, C_GetSlotList of C_Sign betekent dat het record verkeerd uitgelijnd is en de module wordt verwijderd in plaats van erdoorheen te worden aangeroepen

Ondertekenen via de tabel: mechanismen, DigestInfo en de two-pass C_Sign

Zodra de layout klopt is het signeren klein werk, omdat het ICmsSigner-contract waaraan PDFiumPas een backend laat voldoen vijf methoden heeft en vier daarvan alleen OID's en de signer identifier teruggeven. Alleen SignSignedAttrsDigest doet iets: hij neemt de SHA-256-digest van 32 bytes van de signed attributes aan en retourneert signaturebytes. CMS-assemblage, ASN.1, RFC 3161-timestamping en DSS/LTV zijn allemaal platformonafhankelijk en al afgehandeld, dezelfde arbeidsverdeling waardoor remote PAdES-signingsessies tegen een HSM of een cloud-keyservice op dezelfde seam kunnen aansluiten. Drie mechanismedetails kosten je een mislukte verificatie als je ze overslaat. CKM_RSA_PKCS past PKCS#1 v1.5-padding toe maar bouwt de DigestInfo niet, dus de caller voegt zelf de 19-byte SHA-256-DigestInfo-prefix uit RFC 8017 toe; geef de kale digest aan de token en je krijgt een goed gevormde signature over het verkeerde. CKM_RSA_PKCS_PSS en CKM_ECDSA nemen de digest zoals aangeleverd, maar CKM_ECDSA antwoordt met het raw r||s-paar, terwijl CMS de ECDSA-Sig-Value-SEQUENCE uit RFC 3279 §2.2.3 nodig heeft, dus PDFiumPas converteert. En C_Sign is by design two-pass: roep hem aan met een nil-buffer om de token naar de signaturelengte te vragen en daarna opnieuw met een buffer van die grootte

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);
  // Log dit voordat je iets anders doet wanneer een token zich op een nieuw platform misdraagt
  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;

Een paar kleinere dingen moet je kennen vóór je eerste token. Modules worden op pad gecachet omdat C_Initialize één keer per proces per module gebeurt, en een herhaalde call CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190) antwoordt, wat PDFiumPas als succes behandelt in de veronderstelling dat een ander deel van de host dezelfde library al heeft geïnitialiseerd. Tokenstrings zoals slotbeschrijving en tokenlabel zijn met spaties opgevulde velden met vaste breedte en geen NUL-terminated strings, dus ze moeten aan het eind worden getrimd. En CKO_CERTIFICATE is 1 en niet 2 — 0 is CKO_DATA en 2 is CKO_PUBLIC_KEY. Die constante uit het geheugen opschrijven is een fout die een lege zoekopdracht en helemaal geen fout produceert

Wat wordt geverifieerd en waar stopt de garantie?

Wees duidelijk over de grens, want die is smaller dan de featurebeschrijving suggereert. Wat PDFiumPas vandaag verifieert is dat de ABI-layout op beide branches veld voor veld met de C-headers overeenkomt, dat een ontbrekende of niet-laadbare module terugvalt op een gemelde fout in plaats van een crash en dat zowel de Delphi- als de FPC-toolchain de unit bouwen. De echte tokenpaden — C_Login, object search en C_Sign tegen hardware — zijn niet uitgevoerd, omdat er op de ontwikkelhost helemaal geen PKCS#11-module is geïnstalleerd. Breng eerst SoftHSM2 omhoog en bevestig Pkcs11AbiLayout voordat je een fysieke token aansluit, zodat een ABI-probleem en een tokenprobleem nooit tegelijk hoeven te worden gediagnosticeerd. Nog één asymmetrie moet worden genoemd. De signeringskant is nu cross-platform en de verificatiekant niet. CMS-verificatie in PDFiumPas wordt nog steeds bewaakt door {$IFDEF MSWINDOWS} en retourneert elders pcsUnsupported, en er is geen provider-injectiepunt equivalent aan de signerbackend. Een Linux-service kan dus een PAdES B-B-signature over een token-held key produceren en kan zijn eigen output op dezelfde machine nog niet controleren. Plan de verificatiestap op Windows of op een externe validator totdat dat gat is gedicht

De les generaliseert verder dan PKCS#11. Elk Pascal-record dat een conditionally packed C-struct spiegelt heeft drie dingen nodig: één conditional alias voor de platformvariabele scalar zodat de breedtebeslissing exact op één plek bestaat, packingdirectives die de declaraties omsluiten en daarna worden hersteld en een runtimefunctie die de opgeloste layout rapporteert als iets waarop een test kan asserten. Commentaren die beweren dat een struct met zijn header overeenkomt zijn niets waard; SizeOf en een bij het opstarten uitgeprinte veldoffset zijn veel waardevoller. De PKCS#11-backend, de CNG-backend en de rest van de signingstack worden geleverd in de PDFium Component voor Delphi en C++Builder, waar de ABI-plumbing al is geconditioneerd zodat jouw code aan de tokenkant van het probleem kan blijven