Техническа статия

PKCS#11 в Delphi: капанът CK_ULONG и packing

PDFiumPas подписва PAdES document-и през 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-ва, call-овете пак връщат, а числата, които идват обратно, са garbage. Това е формата на bug-а, която трябва да очаквате. Никой не ви дава linker error, защото нищо не се link-ва: module-ът е .so, .dylib или .dll, който отваряте runtime по path, а целият surface е struct от function pointers, които cast-вате и извиквате. Compiler-ът няма идея как е изглеждал C header-ът от другата страна. Всеки mismatch е silent до момента на crash

Защо PKCS#11 binding fail-ва с random CKR codes вместо с clean error?

Защото ABI mismatch не произвежда error condition, а грешен address или грешен offset, а token-ът добросъвестно отговаря на какъвто въпрос се окаже това. Няма layer между вашата record declaration и module-а, който да забележи несъгласието. От това идват два различни failure modes. Ако packing-ът е грешен, slot-ът, който четете като C_GetSlotList, съдържа six bytes от единия pointer и два от следващия, а call-ът скача в unmapped memory или, по-лошо, в средата на друга function. Това е access violation. Ако CK_ULONG е с грешна width, addresses са наред, но data не е: var Count: CK_ULONG out-parameter, деклариран с 4 bytes, получава 8 bytes от LP64 module и тихо overwrite-ва следващите четири 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-ът е четири реда по-нагоре в 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 и самият CK_RV return value

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;

Aliasing-ът на всеки от тези types към CK_ULONG, вместо директно към LongWord или UInt64, е смисълът на упражнението. Така conditional се появява точно веднъж. Изпишете някой от тях конкретно и сте заложили 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. Trap-ът е, че 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, ще мине happily, докато table-ът под него е изместен с 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 е достатъчна за всичко, което този backend извиква
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Три неща в този block са по-важни, отколкото изглеждат. {$PACKRECORDS C} не е същото като „няма 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-ът действително е разрешил, като един 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;

// At load time, след като C_GetFunctionList е върнал table-а:
// implausible version или nil entry point означава, че record-ът е layout-нат
// с грешен 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;

Четирите числа не са 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 и това е стойността, която първа хваща packing mistake. Delphi test case assert-ва string-а под {$IFDEF MSWINDOWS}; Lazarus suite-ът assert-ва същото. Един equality check покрива layout, който иначе бихте могли да верифицирате само като четете C header side by side с Pascal record и се доверите на себе си. Load-time check-ът е втората половина на същата идея. PDFiumPas resolve-ва само C_GetFunctionList по име през GetProcAddress или GetProcedureAddress и взема всеки друг entry point от table-а, който този call връща, точно както OASIS PKCS #11 base specification възнамерява да бъде достигнат module, като така заобикаля vendor-specific symbol naming. После прави sanity check на върнатото. Major version извън 2 до 3 или nil C_Initialize, C_GetSlotList или C_Sign означава misaligned record и module-ът се отхвърля, вместо да бъде извикан през него

Подписване през table: mechanisms, DigestInfo и двупасов C_Sign

Щом layout-ът е правилен, signing work е малък, защото ICmsSigner contract-ът, който PDFiumPas иска backend да изпълни, има пет methods и четири от тях просто връщат OIDs и signer identifier. Само SignSignedAttrsDigest върши работа: взема 32-byte SHA-256 digest на signed attributes и връща signature bytes. CMS assembly, ASN.1, RFC 3161 timestamping и DSS/LTV са platform-independent и вече са готови, което е същото разделение на отговорностите, което позволява remote PAdES signing sessions към HSM или cloud key service да се включват в идентичния seam. Три mechanism details ще ви струват failed verification, ако ги пропуснете. CKM_RSA_PKCS прилага PKCS#1 v1.5 padding, но не изгражда DigestInfo, така че caller-ът сам prepend-ва 19-byte SHA-256 DigestInfo prefix от RFC 8017; подайте на token-а bare digest и ще получите well-formed signature върху грешното нещо. 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 е two-pass по дизайн: извикайте го с nil buffer, за да поискате 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;

Няколко по-малки неща си струва да знаете преди първия token. Modules се cache-ват по path, защото C_Initialize е once per process per module, а повторен call връща CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), което PDFiumPas третира като success с предположението, че друга част от host-а вече е initialize-нала същата library. Token strings, като slot description и token label, са blank-padded fixed-width fields, а не NUL-terminated, така че трябва да бъдат trim-нати от края. И CKO_CERTIFICATE е 1, а не 2 — 0 е CKO_DATA и 2 е CKO_PUBLIC_KEY. Да напишете тази constant по memory е грешка, която произвежда empty search result и никакъв error

Какво се проверява и къде спира гаранцията

Бъдете ясни за boundary-то, защото е по-тясно, отколкото подсказва feature description-ът. Провереното в PDFiumPas днес е, че ABI layout-ът съвпада с C headers field по field и в двата branches, че absent или unloadable module деградира до reported failure, а не до crash, и че и Delphi, и FPC toolchains build-ват unit-а. Реалните token paths — C_Login, object search, C_Sign срещу hardware — не са упражнени, защото development host няма инсталиран PKCS#11 module. Вдигнете първо SoftHSM2 и потвърдете Pkcs11AbiLayout, преди да включите physical token, така че ABI problem и token problem никога да не трябва да се диагностицират едновременно. Още една asymmetry заслужава име. Signing side вече е cross-platform, verification side не е. CMS verification вътре в PDFiumPas все още е guard-нато от {$IFDEF MSWINDOWS} и връща pcsUnsupported другаде, а няма provider injection point, еквивалентен на signer backend-а. Така Linux service може да произведе PAdES B-B signature върху token-held key, но още не може да провери собствения си output на същата машина. Планирайте verification step върху Windows или върху external validator, докато тази gap не се затвори

Изводът се обобщава и отвъд PKCS#11. Всеки Pascal record, който mirror-ва conditionally packed C struct, се нуждае от три неща: един conditional alias за platform-variable scalar, така че width decision да съществува на едно място, packing directives, които ограждат declarations и после се restore-ват, и runtime function, която докладва resolved layout като нещо, върху което test може да assert-не. 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-а на проблема