Odborný článok

PKCS#11 v Delphi: CK_ULONG a pasca packingu štruktúr

PDFiumPas podpisuje dokumenty PAdES cez PKCS#11 token vo Windows, Linuxe aj macOS a o tom, či toto napojenie vôbec funguje, rozhodujú dva fakty platformy: CK_ULONG je C unsigned long, teda 4 bajty vo Windows a 8 bajtov v Linuxe a macOS, a hlavičky PKCS#11 používajú #pragma pack(1) iba vo Windows, čím posunú každý pointer vo function table. Pomýľte jedno alebo druhé a modul sa stále načíta, volania stále vracajú a čísla, ktoré prichádzajú späť, sú odpad. Takýto tvar má bug mať. Nikto vám nedá linker error, pretože nič sa nelinkuje: modul je .so, .dylib alebo .dll, ktorý za behu otvoríte podľa cesty, a celý surface je struct function pointerov, ktoré castujete a voláte. Kompilátor netuší, ako vyzerala C hlavička na druhej strane. Každý nesúlad je tichý až po crash

Prečo PKCS#11 binding zlyhá náhodnými CKR kódmi namiesto čistej chyby?

Pretože ABI mismatch nevytvára error condition, ale nesprávnu adresu alebo nesprávny offset a token poslušne odpovie na otázku, ktorá z toho vyjde. Medzi deklaráciou recordu a modulom nie je vrstva, ktorá by si rozpor všimla. Vychádzajú z toho dva odlišné failure modes. Ak je packing nesprávny, slot, ktorý čítate ako C_GetSlotList, drží šesť bajtov jedného pointera a dva z ďalšieho a jeho volanie skočí do unmapped memory alebo, ešte horšie, do stredu inej funkcie. To je access violation. Ak je CK_ULONG nesprávnej šírky, adresy sú v poriadku, ale dáta nie: out-parameter var Count: CK_ULONG deklarovaný na 4 bajty dostane od LP64 modulu zapísaných 8 bajtov a potichu prepíše ďalšie štyri bajty vášho stack frame, zatiaľ čo template CK_ATTRIBUTE, ktorého ValueLen sedí na nesprávnom offsete, prinúti modul prečítať length field z vášho pointera Value. Token potom vráti úplne legitímne CKR_BUFFER_TOO_SMALL alebo CKR_ATTRIBUTE_VALUE_INVALID na otázku, ktorú ste nikdy nepoložili. Tieto kódy pošlú ľudí na hodiny hľadať v konfigurácii tokenu. Bug je o štyri riadky vyššie v typovej deklarácii

CK_ULONG je C unsigned long, nie fixed-width type

CK_ULONG je v hlavičkách PKCS#11 definovaný ako C unsigned long, čo znamená, že jeho šírka sleduje data model platformy, nie špecifikáciu. Windows je LLP64, takže unsigned long zostáva 32-bitový aj v 64-bitovom procese. Linux a macOS sú LP64, takže sleduje pointer a stáva sa 64-bitovým. Toto je najdôležitejší riadok celého unitu, pretože v PKCS#11 je prakticky každý scalar CK_ULONG: slot IDs, session handles, object handles, object classes, key types, attribute types, mechanism types, buffer lengths aj samotná návratová hodnota CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows je LLP64: C unsigned long tam zostáva 32-bitový
  CK_ULONG = LongWord;
{$ELSE}
  // Linux a macOS sú LP64: unsigned long sleduje šírku pointera
  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;

Aliasovať každý z týchto typov na CK_ULONG namiesto priameho aliasu na LongWord alebo UInt64 je pointou cvičenia. Znamená to, že conditional existuje presne raz. Vypíšte niektorý z nich konkrétne a napísali ste landmine, na ktorú v budúcom porte stúpite, a stúpite na ňu práve tam, kde ste ju zabudli

Čo robí pragma pack(1) s PKCS#11 function table?

