Teknisk artikel

PKCS#11 i Delphi: CK_ULONG och packningsfällan

PDFiumPas signerar PAdES-dokument genom en PKCS#11-token i Windows, Linux och macOS, och två plattformsfakta avgör om bindningen fungerar alls: CK_ULONG är C:s unsigned long, alltså 4 byte i Windows och 8 byte i Linux och macOS, och PKCS#11-huvudena tillämpar #pragma pack(1) endast i Windows, vilket flyttar varje pekare i funktionstabellen. Få någon av dessa fel och modulen laddas fortfarande, anropen returnerar fortfarande och värdena som kommer tillbaka är skräp. Det är den form av bugg du bör förvänta dig. Ingen ger dig ett länkfel, eftersom ingenting länkas: modulen är en .so, .dylib eller .dll som du öppnar vid körning via sökväg, och hela ytan är en post med funktionspekare som du kastar och anropar. Kompilatorn har ingen aning om hur C-huvudet på andra sidan såg ut. Varje mismatch är tyst tills den blir en krasch

Varför misslyckas en PKCS#11-bindning med slumpmässiga CKR-koder i stället för ett rent fel?

För att en ABI-mismatch inte producerar ett feltillstånd alls, utan en felaktig adress eller en felaktig offset, och token svarar plikttroget på vilken fråga det råkar vara. Det finns inget lager mellan din postdeklaration och modulen som kan upptäcka oenigheten. Två distinkta fellägen kommer ur den. Om packningen är fel innehåller platsen du läser som C_GetSlotList sex byte från en pekare och två från nästa, och att anropa den hoppar in i omappat minne eller, värre, mitt i någon annan funktion. Det är åtkomstöverträdelsen. Om CK_ULONG har fel bredd är adresserna rätt men datan fel: en var Count: CK_ULONG-utparameter som deklarerats 4 byte bred skrivs över 8 byte av en LP64-modul och skriver tyst över de följande fyra bytena i stackramen, och en CK_ATTRIBUTE-mall vars ValueLen ligger på fel offset får modulen att läsa ett längdfält ur din Value-pekare. Token returnerar då en helt legitim CKR_BUFFER_TOO_SMALL eller CKR_ATTRIBUTE_VALUE_INVALID för en fråga du aldrig ställde. De koderna skickar människor på jakt genom tokenkonfigurationen i timmar. Buggen ligger fyra rader upp i en typdeklaration

CK_ULONG är C:s unsigned long, inte en typ med fast bredd

CK_ULONG definieras av PKCS#11-huvudena som C:s unsigned long, vilket betyder att bredden följer plattformens datamodell i stället för specifikationen. Windows är LLP64, så unsigned long förblir 32-bitars även i en 64-bitarsprocess. Linux och macOS är LP64, så den följer pekaren och blir 64-bitars. Detta är den mest avgörande raden i hela enheten, eftersom praktiskt taget varje skalär i PKCS#11 är en CK_ULONG: plats-ID:n, sessionshandtag, objekthandtag, objektklasser, nyckeltyper, attributtyper, mekanismtyper, buffertlängder och själva returvärdet CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows är LLP64: en C unsigned long förblir 32-bitars där
  CK_ULONG = LongWord;
{$ELSE}
  // Linux och macOS är LP64: unsigned long följer pekarbredden
  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;

Att aliasa var och en av dem till CK_ULONG i stället för direkt till LongWord eller UInt64 är poängen med övningen. Det innebär att villkoret bara finns en gång. Skriv ut någon av dem konkret och du har lagt ut en mina som en framtida portning kommer att trampa på, och den trampar på exakt den plats du glömde

Vad gör pragma pack(1) med PKCS#11:s funktionstabell?

