مقاله فنی

PKCS#11 در Delphi: دام CK_ULONG و struct packing

PDFiumPas در Windows، Linux و macOS سندهای PAdES را از طریق token مربوط به PKCS#11 امضا می‌کند و دو fact پلتفرمی تعیین می‌کنند این binding اصلاً کار می‌کند یا نه: CK_ULONG همان C unsigned long است، پس در Windows چهار byte و در Linux و macOS هشت byte دارد و headerهای PKCS#11 فقط در Windows، #pragma pack(1) را اعمال می‌کنند که position همه pointerها در function table را جابه‌جا می‌کند. هرکدام را اشتباه بگیرید، module همچنان load می‌شود، callها همچنان return می‌کنند و numberهای برگشتی garbage هستند. انتظار همین شکل از bug را داشته باشید. linker error در کار نیست، چون چیزی link نمی‌شود: module یک .so، .dylib یا .dll است که در runtime از روی path باز می‌کنید و کل surface، structی از function pointerهاست که cast و call می‌کنید. compiler نمی‌داند header C در سمت دیگر چه شکلی بوده است. هر mismatch تا لحظه crash خاموش است

چرا PKCS#11 binding به‌جای error تمیز، codeهای تصادفی CKR برمی‌گرداند؟

چون mismatch مربوط به ABI اصلاً condition خطایی تولید نمی‌کند؛ address یا offset اشتباه تولید می‌کند و token با وظیفه‌شناسی به هر سؤالی که از آن address یا offset حاصل شود پاسخ می‌دهد. هیچ لایه‌ای بین record declaration شما و module نیست که بتواند اختلاف را متوجه شود. دو failure mode متفاوت از این وضعیت بیرون می‌آید. اگر packing اشتباه باشد، slotی که شما به‌عنوان C_GetSlotList می‌خوانید شش byte از یک pointer و دو byte از pointer بعدی دارد؛ call کردن آن یا وارد memory map‌نشده می‌پرد یا بدتر، وسط function دیگری می‌نشیند. این همان access violation است. اگر CK_ULONG width اشتباه داشته باشد، addressها درست‌اند اما data نه: یک out-parameter از نوع var Count: CK_ULONG که چهار byte اعلام شده، توسط module نوع LP64 هشت byte در آن نوشته می‌شود و بی‌سروصدا چهار byte بعدی stack frame شما را overwrite می‌کند؛ template از نوع CK_ATTRIBUTE که ValueLen آن در offset اشتباه قرار دارد نیز باعث می‌شود module، length field را از داخل pointer مربوط به Value بخواند. سپس token برای سؤالی که هرگز نپرسیده‌اید، CKR_BUFFER_TOO_SMALL یا CKR_ATTRIBUTE_VALUE_INVALID کاملاً معتبر برمی‌گرداند. این codeها افراد را ساعت‌ها دنبال configuration token می‌فرستند، در حالی که bug چهار line بالاتر و داخل type declaration است

CK_ULONG همان C unsigned long است، نه type با width ثابت

headerهای PKCS#11، CK_ULONG را به‌عنوان C unsigned long تعریف می‌کنند؛ یعنی width آن از data model پلتفرم پیروی می‌کند، نه از specification. Windows مدل LLP64 دارد، پس unsigned long حتی در process شصت‌وچهاربیتی هم 32-bit باقی می‌ماند. Linux و macOS مدل LP64 دارند، بنابراین با pointer هم‌اندازه و 64-bit می‌شود. این مهم‌ترین line در کل unit است، چون در PKCS#11 تقریباً هر scalar یک CK_ULONG است: slot ID، session handle، object handle، object class، key type، attribute type، mechanism type، buffer length و حتی خود return value از نوع CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows مدل LLP64 است: C unsigned long در آنجا 32-bit می‌ماند
  CK_ULONG = LongWord;
{$ELSE}
  // Linux و macOS مدل LP64 هستند: unsigned long از width مربوط به 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;

هدف exercise این است که همه آن aliasها به CK_ULONG اشاره کنند نه اینکه مستقیم به LongWord یا UInt64 اشاره داده شوند. در این حالت conditional فقط یک بار ظاهر می‌شود. هرکدام را به‌صورت concrete بنویسید، landmineای ساخته‌اید که port آینده روی آن پا می‌گذارد و دقیقاً در همان جایی منفجر می‌شود که فراموش کرده‌اید

pragma pack(1) با function table مربوط به PKCS#11 چه می‌کند؟

