PDFiumPas подписывает документы PAdES через PKCS#11 token в Windows, Linux и macOS, и две особенности платформы определяют, будет ли binding вообще работать: CK_ULONG — это C unsigned long, то есть 4 байта в Windows и 8 байт в Linux и macOS, а PKCS#11 headers применяют #pragma pack(1) только в Windows, смещая каждый pointer в function table. Ошибитесь хотя бы в одном — module всё ещё загружается, вызовы всё ещё возвращают значения, а числа, которые приходят обратно, оказываются мусором. Именно такой формы нужно ждать от bug. Linker error никто не выдаст, потому что линковки нет: module — это .so, .dylib или .dll, открываемый во время выполнения по path, а вся поверхность представляет собой struct из function pointers, которые вы cast-ите и вызываете. Compiler понятия не имеет, как выглядел C header на другой стороне. Любое расхождение молчит до самого crash
Почему PKCS#11 binding возвращает случайные CKR codes, а не чистую ошибку?
Потому что ABI mismatch вообще не создаёт error condition, а выдаёт неправильный address или offset, и token добросовестно отвечает на тот вопрос, который в итоге получился. Между вашей record declaration и module нет слоя, способного заметить расхождение. Из этого выходят два разных failure mode. Если packing неверен, slot, который вы читаете как C_GetSlotList, содержит шесть байт одного pointer и два байта следующего, а вызов прыгает в unmapped memory или, что хуже, в середину другой функции. Это access violation. Если CK_ULONG имеет неправильную width, addresses правильны, а данные нет: out-parameter var Count: CK_ULONG, объявленный шириной 4 байта, получает от LP64 module 8 байт и тихо перезаписывает следующие четыре байта вашего stack frame, а template CK_ATTRIBUTE, у которого ValueLen находится по неправильному offset, заставляет module прочитать length field из вашего pointer Value. Token затем возвращает совершенно легитимный CKR_BUFFER_TOO_SMALL или CKR_ATTRIBUTE_VALUE_INVALID для вопроса, которого вы не задавали. Эти codes на часы отправляют людей искать проблему в конфигурации token. Bug находится на четыре строки выше, в type declaration
CK_ULONG — это C unsigned long, а не fixed-width type
PKCS#11 headers определяют CK_ULONG как C unsigned long, поэтому его width следует data model платформы, а не спецификации. Windows использует LLP64, значит unsigned long остаётся 32-битным даже в 64-битном процессе. Linux и macOS используют LP64, поэтому он следует за pointer и становится 64-битным. Это самая важная строка во всём unit, потому что в PKCS#11 практически каждый scalar является CK_ULONG: slot IDs, session handles, object handles, object classes, key types, attribute types, mechanism types, buffer lengths и само возвращаемое значение CK_RV
type
{$IFDEF MSWINDOWS}
// Windows использует LLP64: C unsigned long остаётся здесь 32-битным
CK_ULONG = LongWord;
{$ELSE}
// Linux и macOS используют LP64: unsigned long следует за шириной pointer
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;
Смысл упражнения в том, чтобы каждое из этих aliases вести к CK_ULONG, а не непосредственно к LongWord или UInt64. Тогда conditional появляется ровно один раз. Запишите любой из типов конкретно — и заложите мину, на которую будущий port наступит именно в том месте, про которое вы забыли
Что pragma pack(1) делает с PKCS#11 function table?
Она сдвигает каждый function pointer в CK_FUNCTION_LIST, потому что таблица начинается с двухбайтного CK_VERSION. При natural alignment compiler вставляет после этой version шесть байт padding, поэтому первый function pointer оказывается на offset 8. При byte packing padding нет, и он оказывается на offset 2. Каждая следующая entry наследует то же смещение, поэтому ошибка packing — проблема всей таблицы, а не одного field. Ловушка в том, что PKCS#11 headers применяют #pragma pack(1) только в Windows. Это различие платформ, а не modules: две сборки одной vendor library расходятся в зависимости от host, на котором они получены. Заметьте также, что packing ничего не меняет для структур, чьи поля целиком pointer-width, а это большинство из них, поэтому наивный test, который трогает только CK_SLOT_INFO, спокойно пройдёт, пока лежащая под ним table смещена на шесть байт
{$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; // два байта и причина смещения 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
// достаточна, чтобы добраться до всех вызываемых этим backend entries
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
В этом блоке важнее, чем кажется, три вещи. {$PACKRECORDS C} — не то же самое, что «нет directive»: он говорит Free Pascal следовать правилам alignment платформенного C compiler, а именно это и есть контракт, нужный в Linux и macOS. Ветка Delphi безусловно задаёт {$A1}, потому что Delphi builds PDFiumPas нацелены на Windows, а FPC несёт сборки Linux и macOS. И строка восстановления внизу не косметика: если оставить unit packed, каждая record, объявленная после этого места, молча изменит layout, и именно такой defect на расстоянии призвана устранить статья о защите PDFium component binding от ABI и memory-safety faults
Pkcs11AbiLayout: превратить layout в assertion
Pkcs11AbiLayout сообщает фактический layout сборки в виде одной проверяемой строки формата ulong=4 attr=16 pss=12 table=2. 64-битная Windows build обязана сообщать ровно это, а LP64 target — ulong=8 attr=24 pss=24 table=8. Всё остальное означает, что вызов через function table попадёт не в тот slot, и эта функция существует, чтобы unit test мог сказать это вслух, а не полагаться на комментарий
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;
// Во время загрузки, после того как C_GetFunctionList вернул table:
// неправдоподобная version или nil entry point означает неверный layout record
// из-за packing или width CK_ULONG, поэтому 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;
Четыре числа не случайны. attr — размер 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, то есть 12 или 24. table — offset первого function pointer, и именно он первым ловит ошибку packing. Delphi test case проверяет строку под {$IFDEF MSWINDOWS}, Lazarus suite проверяет то же. Одна equality check покрывает layout, который иначе можно было бы верифицировать только чтением C header рядом с Pascal record и доверием к себе. Load-time check — вторая часть той же идеи. PDFiumPas разрешает по имени через GetProcAddress или GetProcedureAddress только C_GetFunctionList, а все остальные entry points берёт из table, которую возвращает этот вызов, как и предполагает базовая спецификация OASIS PKCS #11, обходя vendor-specific symbol naming. Затем он проверяет результат на здравый смысл. Major version вне диапазона 2–3 или nil у C_Initialize, C_GetSlotList либо C_Sign означает, что record misaligned, и module отбрасывается вместо вызова через него
Подпись через table: mechanisms, DigestInfo и двухпроходный C_Sign
Когда layout правильный, signing work невелик, потому что контракт ICmsSigner, которому PDFiumPas просит соответствовать backend, содержит пять методов, а четыре из них лишь возвращают OID и signer identifier. Реально работает только SignSignedAttrsDigest: он принимает 32-байтный SHA-256 digest signed attributes и возвращает bytes подписи. CMS assembly, ASN.1, RFC 3161 timestamping и DSS/LTV уже выполнены и не зависят от платформы — то же разделение позволяет remote PAdES signing sessions с HSM или cloud key service подключаться к тому же seam. Если пропустить три детали mechanisms, verification провалится. CKM_RSA_PKCS применяет PKCS#1 v1.5 padding, но не строит DigestInfo, поэтому вызывающий сам добавляет 19-байтный SHA-256 DigestInfo prefix из RFC 8017; передайте token чистый digest и получите корректно оформленную подпись не того, что нужно. CKM_RSA_PKCS_PSS и CKM_ECDSA получают digest как есть, но CKM_ECDSA отвечает raw-парой r||s, а CMS требует SEQUENCE ECDSA-Sig-Value из RFC 3279 §2.2.3, поэтому PDFiumPas преобразует результат. И C_Sign по замыслу двухпроходный: сначала вызывается с nil buffer, чтобы спросить token длину подписи, затем ещё раз с buffer такого размера
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);
// Записывать это нужно до всего остального, если token плохо ведёт себя на новой платформе
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;
Перед первым token стоит знать ещё несколько мелочей. Modules кэшируются по path, потому что C_Initialize выполняется один раз на process для каждого module, а повторный вызов возвращает CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), которое PDFiumPas считает успехом, предполагая, что другая часть host уже инициализировала ту же library. Строки token, такие как slot description и token label, являются fixed-width полями с padding пробелами, а не NUL-terminated, поэтому хвост нужно обрезать. И CKO_CERTIFICATE равен 1, а не 2 — 0 это CKO_DATA, а 2 — CKO_PUBLIC_KEY. Записать эту константу по памяти — ошибка, которая даёт пустой search result и вообще никакой ошибки
Что проверяется и где заканчивается гарантия
Границу нужно обозначать чётко, потому что она уже, чем предполагает описание возможности. Сегодня PDFiumPas проверяет, что ABI layout соответствует C headers field by field в обеих ветках, что отсутствующий или незагружаемый module превращается в сообщённый failure, а не crash, и что unit собирается обоими toolchains Delphi и FPC. Настоящие token paths — C_Login, поиск объектов, C_Sign против hardware — не выполнялись, поскольку на development host вообще не установлен PKCS#11 module. Сначала поднимите SoftHSM2 и проверьте Pkcs11AbiLayout, затем подключайте physical token, чтобы ABI problem и token problem не пришлось диагностировать одновременно. Стоит назвать ещё одну асимметрию. Signing side теперь cross-platform, verification side — нет. CMS verification внутри PDFiumPas по-прежнему защищён {$IFDEF MSWINDOWS} и в остальных случаях возвращает pcsUnsupported, причём provider injection point, эквивалентного signer backend, у него нет. Поэтому Linux service может создать подпись PAdES B-B через token-held key, но пока не может проверить собственный output на той же машине. Планируйте verification step на Windows или во внешнем validator, пока этот разрыв не закрыт
Вывод обобщается за пределы PKCS#11. Любая Pascal record, отражающая conditionally packed C struct, нуждается в трёх вещах: одном conditional alias для platform-variable scalar, чтобы решение о width существовало ровно в одном месте, packing directives, охватывающих объявления и затем восстановленных, и runtime function, сообщающей resolved layout в форме, которую может проверить test. Комментарии о совпадении struct с header ничего не стоят; SizeOf и field offset, распечатанные при старте, стоят очень многого. PKCS#11 backend, CNG backend и остальная signing stack входят в PDFium Component для Delphi и C++Builder, где ABI plumbing уже условно настроен, а вашему коду остаётся заниматься token side проблемы