Technický článek

PKCS#11 v Delphi: past CK_ULONG a packingu

PDFiumPas podepisuje dokumenty PAdES přes token PKCS#11 na Windows, Linuxu i macOS a o tom, zda binding vůbec funguje, rozhodují dvě fakta platformy: CK_ULONG je C unsigned long, tedy 4 bajty na Windows a 8 bajtů na Linuxu a macOS, a hlavičky PKCS#11 používají #pragma pack(1) pouze na Windows, čímž posunou každý pointer v tabulce funkcí. Spleťte kteroukoli z těchto věcí a modul se stále načte, volání se stále vracejí a čísla, která přicházejí zpět, jsou nesmysl. To je tvar bugu, který máte čekat. Nikdo vám nepředá linker error, protože nic není linkováno: modul je .so, .dylib nebo .dll otevřený za běhu podle cesty a celé rozhraní je struct function pointerů, které přetypujete a voláte. Kompilátor neví, jak vypadala C hlavička na druhé straně. Každý nesoulad zůstane tichý až do crash

Proč binding PKCS#11 selže náhodnými CKR kódy místo čisté chyby

Protože nesoulad ABI nevytvoří chybový stav, ale špatnou adresu nebo offset a token poslušně odpoví na otázku, kterou tato chyba náhodou vytvořila. Mezi deklarací recordu a modulem není vrstva, která by rozdíl odhalila. Vznikají z toho dva odlišné failure modes. Když je špatný packing, slot, který čtete jako C_GetSlotList, obsahuje šest bajtů jednoho pointeru a dva bajty dalšího a volání skočí do unmapped memory nebo, ještě hůř, doprostřed jiné funkce. To je access violation. Když má CK_ULONG špatnou šířku, adresy jsou v pořádku, ale data ne: out-parameter var Count: CK_ULONG deklarovaný jako 4 bajty dostane od LP64 modulu zapsáno 8 bajtů a potichu přepíše další čtyři bajty stack framu, zatímco template CK_ATTRIBUTE, jehož ValueLen sedí na špatném offsetu, přiměje modul přečíst délkové pole z pointeru Value. Token potom vrátí naprosto legitimní CKR_BUFFER_TOO_SMALL nebo CKR_ATTRIBUTE_VALUE_INVALID na otázku, kterou jste nepoložili. Tyto kódy pošlou lidi na hodiny hledat v konfiguraci tokenu. Bug je o čtyři řádky výše v deklaraci typu

CK_ULONG je C unsigned long, ne typ s pevnou šířkou

CK_ULONG je v hlavičkách PKCS#11 definován jako C unsigned long, což znamená, že jeho šířka sleduje datový model platformy, nikoli specifikaci. Windows je LLP64, takže unsigned long zůstává 32bitový i v 64bitovém procesu. Linux a macOS jsou LP64, takže sleduje pointer a stává se 64bitovým. Toto je nejdůležitější řádek celé unity, protože v PKCS#11 je prakticky každý scalar CK_ULONG: ID slotů, handly sessions, handly objektů, object classes, key types, attribute types, mechanism types, délky bufferů i samotná návratová hodnota CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows je LLP64: C unsigned long zde zůstává 32bitový
  CK_ULONG = LongWord;
{$ELSE}
  // Linux a macOS jsou LP64: unsigned long sleduje šířku pointeru
  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;

Aliasovat všechny tyto typy na CK_ULONG místo přímého aliasu na LongWord nebo UInt64 je smyslem cvičení. Podmíněnost se objeví přesně jednou. Napište kterýkoli z nich konkrétně a vytvoříte minu, na kterou budoucí port šlápne, přesně na tom jediném místě, které jste zapomněli

Co udělá pragma pack(1) s tabulkou funkcí PKCS#11