Den flyttar varje funktionspekare i CK_FUNCTION_LIST eftersom tabellen börjar med en tvåbyte-CK_VERSION. Med naturlig justering lägger kompilatorn in sex byte utfyllnad efter versionen, så den första funktionspekaren hamnar på offset 8. Med bytepackning finns ingen utfyllnad, så den hamnar på offset 2. Varje följande post ärver samma förskjutning, vilket är varför ett packningsfel inte är ett enskilt fältproblem utan ett problem för hela tabellen. Fällan är att PKCS#11-huvudena bara tillämpar #pragma pack(1) i Windows. Det är en plattformsskillnad, inte en modulskillnad: två byggen av samma leverantörsbibliotek skiljer sig på detta beroende på vilken värd de kom från. Lägg också märke till att packning inte ändrar något för strukturer vars fält alla är pekarbreda, vilket gäller de flesta, så ett naivt test som bara rör CK_SLOT_INFO passerar glatt medan tabellen under är förskjuten med sex byte

{$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;      // två byte, och orsaken till att tabellen flyttar sig
    C_Initialize: Pointer;    // offset 2 packad, offset 8 justerad
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... tabellen har en fast ordning; prefixet fram till C_Sign räcker
    // för att nå allt som denna backend anropar
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Tre saker i det blocket betyder mer än de ser ut att göra. {$PACKRECORDS C} är inte samma sak som "inget direktiv"; det säger åt Free Pascal att följa den plattformsanpassade C-kompilatorns justeringsregler, vilket är exakt det kontrakt du behöver i Linux och macOS. Delphi-grenen är ovillkorligt {$A1} eftersom Delphi-byggen av PDFiumPas riktar sig mot Windows, medan FPC bär Linux- och macOS-byggena. Och återställningsraden längst ned är inte kosmetisk: lämna enheten packad och varje post som deklareras efter denna punkt ändrar tyst layout, vilket är exakt den sorts åtgärd-på-avstånd-bugg som härdning av en PDFium-komponentbindning mot ABI- och minnessäkerhetsfel ska eliminera

Pkcs11AbiLayout: gör layouten till ett påstående

Pkcs11AbiLayout rapporterar layouten som bygget faktiskt löste till en enda sträng som kan hävdas, i formen ulong=4 attr=16 pss=12 table=2. Ett 64-bitars Windows-bygge måste rapportera exakt det, och ett LP64-mål måste rapportera ulong=8 attr=24 pss=24 table=8. Allt annat betyder att ett anrop genom funktionstabellen skulle landa på fel plats, och funktionen finns för att ett enhetstest ska kunna säga det högt i stället för att en kommentar påstår det

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;

// Vid inläsning, efter att C_GetFunctionList lämnat tillbaka tabellen:
// en orimlig version eller en nil-ingångspunkt betyder att posten lades ut
// med fel packning eller CK_ULONG-bredd, så modulen avvisas
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 fyra talen är inte godtyckliga. attr är storleken på CK_ATTRIBUTE, som håller en CK_ULONG, en pekare och en CK_ULONG: 4 + 8 + 4 packat i Windows x64, 8 + 8 + 8 justerat i LP64. pss är CK_RSA_PKCS_PSS_PARAMS, tre CK_ULONG-fält, alltså 12 eller 24. table är offseten för den första funktionspekaren och är värdet som fångar ett packningsfel först. Delphi-testfallet hävdar strängen under {$IFDEF MSWINDOWS}; Lazarus-sviten hävdar samma sak. En likhetskontroll täcker en layout som annars bara skulle kunna verifieras genom att läsa ett C-huvud bredvid en Pascal-post och lita på sig själv. Kontroll vid inläsning är den andra halvan av samma idé. PDFiumPas löser endast C_GetFunctionList med namn genom GetProcAddress eller GetProcedureAddress och tar varje annan ingångspunkt från tabellen som det anropet lämnar tillbaka, vilket är hur OASIS PKCS #11-basspecifikationen avser att en modul ska nås och kringgår leverantörsspecifika symbolnamn. Sedan rimlighetskontrollerar den det som kom tillbaka. En huvudversion utanför 2 till 3 eller en nil-C_Initialize, C_GetSlotList eller C_Sign betyder att posten är feljusterad, och modulen släpps i stället för att anropas genom den

Signering genom tabellen: mekanismer, DigestInfo och C_Sign i två pass

När layouten är rätt är signeringsarbetet litet, eftersom ICmsSigner-kontraktet som PDFiumPas ber en backend uppfylla har fem metoder och fyra av dem bara returnerar OID:er och signeraridentifieraren. Endast SignSignedAttrsDigest gör något: den tar SHA-256-digesten på 32 byte av de signerade attributen och returnerar signaturbyte. CMS-sammansättning, ASN.1, RFC 3161-tidsstämpling och DSS/LTV är plattformsoberoende och redan klara, vilket är samma arbetsdelning som låter fjärrsigneringssessioner för PAdES mot en HSM eller en molnnyckeltjänst ansluta till samma skarv. Tre mekanismdetaljer ger dig en misslyckad verifiering om du hoppar över dem. CKM_RSA_PKCS tillämpar PKCS#1 v1.5-utfyllnad men konstruerar inte DigestInfo, så anroparen förlänger själv digesten med 19-bytesprefixet för SHA-256 från RFC 8017; lämna den nakna digesten till token och du får en välformad signatur över fel sak. CKM_RSA_PKCS_PSS och CKM_ECDSA tar digesten som den kommer, men CKM_ECDSA svarar med det råa r||s-paret, och CMS behöver RFC 3279 §2.2.3:s ECDSA-Sig-Value-SEQUENCE, så PDFiumPas omvandlar det. Och C_Sign är avsiktligt tvåpassigt: anropa med en nil-buffert för att fråga token efter signaturlängden och sedan igen med en buffert av den storleken

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);
  // Logga detta först av allt när en token beter sig fel på en ny plattform
  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;