این directive هر function pointer را در CK_FUNCTION_LIST جابه‌جا می‌کند، چون table با CK_VERSION دو بایتی آغاز می‌شود. با alignment طبیعی، compiler بعد از آن version شش byte padding می‌گذارد، پس نخستین function pointer در offset 8 قرار می‌گیرد. با byte packing هیچ paddingی وجود ندارد و pointer در offset 2 قرار می‌گیرد. هر entry بعدی همان displacement را به ارث می‌برد؛ برای همین اشتباه packing مشکل یک field نیست و کل table را درگیر می‌کند. دام این است که headerهای PKCS#11 فقط در Windows #pragma pack(1) را اعمال می‌کنند. این یک تفاوت platform است، نه module: دو build از یک vendor library بسته به host مبدأ می‌توانند درباره این موضوع اختلاف داشته باشند. همچنین packing برای structureهایی که همه fieldهایشان pointer-width است چیزی را عوض نمی‌کند؛ بیشتر structureها چنین هستند، پس test ساده‌ای که فقط CK_SLOT_INFO را لمس کند، با خوشحالی pass می‌شود در حالی که table زیر آن شش byte جابه‌جا است

{$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;      // دو byte و دلیل جابه‌جا شدن table
    C_Initialize: Pointer;    // offset 2 در packed و offset 8 در aligned
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... table ترتیب ثابتی دارد؛ declaration تا C_Sign برای رسیدن به
    // همه entry pointهایی که این backend call می‌کند کافی است
    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 می‌گوید ruleهای alignment مربوط به C compiler پلتفرم را دنبال کند و دقیقاً همان contractی است که در Linux و macOS نیاز دارید. branch مربوط به Delphi، {$A1} بدون شرط است چون buildهای Delphi از PDFiumPas هدفشان Windows است و FPC buildهای Linux و macOS را حمل می‌کند. line مربوط به restore در پایین نیز تزئینی نیست: unit را packed رها کنید و هر recordی که بعد از این نقطه declare شود بی‌سروصدا layout دیگری پیدا می‌کند، همان defect action-at-a-distance که سخت‌سازی binding مربوط به PDFium VCL در برابر ABI و خطاهای memory safety قرار است حذفش کند

Pkcs11AbiLayout: تبدیل layout به یک assertion

Pkcs11AbiLayout layoutی را که build واقعاً resolve کرده به‌صورت stringی قابل assert گزارش می‌کند، با شکل ulong=4 attr=16 pss=12 table=2. build شصت‌وچهاربیتی Windows باید دقیقاً همین را report کند و target از نوع LP64 باید 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 را تحویل داد:
// version نامعقول یا entry point تهی یعنی record با packing یا width اشتباه
// برای CK_ULONG layout شده است، پس 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;

این چهار number دل‌بخواهی نیستند. attr اندازه CK_ATTRIBUTE است که یک CK_ULONG، یک pointer و یک CK_ULONG را نگه می‌دارد: در Windows x64 با packing برابر 4 + 8 + 4 و در LP64 با alignment برابر 8 + 8 + 8. pss اندازه CK_RSA_PKCS_PSS_PARAMS است که سه field از نوع CK_ULONG دارد، پس 12 یا 24 می‌شود. table offset نخستین function pointer است و اولین valueای است که اشتباه packing را می‌گیرد. test case مربوط به Delphi، string را زیر {$IFDEF MSWINDOWS} assert می‌کند و suite مربوط به Lazarus نیز همین را assert می‌کند. یک equality check layoutی را پوشش می‌دهد که در غیر این صورت فقط با خواندن header C کنار record پاسکال و اعتماد به خودتان قابل verify بود. check زمان load نیمه دوم همین ایده است. PDFiumPas فقط C_GetFunctionList را با نام از طریق GetProcAddress یا GetProcedureAddress resolve می‌کند و هر entry point دیگر را از table برگشتی همان call می‌گیرد؛ این همان روشی است که OASIS PKCS #11 base specification برای رسیدن به module در نظر گرفته و به نام‌گذاری symbolهای vendor وابسته نیست. سپس آنچه برگشته را sanity-check می‌کند. major version بیرون از بازه 2 تا 3 یا C_Initialize، C_GetSlotList یا C_Sign تهی یعنی record misaligned است؛ module کنار گذاشته می‌شود و از طریق آن call نمی‌شود

امضا کردن از مسیر table: mechanismها، DigestInfo و C_Sign دو مرحله‌ای

وقتی layout درست باشد، کار signing کوچک است، چون contract مربوط به ICmsSigner که PDFiumPas از backend می‌خواهد فقط پنج method دارد و چهار مورد از آن‌ها OID و signer identifier را برمی‌گردانند. فقط SignSignedAttrsDigest واقعاً کاری انجام می‌دهد: digest سی‌ودوبایتی SHA-256 مربوط به signed attributes را می‌گیرد و signature byteها را برمی‌گرداند. assembly مربوط به CMS، ASN.1، timestamping از نوع RFC 3161 و DSS/LTV همگی مستقل از platform و از قبل انجام‌شده‌اند؛ همان تقسیم کاری که اجازه می‌دهد sessionهای remote PAdES در برابر HSM یا cloud key service به همین seam وصل شوند. اگر سه جزئیات mechanism را نادیده بگیرید، verification fail می‌شود. CKM_RSA_PKCS padding از نوع PKCS#1 v1.5 را اعمال می‌کند اما DigestInfo را نمی‌سازد، پس caller باید prefix نوزده‌بایتی SHA-256 DigestInfo از RFC 8017 را خودش prepend کند؛ digest خام را به token بدهید و signature سالمی روی چیز اشتباه می‌گیرید. CKM_RSA_PKCS_PSS و CKM_ECDSA digest را همان‌طور که ارائه شده می‌گیرند، اما CKM_ECDSA با جفت خام r||s پاسخ می‌دهد و CMS به SEQUENCE از نوع ECDSA-Sig-Value طبق RFC 3279 §2.2.3 نیاز دارد، پس PDFiumPas آن را convert می‌کند. همچنین C_Sign عمداً دو مرحله‌ای است: یک بار با buffer تهی call کنید تا token طول signature را بگوید و بار دوم با 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 روی platform جدید بدرفتاری می‌کند، این را پیش از هر چیز دیگر log کن
  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، چند نکته کوچک را هم بدانید. moduleها بر اساس path cache می‌شوند چون C_Initialize برای هر process و هر module یک بار است و call تکراری CKR_CRYPTOKI_ALREADY_INITIALIZED با مقدار 0x00000190 برمی‌گرداند؛ PDFiumPas با این فرض که بخش دیگری از host همان library را initialize کرده، آن را success در نظر می‌گیرد. stringهای token مانند slot description و token label fieldهای fixed-width با padding در انتها هستند، نه NUL-terminated و باید از tail trim شوند. همچنین CKO_CERTIFICATE برابر 1 است نه 2؛ 0 برابر CKO_DATA و 2 برابر CKO_PUBLIC_KEY است. نوشتن این constant از روی memory اشتباهی است که result search خالی و بدون هیچ error ایجاد می‌کند

چه چیزی verify شده و guarantee کجا متوقف می‌شود؟

درباره boundary شفاف باشید، چون از توضیح feature باریک‌تر است. چیزی که امروز در PDFiumPas verify شده این است که ABI layout در هر دو branch field به field با headerهای C match است، module غایب یا غیرقابل‌load شدن به failure گزارش‌شده تبدیل می‌شود نه crash و هر دو toolchain یعنی Delphi و FPC unit را build می‌کنند. مسیرهای واقعی token یعنی C_Login، object search و C_Sign در برابر hardware هنوز exercise نشده‌اند، چون روی development host اصلاً PKCS#11 module نصب نیست. ابتدا SoftHSM2 را راه‌اندازی کنید و پیش از اتصال token فیزیکی، Pkcs11AbiLayout را تأیید کنید تا مشکل ABI و مشکل token هرگز هم‌زمان برای diagnosis روی هم نیفتند. یک asymmetry دیگر هم ارزش نام بردن دارد. سمت signing حالا cross-platform است، اما سمت verification هنوز نیست. verification مربوط به CMS در PDFiumPas همچنان زیر {$IFDEF MSWINDOWS} guard شده و در جاهای دیگر pcsUnsupported برمی‌گرداند و هیچ provider injection pointی معادل backend امضا ندارد. پس یک service لینوکسی می‌تواند signature از نوع PAdES B-B روی key نگه‌داری‌شده در token تولید کند اما هنوز نمی‌تواند output خودش را روی همان machine check کند. تا رفع این فاصله، مرحله verification را روی Windows یا validator خارجی برنامه‌ریزی کنید

این درس از PKCS#11 فراتر می‌رود. هر record پاسکالی که mirror یک C struct با packing مشروط است، به سه چیز نیاز دارد: alias شرطی برای scalarای که width آن در platform عوض می‌شود تا تصمیم width دقیقاً یک‌جا وجود داشته باشد، directiveهای packing که declarationها را دربر بگیرند و بعد restore شوند و functionی در runtime که layout resolve‌شده را به چیزی تبدیل کند که test بتواند assert کند. commentهایی که می‌گویند struct با header match است ارزشی ندارند؛ SizeOf و field offsetی که هنگام startup چاپ شود ارزش زیادی دارد. backend مربوط به PKCS#11، backend مربوط به CNG و باقی signing stack در PDFium Component برای Delphi و C++Builder ارائه می‌شوند و plumbing مربوط به ABI از قبل condition شده است تا code شما در سمت token مسئله باقی بماند