Posune každý function pointer v CK_FUNCTION_LIST, protože tabulka začíná dvoubajtovým CK_VERSION. Při přirozeném zarovnání kompilátor vloží po této verzi šest bajtů paddingu, takže první function pointer dopadne na offset 8. Při byte packingu padding není a dopadne na offset 2. Každá další položka zdědí stejný posun, proto chyba packingu není problém jednoho pole, ale celé tabulky. Past je v tom, že hlavičky PKCS#11 aplikují #pragma pack(1) jen na Windows. Je to rozdíl platformy, nikoli modulu: dva buildy stejné knihovny dodavatele se v tom liší podle hostitele, na němž vznikly. Všimněte si také, že packing nic nemění u struktur, jejichž pole jsou všechna pointer-width, což je většina z nich, takže naivní test dotýkající se pouze CK_SLOT_INFO spokojeně projde, zatímco tabulka pod ním je posunutá o šest bajtů

{$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, důvod posunu tabulky
    C_Initialize: Pointer;    // offset 2 packed, offset 8 aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... tabulka má pevné pořadí; prefix do C_Sign stačí
    // k dosažení všeho, co tento backend volá
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

V tomto bloku záleží na třech věcech více, než vypadají. {$PACKRECORDS C} není totéž co „žádná direktiva“; říká Free Pascalu, aby následoval pravidla zarovnání platformního C kompilátoru, což je přesně smlouva potřebná na Linuxu a macOS. Větev Delphi je nepodmíněné {$A1}, protože buildy PDFiumPas pro Delphi míří na Windows, zatímco FPC nese buildy Linuxu a macOS. A restore řádek na konci není kosmetika: nechte jednotku packed a každý record deklarovaný po tomto místě potichu změní layout, což je přesně druh akce na dálku, který má odstranit zpevnění bindingu PDFium VCL proti chybám ABI a memory safety

Pkcs11AbiLayout: převod layoutu na assertion

Pkcs11AbiLayout ohlásí layout, který build skutečně vyřešil, jako jeden assertovatelný string ve tvaru ulong=4 attr=16 pss=12 table=2. 64bitový build Windows musí nahlásit přesně tohle a LP64 cíl musí uvést ulong=8 attr=24 pss=24 table=8. Cokoli jiného znamená, že volání přes function table dopadne na špatný slot, a tato funkce existuje proto, aby unit test mohl říct nahlas pravdu místo komentáře, který ji jen 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;

// Při loadu, poté co C_GetFunctionList předal tabulku:
// nepravděpodobná verze nebo nil entry point znamená, že record má špatný
// packing nebo šířku CK_ULONG, proto modul odmítni
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;

Čtyři čísla nejsou náhodná. attr je velikost CK_ATTRIBUTE, který drží CK_ULONG, pointer a CK_ULONG: 4 + 8 + 4 packed na Windows x64, 8 + 8 + 8 zarovnané na LP64. pss je CK_RSA_PKCS_PSS_PARAMS, tři pole CK_ULONG, tedy 12 nebo 24. table je offset prvního function pointeru a je to hodnota, která chybu packingu chytí jako první. Delphi test case assertuje string pod {$IFDEF MSWINDOWS}; Lazarus suite assertuje totéž. Jediná equality check pokryje layout, který by jinak šel ověřit pouze čtením C hlavičky vedle Pascalového recordu a důvěrou ve vlastní oči. Load-time check je druhá polovina stejné myšlenky. PDFiumPas vyřeší jménem přes GetProcAddress nebo GetProcedureAddress pouze C_GetFunctionList a všechny další entry pointy vezme z tabulky vrácené tímto voláním, což je způsob, jakým má být modul podle základní specifikace OASIS PKCS #11 dosažen, a obchází jmenné rozdíly jednotlivých vendorů. Potom ověří, co se vrátilo. Major version mimo 2 až 3 nebo nil C_Initialize, C_GetSlotList či C_Sign znamená špatně zarovnaný record a modul se zahodí místo volání přes něj

Podpis přes tabulku: mechanismy, DigestInfo a dvoufázový C_Sign

Jakmile layout sedí, podpisová práce je malá, protože kontrakt ICmsSigner, který PDFiumPas po backendu vyžaduje, má pět metod a čtyři z nich jen vracejí OID a identifikátor signera. Jediná SignSignedAttrsDigest něco dělá: přijme 32bajtový SHA-256 digest podepsaných atributů a vrátí bajty podpisu. Sestavení CMS, ASN.1, timestamping RFC 3161 i DSS/LTV je platformně nezávislé a už hotové, což je stejné rozdělení práce, které umožní vzdáleným PAdES signing sessions proti HSM nebo cloudové key service napojit se na totožný seam. Když přeskočíte tři detaily mechanismů, zaplatíte za to neúspěšným ověřením. CKM_RSA_PKCS aplikuje padding PKCS#1 v1.5, ale nesestavuje DigestInfo, takže caller musí sám přidat 19bajtový prefix SHA-256 DigestInfo z RFC 8017; předejte tokenu holý digest a dostanete formálně správný podpis nad špatnou věcí. CKM_RSA_PKCS_PSS a CKM_ECDSA přijímají digest tak, jak je, ale CKM_ECDSA odpovídá surovou dvojicí r||s a CMS potřebuje SEQUENCE ECDSA-Sig-Value podle RFC 3279 §2.2.3, takže PDFiumPas ji převede. A C_Sign je záměrně dvoufázový: jednou ho zavolejte s nil bufferem pro zjištění délky podpisu tokenem a podruhé s bufferem této velikosti

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);
  // Při špatném chování tokenu na nové platformě zaloguj layout jako první
  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;

