A PDFiumPas PKCS#11 tokenen keresztül ír alá PAdES-dokumentumokat Windowson, Linuxon és macOS-en, és két platformtény dönti el, hogy a binding egyáltalán működik-e: a CK_ULONG a C unsigned long típusa, ezért Windowson 4, Linuxon és macOS-en 8 bájt, a PKCS#11 headerek pedig csak Windowson alkalmazzák a #pragma pack(1) direktívát, ami minden pointert eltol a function table-ben. Ha bármelyiket elrontod, a modul még betöltődik, a hívások még visszatérnek, a visszaérkező számok viszont szemét értékek. Erre számíts ennél a hibánál. Linkerhiba nem érkezik, mert semmi sincs linkelve: a modul egy futásidőben útvonal alapján megnyitott .so, .dylib vagy .dll, a teljes felület pedig function pointerökből álló struct, amelyet castolsz és hívsz. A compiler nem tudja, hogyan nézett ki a C header a túloldalon. Minden eltérés csendben marad egészen az összeomlásig
Miért véletlenszerű CKR-kódokkal bukik el egy PKCS#11 binding tiszta hiba helyett?
Azért, mert egy ABI-eltérés egyáltalán nem hibafeltételt hoz létre, hanem rossz címet vagy rossz offsetet, a token pedig kötelességtudóan arra a kérdésre válaszol, amelyik ebből kijön. Nincs olyan réteg a rekorddeklarációd és a modul között, amely észrevehetné az eltérést. Két külön hibamód születik belőle. Ha a packing rossz, a C_GetSlotList-ként olvasott slot egy pointer hat bájtját és a következő kettőjét tartalmazza, a hívás pedig unmapped memóriába, rosszabb esetben egy másik függvény közepébe ugrik. Ez az access violation. Ha a CK_ULONG szélessége rossz, a címek jók, az adatok nem: egy LP64 modul által 8 bájt szélességben kiírt var Count: CK_ULONG out-paramétert 4 bájtosnak deklarálsz, így csendben felülírja a stack frame következő négy bájtját, egy olyan CK_ATTRIBUTE template pedig, amelyben a ValueLen rossz offseten van, a modulodat a Value pointerből olvasott hosszmezővel eteti. A token ezután teljesen jogos CKR_BUFFER_TOO_SMALL vagy CKR_ATTRIBUTE_VALUE_INVALID kódot ad egy olyan kérdésre, amelyet soha nem tettél fel. Ezek a kódok órákra tokenkonfigurációt kereső útra küldik az embert. A hiba négy sorral feljebb van egy típusdeklarációban
A CK_ULONG a C unsigned long, nem fix szélességű típus
A PKCS#11 headerek a CK_ULONG-ot C unsigned long-ként definiálják, ezért a szélessége a platform adatmodelljét követi, nem a specifikációt. A Windows LLP64, így az unsigned long 64 bites folyamatban is 32 bites marad. Linux és macOS LP64, ezért a pointert követi, és 64 bites lesz. Ez az egész unit legfontosabb sora, mert a PKCS#11-ben gyakorlatilag minden skalár CK_ULONG: slot ID-k, session handle-ök, object handle-ök, object classok, key type-ok, attribute type-ok, mechanism type-ok, buffer lengthek és maga a CK_RV visszatérési érték is
type
{$IFDEF MSWINDOWS}
// Windows LLP64: a C unsigned long ott 32 bites marad
CK_ULONG = LongWord;
{$ELSE}
// Linux és macOS LP64: az unsigned long a pointer szélességét követi
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;
Mindegyik aliasolása CK_ULONG-ra, nem közvetlenül LongWord-re vagy UInt64-re, ennek a gyakorlatnak a lényege. Így a feltételes döntés pontosan egyszer jelenik meg. Ha bármelyiket konkrétan kiírod, aknát telepítesz, amelybe egy későbbi portáláskor belelépsz, mégpedig azon az egy helyen, amelyet elfelejtettél
Mit tesz a pragma pack(1) a PKCS#11 function table-lel?
Minden function pointert eltol a CK_FUNCTION_LIST-ben, mert a tábla egy kétbájtos CK_VERSION-nel indul. Természetes igazításnál a compiler hat bájt paddinget szúr a verzió után, így az első function pointer a 8-as offsetre kerül. Byte packingnál nincs padding, így a 2-es offsetre. Minden későbbi bejegyzés ugyanazt az eltolást örökli, ezért a packinghiba nem egy mező problémája, hanem az egész tábla hibája. A csapda, hogy a PKCS#11 headerek csak Windowson alkalmazzák a #pragma pack(1)-et. Ez platformkülönbség, nem modulfüggőség: ugyanazon gyártói library két buildje attól függően tér el, melyik hostról származik. A packing ráadásul nem változtat semmit azokon a struktúrákon, amelyeknek minden mezője pointer szélességű, és ez a legtöbbjük, így egy naiv teszt, amely csak a CK_SLOT_INFO-t érinti, boldogan átmegy, miközben az alatta lévő tábla hat bájttal el van tolva
{$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; // két byte, ezért mozdul el a tábla
C_Initialize: Pointer; // 2-es offset packed, 8-as igazítva
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... a tábla fix sorrendű; a C_Sign-ig deklarált prefix elég
// minden olyan elérési ponthoz, amelyet ez a backend hív
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Ebben a blokkban három dolog fontosabb, mint amilyennek látszik. A {$PACKRECORDS C} nem ugyanaz, mint a „nincs direktíva”: azt mondja a Free Pascalnak, hogy kövesse a platform C compilerének igazítási szabályait, pontosan ezt a szerződést kérjük Linuxon és macOS-en. A Delphi-ág feltétel nélküli {$A1}, mert a PDFiumPas Delphi buildjei Windowsra céloznak, az FPC pedig a Linux- és macOS-buildjeit viszi. Az alsó restore sor sem kozmetika: ha a unit packed marad, minden utána deklarált rekord layoutja csendben megváltozik, ez pedig pontosan az a távolról ható hiba, amelyet a PDFium binding ABI- és memóriabiztonsági megerősítéséről szóló cikk el akar kerülni
Pkcs11AbiLayout: a layout assertionné alakítása
A Pkcs11AbiLayout egyetlen assertionre alkalmas stringként jelenti a build által ténylegesen feloldott layoutot, például ulong=4 attr=16 pss=12 table=2. Egy 64 bites Windows buildnek pontosan ezt kell jelentenie, egy LP64 célon pedig ulong=8 attr=24 pss=24 table=8 értéket kell kapnod. Bármi más azt jelenti, hogy a function table-en át végzett hívás rossz slotra érne, és a függvény azért létezik, hogy egy unit test ezt hangosan kimondhassa, ne egy komment állítsa
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;
// Betöltéskor, miután a C_GetFunctionList visszaadta a táblát:
// hihetetlen verzió vagy nil belépési pont azt jelenti, hogy a rekord
// rossz packinggal vagy CK_ULONG-szélességgel layoutolódott, ezért elutasítjuk a modult
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;
A négy szám nem véletlen. Az attr a CK_ATTRIBUTE mérete, amely egy CK_ULONG-ot, egy pointert és egy CK_ULONG-ot tartalmaz: Windowson x64 packed formában 4 + 8 + 4, LP64 igazítva 8 + 8 + 8. A pss a CK_RSA_PKCS_PSS_PARAMS, három CK_ULONG mezővel, tehát 12 vagy 24. A table az első function pointer offsetje, és ez kapja el először a packinghibát. A Delphi-teszt a stringet {$IFDEF MSWINDOWS} alatt assertioneli, a Lazarus suite ugyanezt teszi. Egy egyenlőségvizsgálat olyan layoutot fed le, amelyet különben csak egy C header és Pascal-record egymás melletti olvasásával, saját magadban bízva tudnál ellenőrizni. A load-time ellenőrzés ugyanez a gondolat második fele. A PDFiumPas név alapján csak a C_GetFunctionList-et oldja fel GetProcAddress vagy GetProcedureAddress segítségével, az összes többi belépési pontot abból a táblából veszi ki, amelyet a hívás visszaad, pontosan úgy, ahogy az OASIS PKCS #11 alap-specifikációja megköveteli, és megkerüli a gyártónként eltérő szimbólumneveket. Ezután sanity-checket végez a kapott adatokon. A 2–3-on kívüli major verzió, vagy a C_Initialize, C_GetSlotList vagy C_Sign nil értéke rosszul igazított rekordot jelent, a modult pedig eldobja, nem hívja rajta keresztül
Aláírás a táblán keresztül: mechanizmusok, DigestInfo és a kétmenetes C_Sign
Ha a layout helyes, az aláírási munka kicsi, mert az ICmsSigner szerződés, amelyet a PDFiumPas a backendtől kér, öt metódust tartalmaz, ezek közül négy csak OID-kat és signer identifier-t ad vissza. Csak a SignSignedAttrsDigest csinál valamit: a signed attribute-ok 32 bájtos SHA-256 digestjét kapja és signature byte-okat ad. A CMS-összeállítás, az ASN.1, az RFC 3161 timestampelés és a DSS/LTV platformfüggetlenül már elkészül, ugyanez a munkamegosztás teszi lehetővé, hogy a HSM- vagy cloud-kulcs elleni távoli PAdES-signing session ugyanabba a varratba kapcsolódjon. Három mechanizmus-részlet kihagyva hibás verifikációba kerül. A CKM_RSA_PKCS PKCS#1 v1.5 paddinget alkalmaz, de nem építi fel a DigestInfo-t, ezért a hívónak kell eléfűznie az RFC 8017 19 bájtos SHA-256 DigestInfo prefixét; ha a nyers digestet adod a tokennek, szabályos, de rossz dolog fölött készült aláírást kapsz. A CKM_RSA_PKCS_PSS és a CKM_ECDSA a digestet változatlanul veszi át, a CKM_ECDSA viszont nyers r||s párt ad, a CMS-nek pedig az RFC 3279 §2.2.3 szerinti ECDSA-Sig-Value SEQUENCE kell, ezért a PDFiumPas konvertál. A C_Sign pedig szándékosan kétmenetes: először nil bufferrel kéred le a tokentől a signature hosszát, majd ekkora bufferrel másodszor is meghívod
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);
// Ezt naplózd elsőként, ha a token új platformon rosszul viselkedik
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éhány kisebb részletet is érdemes tudni az első token előtt. A modulokat útvonal szerint cache-eli, mert a C_Initialize modulonként és folyamatonként egyszer történik, egy ismételt hívás pedig CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190) választ ad, amelyet a PDFiumPas sikernek tekint, abból kiindulva, hogy a host más része már inicializálta ugyanazt a libraryt. A tokenstringek, például a slot leírása és a token labelje, space-ekkel feltöltött fix szélességű mezők, nem NUL-termináltak, ezért a végükről trimelni kell őket. A CKO_CERTIFICATE pedig 1, nem 2 — a 0 a CKO_DATA, a 2 a CKO_PUBLIC_KEY. Ezt a konstanst emlékezetből beírni üres keresési eredményt és teljes csendet eredményez
Mi ellenőrzött, és hol ér véget a garancia?
A határt világosan kell látni, mert szűkebb, mint amit a feature-leírás sugall. A PDFiumPas jelenleg azt ellenőrzi, hogy az ABI layout mezőről mezőre egyezik a C headerekkel mindkét ágon, hogy a hiányzó vagy betölthetetlen modul jelentett hibává, nem összeomlássá alakul, és hogy a Delphi és az FPC toolchain is lefordítja az unitot. A valódi tokenútvonalakat — C_Login, objektumkeresés, hardveren végzett C_Sign — nem futtattuk, mert a fejlesztői hoston egyáltalán nincs telepített PKCS#11 modul. Először SoftHSM2-t indíts, és ellenőrizd a Pkcs11AbiLayout eredményét, mielőtt fizikai tokent kötsz be, így az ABI- és tokenproblémát soha nem kell egyszerre diagnosztizálni. Egy másik aszimmetriát is érdemes megnevezni. Az aláírási oldal már cross-platform, a verifikációs oldal nem. A PDFiumPas CMS-verifikációja továbbra is {$IFDEF MSWINDOWS} alatt védett, máshol pcsUnsupported-ot ad vissza, és nincs a signer backendével egyenértékű provider-injection pontja. Így egy Linux service elő tud állítani egy tokenen tartott kulccsal készült PAdES B-B aláírást, saját kimenetét viszont még nem tudja ugyanazon a gépen ellenőrizni. A verifikációt addig Windowsra vagy külső validatorra kell tervezni, amíg ez a rés be nem zárul
A tanulság túlmutat a PKCS#11-en. Minden Pascal-recordnak, amely feltételesen packed C structot tükröz, három dolog kell: egy feltételes alias a platformfüggő skalárhoz, hogy a szélességi döntés pontosan egy helyen létezzen, a deklarációkat közrefogó és utána visszaállított packing-direktívák, valamint egy runtime-függvény, amely az effektív layoutot tesztelhető formában jelenti. Azt állító kommentek, hogy egy struct egyezik a headerével, semmit sem érnek; a SizeOf és egy induláskor kiírt field offset nagyon sokat. A PKCS#11 backend, a CNG backend és az aláírási stack többi része a PDFium Component for Delphi and C++Builder része, ahol az ABI plumbing már feltételes, így a kódod a token oldalán maradhat