Posunie každý function pointer v CK_FUNCTION_LIST, pretože tabuľka sa otvára dvojbajtovým CK_VERSION. Pri prirodzenom zarovnaní kompilátor vloží za túto verziu šesť bajtov paddingu, takže prvý function pointer dopadne na offset 8. Pri byte packingu padding nie je a dopadne na offset 2. Každá ďalšia položka zdedí rovnaký posun, preto chyba packingu nie je problém jedného fieldu, ale celej tabuľky. Pasca je v tom, že hlavičky PKCS#11 uplatňujú #pragma pack(1) iba vo Windows. Je to rozdiel platformy, nie modulu: dve buildy tej istej vendor library sa v tomto podľa hosta líšia. Všimnite si tiež, že packing nič nemení na štruktúrach, ktorých polia sú všetky pointer-width, čo je väčšina z nich, takže naivný test, ktorý sa dotkne iba CK_SLOT_INFO, prejde spokojne, zatiaľ čo tabuľka pod ním je posunutá o šesť bajtov

{$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;      // dva bajty a dôvod, prečo sa tabuľka posúva
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... tabuľka má pevné poradie; deklarovať prefix
    // po C_Sign stačí na dosiahnutie všetkého, čo tento backend volá
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Tri veci v tomto bloku sú dôležitejšie, než vyzerajú. {$PACKRECORDS C} nie je to isté ako „žiadna direktíva“; hovorí Free Pascalu, aby nasledoval pravidlá zarovnania platformového C kompilátora, čo je presne kontrakt, ktorý potrebujete na Linuxe a macOS. Vetva Delphi je nepodmienené {$A1}, pretože buildy PDFiumPas v Delphi cielia Windows, zatiaľ čo FPC nesie buildy Linuxu a macOS. A restore riadok dole nie je kozmetika: nechajte unit packed a každý record deklarovaný za týmto bodom potichu zmení layout tiež, presne ten typ action-at-a-distance defektu, ktorý má odstrániť spevnenie bindingu PDFium komponentu proti ABI a memory-safety chybám

Pkcs11AbiLayout: premena layoutu na assertion

Pkcs11AbiLayout oznámi layout, ktorý build skutočne vyriešil, ako jeden assertable string v tvare ulong=4 attr=16 pss=12 table=2. 64-bitový Windows build musí nahlásiť presne toto a LP64 target musí nahlásiť ulong=8 attr=24 pss=24 table=8. Čokoľvek iné znamená, že volanie cez function table dopadne na nesprávny slot a táto funkcia existuje preto, aby to unit test povedal nahlas namiesto komentára, ktorý to iba tvrdí

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;

// Pri load, po tom, čo C_GetFunctionList vráti tabuľku:
// nepravdepodobná verzia alebo nil entry point znamená, že record bol
// rozložený s nesprávnym packingom alebo šírkou CK_ULONG, preto modul odmietni
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;

Štyri čísla nie sú ľubovoľné. attr je veľkosť CK_ATTRIBUTE, ktorá drží CK_ULONG, pointer a CK_ULONG: 4 + 8 + 4 packed na Windows x64, 8 + 8 + 8 aligned v LP64. pss je CK_RSA_PKCS_PSS_PARAMS, tri polia CK_ULONG, teda 12 alebo 24. table je offset prvého function pointera a je to hodnota, ktorá zachytí chybu packingu ako prvá. Delphi test case assertuje string pod {$IFDEF MSWINDOWS}; Lazarus suite assertuje to isté. Jedna equality check pokryje layout, ktorý by inak bolo možné overiť iba čítaním C hlavičky vedľa Pascal recordu a dôverou vo vlastný úsudok. Load-time check je druhá polovica rovnakej myšlienky. PDFiumPas resolveuje podľa mena iba C_GetFunctionList cez GetProcAddress alebo GetProcedureAddress a každý ďalší entry point berie z tabuľky, ktorú toto volanie vráti, čo je spôsob, akým má modul dosiahnuť základná špecifikácia OASIS PKCS #11, a obchádza per-vendor pomenovanie symbolov. Potom skontroluje, čo sa vrátilo. Major version mimo 2 až 3 alebo nil C_Initialize, C_GetSlotList či C_Sign znamená, že record je zle zarovnaný a modul sa zahodí namiesto volania cez neho

Podpisovanie cez tabuľku: mechanizmy, DigestInfo a dvojprechodové C_Sign

Keď je layout správny, podpisovanie je malé, pretože kontrakt ICmsSigner, ktorý PDFiumPas žiada od backendu, má päť metód a štyri z nich iba vracajú OID a identifikátor signera. Iba SignSignedAttrsDigest niečo robí: vezme 32-bajtový SHA-256 digest podpísaných atribútov a vráti bajty podpisu. CMS assembly, ASN.1, timestampovanie RFC 3161 a DSS/LTV sú platformovo nezávislé a už hotové, čo je rovnaké rozdelenie práce, ktoré umožňuje, aby sa vzdialené PAdES signing sessions voči HSM alebo cloudovej key service zapojili do identického seam. Tri detaily mechanizmov vám pri preskočení vyrobia neúspešné overenie. CKM_RSA_PKCS aplikuje padding PKCS#1 v1.5, ale nevytvorí DigestInfo, takže volajúci sám pridá 19-bajtový prefix SHA-256 DigestInfo z RFC 8017; odovzdajte tokenu holý digest a dostanete dobre vytvorený podpis nad nesprávnou vecou. CKM_RSA_PKCS_PSS a CKM_ECDSA prijímajú digest tak, ako je, ale CKM_ECDSA odpovedá surovým párom r||s a CMS potrebuje SEQUENCE ECDSA-Sig-Value podľa RFC 3279 §2.2.3, takže PDFiumPas ho konvertuje. A C_Sign je zámerne dvojprechodové: zavolajte ho s nil bufferom, aby ste si od tokenu vypýtali dĺžku podpisu, a potom znova s bufferom tejto veľkosti

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);
  // Toto zaloguj ako prvé, keď sa token na novej platforme správa zle
  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;