Před prvním tokenem stojí za znalost několik menších věcí. Moduly se cacheují podle cesty, protože C_Initialize probíhá jednou za proces a modul a opakované volání vrátí CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), což PDFiumPas považuje za úspěch s předpokladem, že jiná část hostitele už stejnou knihovnu inicializovala. Stringy tokenu, například popis slotu a label tokenu, jsou fixed-width pole s paddingem mezerami, nikoli NUL-terminated, takže se musí ořezat na konci. A CKO_CERTIFICATE je 1, nikoli 2 — 0 je CKO_DATA a 2 je CKO_PUBLIC_KEY. Napsat tuto konstantu zpaměti je chyba, která vyrobí prázdný search result bez jakékoli chyby

Co je ověřeno a kde záruka končí

Hranici si pojmenujme jasně, protože je užší než popis funkce napovídá. Co je dnes v PDFiumPas ověřeno, je shoda layoutu ABI s C hlavičkami pole po poli na obou větvích, degradace chybějícího nebo nenačitatelného modulu do hlášeného selhání místo pádu a build unity v toolchainech Delphi i FPC. Skutečné tokenové cesty — C_Login, hledání objektů a C_Sign proti hardwaru — procvičené nebyly, protože vývojový host nemá nainstalovaný vůbec žádný modul PKCS#11. Nejprve zprovozněte SoftHSM2 a potvrďte Pkcs11AbiLayout, než připojíte fyzický token, aby se problém ABI a problém tokenu nemusely diagnostikovat současně. Ještě jedna asymetrie si zaslouží jméno. Podpisová strana je nyní cross-platform, verifikační nikoli. Ověření CMS uvnitř PDFiumPas stále chrání {$IFDEF MSWINDOWS} a jinde vrací pcsUnsupported, přičemž nemá provider injection point ekvivalentní signer backendu. Linuxová služba tedy může vytvořit podpis PAdES B-B nad klíčem drženým tokenem, ale zatím nemůže na stejném stroji zkontrolovat vlastní výstup. Dokud se mezera nezavře, plánujte ověření na Windows nebo v externím validatoru

Poučení přesahuje PKCS#11. Každý Pascalový record zrcadlící podmíněně packovanou C struct potřebuje tři věci: jeden podmíněný alias pro scalar závislý na platformě, aby rozhodnutí o šířce existovalo na jediném místě, packing directives ohraničující deklarace a obnovené po nich a runtime funkci, která reportuje vyřešený layout v podobě, již může test assertovat. Komentáře tvrdící shodu structu s hlavičkou nestojí za nic; SizeOf a offset pole vypsaný při startu stojí za mnoho. PKCS#11 backend, CNG backend i zbytek podpisového stacku se dodávají v PDFium Component for Delphi and C++Builder, kde je ABI plumbing už podmíněný, takže váš kód může zůstat na straně tokenu problému