Några mindre saker är värda att känna till före din första token. Moduler cachas efter sökväg eftersom C_Initialize körs en gång per process och modul, och ett upprepat anrop svarar CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), vilket PDFiumPas behandlar som framgång med antagandet att en annan del av värden redan initierade samma bibliotek. Tokensträngar som platsbeskrivningen och tokenetiketten är blankfyllda fält med fast bredd, inte NUL-terminerade, så de måste trimmas från slutet. Och CKO_CERTIFICATE är 1, inte 2 — 0 är CKO_DATA och 2 är CKO_PUBLIC_KEY. Att skriva den konstanten ur minnet är ett misstag som ger ett tomt sökresultat och inget fel alls

Vad verifieras, och var slutar garantin?

Var tydlig med gränsen, eftersom den är smalare än funktionsbeskrivningen antyder. Det som verifieras i PDFiumPas i dag är att ABI-layouten stämmer med C-huvudena fält för fält i båda grenarna, att en saknad eller oladdningsbar modul försämras till ett rapporterat fel i stället för en krasch och att både Delphi- och FPC-verktygskedjorna bygger enheten. De riktiga tokenvägarna — C_Login, objektsökning och C_Sign mot hårdvara — har inte körts, eftersom utvecklingsmaskinen inte har någon PKCS#11-modul installerad alls. Ta upp SoftHSM2 först och bekräfta Pkcs11AbiLayout innan du ansluter en fysisk token, så att ett ABI-problem och ett tokenproblem aldrig behöver diagnostiseras samtidigt. En asymmetri till förtjänar att namnges. Signeringssidan är nu plattformsoberoende; verifieringssidan är det inte. CMS-verifiering inne i PDFiumPas skyddas fortfarande av {$IFDEF MSWINDOWS} och returnerar pcsUnsupported på andra plattformar, och den har ingen motsvarande injektionspunkt för provider på signerarens sida. Så en Linux-tjänst kan producera en PAdES B-B-signatur med en tokenhållen nyckel och kan ännu inte kontrollera sin egen utdata på samma maskin. Planera verifieringssteget till Windows eller till en extern validerare tills luckan stängs

Lärdomen generaliserar utanför PKCS#11. Varje Pascal-post som speglar en villkorligt packad C-struktur behöver tre saker: ett villkorligt alias för den plattformsvariabla skalären så att breddbeslutet finns på exakt ett ställe, packningsdirektiv som omger deklarationerna och återställs efteråt samt en runtimefunktion som rapporterar den lösta layouten som något ett test kan hävda. Kommentarer som påstår att en struktur matchar sitt huvud är värdelösa; SizeOf och en fältoffset som skrivs ut vid start är värda mycket. PKCS#11-backenden, CNG-backenden och resten av signeringsstacken levereras i PDFium Component for Delphi and C++Builder, där ABI-rördragningen redan är villkorad så att din kod kan stanna på tokensidan av problemet