PDFiumPas signs PAdES documents through a PKCS#11 token on Windows, Linux and macOS, and two platform facts decide whether that binding works at all: CK_ULONG is the C unsigned long, so 4 bytes on Windows and 8 bytes on Linux and macOS, and the PKCS#11 headers apply #pragma pack(1) only on Windows, which moves every pointer in the function table. Get either one wrong and the module still loads, the calls still return, and the numbers coming back are garbage. That is the shape of the bug you should expect. Nobody hands you a linker error, because nothing is linked: the module is a .so, .dylib or .dll you open at run time by path, and the whole surface is a struct of function pointers you cast and call. The compiler has no idea what the C header on the other side looked like. Every mismatch is silent until it is a crash
Why does a PKCS#11 binding fail with random CKR codes instead of a clean error?
Because an ABI mismatch does not produce an error condition at all, it produces a wrong address or a wrong offset, and the token dutifully answers whatever question that turns out to be. There is no layer between your record declaration and the module that could notice the disagreement. Two distinct failure modes come out of it. If the packing is wrong, the slot you read as C_GetSlotList holds six bytes of one pointer and two of the next, and calling it jumps into unmapped memory or, worse, into the middle of some other function. That is the access violation. If CK_ULONG is the wrong width, the addresses are fine but the data is not: a var Count: CK_ULONG out-parameter declared 4 bytes wide gets 8 bytes written into it by an LP64 module, quietly overwriting the next four bytes of your stack frame, and a CK_ATTRIBUTE template whose ValueLen sits at the wrong offset makes the module read a length field out of your Value pointer. The token then returns a perfectly legitimate CKR_BUFFER_TOO_SMALL or CKR_ATTRIBUTE_VALUE_INVALID for a question you never asked. Those codes send people hunting through token configuration for hours. The bug is four lines up in a type declaration
CK_ULONG is the C unsigned long, not a fixed-width type
CK_ULONG is defined by the PKCS#11 headers as a C unsigned long, which means its width follows the platform data model rather than the specification. Windows is LLP64, so unsigned long stays 32-bit even in a 64-bit process. Linux and macOS are LP64, so it tracks the pointer and becomes 64-bit. This is the single most consequential line in the whole unit, because in PKCS#11 practically every scalar is a CK_ULONG: slot IDs, session handles, object handles, object classes, key types, attribute types, mechanism types, buffer lengths, and the CK_RV return value itself
type
{$IFDEF MSWINDOWS}
// Windows is LLP64: a C unsigned long stays 32-bit there
CK_ULONG = LongWord;
{$ELSE}
// Linux and macOS are LP64: unsigned long follows the pointer width
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;
Aliasing every one of those to CK_ULONG rather than to LongWord or UInt64 directly is the point of the exercise. It means the conditional appears exactly once. Spell any of them out concretely and you have written a landmine that a future port will step on, and it will step on it in the one place you forgot
What does pragma pack(1) do to the PKCS#11 function table?
It shifts every function pointer in CK_FUNCTION_LIST, because the table opens with a two-byte CK_VERSION. Under natural alignment the compiler inserts six bytes of padding after that version, so the first function pointer lands at offset 8. Under byte packing there is no padding, so it lands at offset 2. Every subsequent entry inherits the same displacement, which is why a packing mistake is not a one-field problem but a whole-table problem. The trap is that the PKCS#11 headers apply #pragma pack(1) on Windows only. It is a platform difference, not a module difference: two builds of the same vendor library disagree about this depending on which host they came from. Note also that packing changes nothing for structures whose fields are all pointer-width, which is most of them, so a naive test that only touches CK_SLOT_INFO will pass happily while the table underneath is displaced by six bytes
{$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; // two bytes, and the reason the table moves
C_Initialize: Pointer; // offset 2 packed, offset 8 aligned
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... the table is in a fixed order; declaring the prefix
// through C_Sign is enough to reach everything this backend calls
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Three things in that block matter more than they look. {$PACKRECORDS C} is not the same as "no directive"; it tells Free Pascal to follow the platform C compiler's alignment rules, which is precisely the contract you need on Linux and macOS. The Delphi branch is unconditional {$A1} because Delphi builds of PDFiumPas target Windows, while FPC carries the Linux and macOS builds. And the restore line at the bottom is not cosmetic: leave the unit packed and every record declared after this point silently changes layout too, which is exactly the kind of action-at-a-distance defect that hardening a PDFium component binding against ABI and memory-safety faults is meant to eliminate
Pkcs11AbiLayout: turning the layout into an assertion
Pkcs11AbiLayout reports the layout the build actually resolved as one assertable string of the form ulong=4 attr=16 pss=12 table=2. A 64-bit Windows build must report exactly that, and an LP64 target must report ulong=8 attr=24 pss=24 table=8. Anything else means a call through the function table would land on the wrong slot, and the function exists so a unit test can say so out loud instead of a comment claiming it
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;
// At load time, after C_GetFunctionList has handed back the table:
// an implausible version or a nil entry point means the record was laid out
// with the wrong packing or CK_ULONG width, so refuse the 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;
The four numbers are not arbitrary. attr is the size of CK_ATTRIBUTE, which holds a CK_ULONG, a pointer and a CK_ULONG: 4 + 8 + 4 packed on Windows x64, 8 + 8 + 8 aligned on LP64. pss is CK_RSA_PKCS_PSS_PARAMS, three CK_ULONG fields, so 12 or 24. table is the offset of the first function pointer, and it is the value that catches a packing mistake first. The Delphi test case asserts the string under {$IFDEF MSWINDOWS}; the Lazarus suite asserts the same thing. One equality check covers a layout that would otherwise be verifiable only by reading a C header side by side with a Pascal record and trusting yourself. The load-time check is the second half of the same idea. PDFiumPas resolves only C_GetFunctionList by name through GetProcAddress or GetProcedureAddress and takes every other entry point out of the table that call returns, which is how the OASIS PKCS #11 base specification intends a module to be reached and sidesteps per-vendor symbol naming. Then it sanity-checks what came back. A major version outside 2 to 3, or a nil C_Initialize, C_GetSlotList or C_Sign, means the record is misaligned, and the module is dropped rather than called through
Signing through the table: mechanisms, DigestInfo and the two-pass C_Sign
Once the layout is right the signing work is small, because the ICmsSigner contract PDFiumPas asks a backend to satisfy has five methods and four of them just return OIDs and the signer identifier. Only SignSignedAttrsDigest does anything: it takes the 32-byte SHA-256 digest of the signed attributes and returns signature bytes. CMS assembly, ASN.1, RFC 3161 timestamping and DSS/LTV are all platform-independent and already done, which is the same division of labour that lets remote PAdES signing sessions against an HSM or a cloud key service plug into the identical seam. Three mechanism details will cost you a failed verification if you skip them. CKM_RSA_PKCS applies PKCS#1 v1.5 padding but does not construct the DigestInfo, so the caller prepends the 19-byte SHA-256 DigestInfo prefix from RFC 8017 itself; hand the bare digest to the token and you get a well-formed signature over the wrong thing. CKM_RSA_PKCS_PSS and CKM_ECDSA take the digest as presented, but CKM_ECDSA answers with the raw r||s pair, and CMS needs the ECDSA-Sig-Value SEQUENCE of RFC 3279 §2.2.3, so PDFiumPas converts. And C_Sign is two-pass by design: call it with a nil buffer to ask the token for the signature length, then again with a buffer of that size
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 this before anything else when a token misbehaves on a new platform
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;
A few smaller things are worth knowing before your first token. Modules are cached by path because C_Initialize is once per process per module, and a repeat call answers CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), which PDFiumPas treats as success on the assumption that another part of the host already initialised the same library. Token strings such as the slot description and token label are blank-padded fixed-width fields, not NUL-terminated, so they have to be trimmed from the tail. And CKO_CERTIFICATE is 1, not 2 — 0 is CKO_DATA and 2 is CKO_PUBLIC_KEY. Writing that constant from memory is a mistake that produces an empty search result and no error whatsoever
What is verified, and where the guarantee stops
Be clear about the boundary, because it is narrower than the feature description suggests. What is verified in PDFiumPas today is that the ABI layout matches the C headers field for field on both branches, that an absent or unloadable module degrades to a reported failure rather than a crash, and that both the Delphi and the FPC toolchains build the unit. The real token paths — C_Login, object search, C_Sign against hardware — have not been exercised, because the development host has no PKCS#11 module installed at all. Bring up SoftHSM2 first and confirm Pkcs11AbiLayout before you plug in a physical token, so an ABI problem and a token problem never have to be diagnosed at the same time. One more asymmetry deserves naming. The signing side is now cross-platform; the verification side is not. CMS verification inside PDFiumPas is still guarded by {$IFDEF MSWINDOWS} and returns pcsUnsupported elsewhere, and it has no provider injection point equivalent to the signer backend. So a Linux service can produce a PAdES B-B signature over a token-held key and cannot yet check its own output on the same machine. Plan the verification step onto Windows, or onto an external validator, until that gap closes
The lesson generalises past PKCS#11. Any Pascal record that mirrors a conditionally packed C struct needs three things: one conditional alias for the platform-variable scalar so the width decision exists in exactly one place, packing directives that bracket the declarations and are restored afterwards, and a runtime function that reports the resolved layout as something a test can assert. Comments claiming a struct matches its header are worth nothing; SizeOf and a field offset printed at startup are worth a great deal. The PKCS#11 backend, the CNG backend and the rest of the signing stack ship in the PDFium Component for Delphi and C++Builder, where the ABI plumbing is already conditioned so your code can stay on the token side of the problem