PDFiumPas підписує PAdES documents через PKCS#11 token у Windows, Linux та macOS, і дві platform facts вирішують, чи binding взагалі працює: CK_ULONG — це C unsigned long, тому 4 bytes у Windows і 8 bytes у Linux та macOS, а PKCS#11 headers застосовують #pragma pack(1) лише у Windows, що зсуває кожен pointer у function table. Помиліться з будь-якою з них — module все ще load-иться, calls усе ще return-яться, а values, що приходять назад, є garbage. Саме такої форми bug і треба очікувати. Ніхто не дасть linker error, бо нічого не linked: module — це .so, .dylib або .dll, який відкривається runtime за path, а вся surface — struct із function pointers, які ви cast-ите і викликаєте. Compiler не знає, як виглядав C header на іншому боці. Кожен mismatch silent, доки не стане crash
Чому PKCS#11 binding повертає random CKR codes, а не clean error?
Тому що ABI mismatch не створює error condition, а створює неправильний address або неправильний offset, і token сумлінно відповідає на те питання, яким це виявляється. Між вашою record declaration та module немає layer, здатного помітити disagreement. Із цього виходять два distinct failure modes. Якщо packing неправильний, slot, який ви читаєте як C_GetSlotList, містить шість bytes одного pointer і два bytes наступного, а його call стрибає в unmapped memory або, ще гірше, у середину іншої function. Це access violation. Якщо CK_ULONG має неправильну width, addresses правильні, але data — ні: var Count: CK_ULONG, оголошений width 4 bytes, отримує 8 bytes від LP64 module і тихо перезаписує наступні four bytes вашого stack frame, а CK_ATTRIBUTE template, чий ValueLen лежить на неправильному offset, змушує module прочитати length field із вашого Value pointer. Token тоді повертає цілком legitimate CKR_BUFFER_TOO_SMALL або CKR_ATTRIBUTE_VALUE_INVALID на питання, якого ви не ставили. Ці codes годинами відправляють людей шукати проблему в token configuration. Bug — на four lines вище, у type declaration
CK_ULONG — це C unsigned long, а не fixed-width type
CK_ULONG визначений PKCS#11 headers як C unsigned long, тобто його width залежить від platform data model, а не від specification. Windows — LLP64, тому unsigned long залишається 32-bit навіть у 64-bit process. Linux і macOS — LP64, тому він відповідає pointer і стає 64-bit. Це один із найважливіших рядків у всьому unit, бо в PKCS#11 практично кожен scalar — CK_ULONG: slot IDs, session handles, object handles, object classes, key types, attribute types, mechanism types, buffer lengths і навіть return value CK_RV
type
{$IFDEF MSWINDOWS}
// Windows — LLP64: C unsigned long залишається 32-bit
CK_ULONG = LongWord;
{$ELSE}
// Linux і macOS — LP64: unsigned long відповідає 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;
Сенс exercise — alias-ити кожен із них до CK_ULONG, а не безпосередньо до LongWord або UInt64. Так conditional з’являється рівно один раз. Напишіть будь-який із них concrete — і закладете landmine, на яку майбутній port наступить у тому єдиному місці, про яке ви забули
Що робить pragma pack(1) з PKCS#11 function table?
Вона зсуває кожен function pointer у CK_FUNCTION_LIST, бо table починається з two-byte CK_VERSION. За natural alignment compiler вставляє six bytes padding після version, тому перший function pointer лежить на offset 8. За byte packing padding немає, тому він лежить на offset 2. Кожен наступний entry успадковує те саме displacement, тому packing mistake — це не one-field problem, а whole-table problem. Пастка в тому, що PKCS#11 headers застосовують #pragma pack(1) лише на Windows. Це platform difference, а не module difference: дві builds тієї самої vendor library не погоджуються залежно від host, із якого походять. Також зауважте: packing нічого не змінює для structures, чиї fields усі pointer-width, а це більшість із них, тому naive test, який торкається лише CK_SLOT_INFO, щасливо pass-иться, поки table під ним displaced на 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, і саме тому table зсувається
C_Initialize: Pointer; // offset 2 packed, offset 8 aligned
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... table має fixed order; declaration prefix до C_Sign
// достатня, щоб дістатися всіх entry points, які викликає backend
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Три details у цьому block важливіші, ніж здаються. {$PACKRECORDS C} — це не те саме, що "no directive"; він каже Free Pascal дотримуватися alignment rules platform C compiler, саме того contract, який потрібен на Linux і macOS. Delphi branch unconditional {$A1}, бо Delphi builds PDFiumPas target-ять Windows, тоді як FPC несе Linux та macOS builds. І restore line внизу не cosmetic: залиште unit packed, і кожен record, оголошений після цього, тихо змінить layout — саме такий action-at-a-distance defect покликаний усунути матеріал про hardening PDFium component binding проти ABI та memory-safety faults
Pkcs11AbiLayout: перетворюємо layout на assertion
Pkcs11AbiLayout повідомляє layout, який build справді resolve-нув, як один assertable string форми ulong=4 attr=16 pss=12 table=2. 64-bit Windows build має повідомити рівно це, а LP64 target — ulong=8 attr=24 pss=24 table=8. Будь-яке інше означає, що call через function table потрапить у неправильний slot, і function існує для того, щоб unit test міг сказати це вголос, а не comment стверджував протилежне
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;
// Під час load, після того як C_GetFunctionList повернув table:
// implausible version або nil entry point означає неправильні packing
// чи CK_ULONG width, тому 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;
Чотири numbers не arbitrary. attr — size CK_ATTRIBUTE, який містить CK_ULONG, pointer та CK_ULONG: 4 + 8 + 4 packed у Windows x64, 8 + 8 + 8 aligned у LP64. pss — CK_RSA_PKCS_PSS_PARAMS, три CK_ULONG fields, отже 12 або 24. table — offset першого function pointer, і саме це value першим ловить packing mistake. Delphi test case assert-ить string під {$IFDEF MSWINDOWS}; Lazarus suite assert-ить те саме. Одна equality check накриває layout, який інакше можна було б verify лише читанням C header поруч із Pascal record і довірою до себе. Load-time check — друга половина тієї самої ідеї. PDFiumPas resolve-ить за name лише C_GetFunctionList через GetProcAddress або GetProcedureAddress, а кожен інший entry point бере з table, яку повернув цей call, — саме так OASIS PKCS #11 base specification задумує доступ до module і саме так обходиться per-vendor symbol naming. Потім він sanity-check-ить result. Major version поза 2–3 або nil C_Initialize, C_GetSlotList чи C_Sign означає misaligned record, і module відкидається замість call через нього
Signing через table: mechanisms, DigestInfo і two-pass C_Sign
Коли layout правильний, signing work невеликий, бо contract ICmsSigner, якого PDFiumPas просить від backend, має five methods, і four із них лише повертають OIDs та signer identifier. Лише SignSignedAttrsDigest робить щось: бере 32-byte SHA-256 digest signed attributes і повертає signature bytes. CMS assembly, ASN.1, RFC 3161 timestamping і DSS/LTV platform-independent та вже готові, і це та сама division of labor, яка дозволяє remote PAdES signing sessions проти HSM або cloud key service підключитися до identical seam. Якщо пропустити три mechanism details, verification провалиться. CKM_RSA_PKCS застосовує PKCS#1 v1.5 padding, але не створює DigestInfo, тому caller сам додає 19-byte SHA-256 DigestInfo prefix із RFC 8017; передайте token bare digest — і отримаєте well-formed signature не над тією data. CKM_RSA_PKCS_PSS та CKM_ECDSA приймають digest як є, але CKM_ECDSA відповідає raw r||s pair, а CMS потребує ECDSA-Sig-Value SEQUENCE із RFC 3279 §2.2.3, тому PDFiumPas перетворює його. А C_Sign by design two-pass: викликати його з nil buffer, щоб запитати token про signature length, а потім ще раз із buffer такого 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-увати це перед усім іншим, коли token misbehave-иться на новій 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;
Кілька smaller things варто знати до першого token. Modules cache-уються за path, бо C_Initialize виконується один раз на process для кожного module, а repeat call відповідає CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), що PDFiumPas трактує як success, припускаючи, що інша частина host уже initialized ту саму library. Token strings, як slot description і token label, є blank-padded fixed-width fields, а не NUL-terminated, тому їх треба trim-ити з tail. І CKO_CERTIFICATE — це 1, а не 2: 0 — CKO_DATA, а 2 — CKO_PUBLIC_KEY. Записати цю constant з пам’яті — помилка, яка дає empty search result без жодного error
Що verified і де завершується guarantee
Чітко назвіть boundary, бо вона вужча за feature description. Сьогодні PDFiumPas перевіряє, що ABI layout field by field збігається з C headers в обох branches, що absent або unloadable module деградує в reported failure, а не crash, і що обидва toolchains, Delphi та FPC, будують unit. Real token paths — C_Login, object search, C_Sign проти hardware — не exercise-илися, бо development host взагалі не має встановленого PKCS#11 module. Спочатку bring up SoftHSM2 і підтвердьте Pkcs11AbiLayout, перш ніж підключати physical token, щоб ABI problem і token problem ніколи не довелося діагностувати одночасно. Варто назвати ще одну asymmetry. Signing side тепер cross-platform, verification side — ні. CMS verification усередині PDFiumPas усе ще guarded через {$IFDEF MSWINDOWS} і повертає pcsUnsupported деінде, без provider injection point, equivalent signer backend. Тому Linux service може створити PAdES B-B signature на key, що зберігається в token, але поки не може перевірити власний output на тій самій machine. Плануйте verification step на Windows або в external validator, доки gap не закриють
Lesson виходить за межі PKCS#11. Будь-який Pascal record, що mirror-ить conditionally packed C struct, потребує трьох речей: одного conditional alias для platform-variable scalar, щоб width decision існувало рівно в одному місці, packing directives, які огортають declarations і потім restore-яться, та runtime function, яка повідомляє resolved layout як щось, що може assert-ити test. Comments, які стверджують, що struct відповідає header, не варті нічого; SizeOf і field offset, надруковані startup, варті багато. PKCS#11 backend, CNG backend і решта signing stack постачаються у PDFium Component for Delphi and C++Builder, де ABI plumbing уже conditioned, тож ваш code може залишатися на token side problem