Pred prvým tokenom sa oplatí poznať niekoľko menších vecí. Moduly sa cachujú podľa cesty, pretože C_Initialize je raz na proces a modul a opakované volanie odpovie CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), čo PDFiumPas považuje za úspech s predpokladom, že rovnakú knižnicu už inicializovala iná časť hosta. Stringy tokenu, napríklad popis slotu a label tokenu, sú fixed-width polia s paddingom na konci, nie NUL-terminated, takže sa musia orezávať od konca. A CKO_CERTIFICATE je 1, nie 2 — 0 je CKO_DATA a 2 je CKO_PUBLIC_KEY. Zapísať túto konštantu z pamäti je chyba, ktorá vyrobí prázdny search result a vôbec žiadnu chybu

Čo sa overuje a kde sa garancia končí

Ujasnite si hranicu, pretože je užšia, než naznačuje opis funkcie. PDFiumPas dnes overuje, že ABI layout sa zhoduje s C hlavičkami field po fielde na oboch vetvách, že chýbajúci alebo nenačítateľný modul degradoval na nahlásené zlyhanie namiesto crashu a že toolchainy Delphi aj FPC unit zostavia. Skutočné token paths — C_Login, object search, C_Sign voči hardvéru — neboli precvičené, pretože vývojový host nemá nainštalovaný žiadny PKCS#11 modul. Najprv uveďte do chodu SoftHSM2 a pred zapojením fyzického tokenu potvrďte Pkcs11AbiLayout, aby sa ABI problém a problém tokenu nikdy nemuseli diagnostikovať naraz. Treba pomenovať ešte jednu asymetriu. Signing side je teraz cross-platform; verification side nie. CMS verification vo vnútri PDFiumPas stále chráni {$IFDEF MSWINDOWS} a inde vracia pcsUnsupported, pričom nemá injection point providera ekvivalentný signer backendu. Linux service teda môže vytvoriť podpis PAdES B-B nad kľúčom držaným tokenom a zatiaľ nedokáže na tom istom počítači skontrolovať vlastný výstup. Kým sa táto medzera nezatvorí, naplánujte verification na Windows alebo na externý validator

Poučenie sa dá zovšeobecniť za PKCS#11. Každý Pascal record, ktorý zrkadlí podmienene packed C struct, potrebuje tri veci: jeden conditional alias pre platformovo premenný scalar, aby rozhodnutie o šírke existovalo presne na jednom mieste, packing directives ohraničujúce deklarácie a potom obnovené, a runtime funkciu, ktorá nahlási vyriešený layout v podobe, ktorú môže test assertovať. Komentáre tvrdiace, že struct zodpovedá hlavičke, nemajú žiadnu cenu; SizeOf a field offset vypísaný pri štarte majú veľkú. PKCS#11 backend, CNG backend aj zvyšok signing stacku sa dodávajú v PDFium Component for Delphi and C++Builder, kde je ABI plumbing už podmienený, takže váš kód môže zostať na tokenovej strane problému