บทความเทคนิค

PKCS#11 ใน Delphi: กับดัก CK_ULONG และ Struct Packing

PDFiumPas เซ็นเอกสาร PAdES ผ่าน PKCS#11 token บน Windows, Linux และ macOS และข้อเท็จจริงของ platform สองอย่างเป็นตัวตัดสินว่า binding จะทำงานหรือไม่ CK_ULONG คือ C unsigned long จึงกว้าง 4 byte บน Windows และ 8 byte บน Linux กับ macOS และ PKCS#11 header ใช้ #pragma pack(1) เฉพาะบน Windows ซึ่งเลื่อน pointer ทุกตัวใน function table หากผิดอย่างใดอย่างหนึ่ง module ก็ยัง load ได้ call ก็ยัง return ได้ และตัวเลขที่ได้กลับมาเป็น garbage นี่คือรูปทรงของ bug ที่ควรคาดไว้ ไม่มี linker error เพราะไม่มีอะไร link module เป็น .so, .dylib หรือ .dll ที่เปิดตอน runtime ตาม path และ surface ทั้งหมดเป็น struct ที่ประกอบด้วย function pointer ซึ่งคุณ cast แล้วเรียก compiler ไม่รู้ว่า C header อีกฝั่งมีหน้าตาอย่างไร mismatch ทุกตัวจึงเงียบจนกว่าจะกลายเป็น crash

ทำไม PKCS#11 binding จึง fail ด้วย random CKR code แทน error ที่ชัดเจน

เพราะ ABI mismatch ไม่ได้สร้าง error condition มันสร้าง address หรือ offset ที่ผิด และ token ก็ตอบคำถามที่ address นั้นบังเอิญกลายเป็นอย่างสุภาพ ไม่มี layer คั่นระหว่าง record declaration ของคุณกับ module ที่จะสังเกตความไม่ตรงกันได้ failure สองแบบจึงเกิดขึ้น หาก packing ผิด slot ที่คุณอ่านเป็น C_GetSlotList จะมี pointer หก byte จากตัวหนึ่งกับอีกสอง byte จากตัวถัดไป การ call มันอาจ jump เข้า unmapped memory หรือแย่กว่านั้นคือเข้าไปกลาง function อื่น นั่นคือ access violation หาก CK_ULONG กว้างผิด address ยังถูก แต่ data เสีย var Count: CK_ULONG ที่ประกาศกว้าง 4 byte จะถูก LP64 module เขียน 8 byte ลงไป และเขียนทับสี่ byte ถัดไปใน stack frame อย่างเงียบ ๆ ส่วน CK_ATTRIBUTE template ที่ ValueLen อยู่ผิด offset จะทำให้ module อ่าน length field จาก Value pointer ของคุณ token จึงคืน CKR_BUFFER_TOO_SMALL หรือ CKR_ATTRIBUTE_VALUE_INVALID อย่างถูกต้องสำหรับคำถามที่คุณไม่ได้ถาม code เหล่านี้ทำให้คนไปไล่หา token configuration อยู่หลายชั่วโมง ทั้งที่ bug อยู่สูงขึ้นไปสี่บรรทัดใน type declaration

CK_ULONG คือ C unsigned long ไม่ใช่ fixed-width type

header ของ PKCS#11 นิยาม CK_ULONG เป็น C unsigned long ซึ่งหมายความว่า width ของมันตาม data model ของ platform ไม่ได้ตาม specification Windows เป็น LLP64 ดังนั้น unsigned long ยังคง 32-bit แม้ process จะเป็น 64-bit ส่วน Linux กับ macOS เป็น LP64 จึงตาม pointer และกลายเป็น 64-bit นี่คือบรรทัดที่มีผลมากที่สุดใน 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 ตาม 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;

การ alias ทุกตัวให้เป็น CK_ULONG แทนการเขียน LongWord หรือ UInt64 ตรง ๆ คือหัวใจของงานนี้ มันทำให้ conditional มีอยู่เพียงจุดเดียว หากเขียนชนิดใดชนิดหนึ่งเป็น concrete type คุณกำลังวางกับระเบิดให้ port ในอนาคต และมันจะระเบิดตรงจุดที่คุณลืม

pragma pack(1) ทำอะไรกับ PKCS#11 function table

มันเลื่อน function pointer ทุกตัวใน CK_FUNCTION_LIST เพราะ table เริ่มด้วย CK_VERSION ขนาดสอง byte ภายใต้ natural alignment compiler จะเติม padding หก byte หลัง version ดังนั้น function pointer แรกอยู่ที่ offset 8 แต่ภายใต้ byte packing ไม่มี padding จึงอยู่ที่ offset 2 entry ทุกตัวหลังจากนั้น inherit displacement เดียวกัน นี่คือเหตุผลที่ packing mistake ไม่ใช่ปัญหาของ field เดียวแต่เป็นปัญหาทั้ง table trap คือ PKCS#11 header ใช้ #pragma pack(1) เฉพาะ Windows มันเป็น platform difference ไม่ใช่ module difference library ของ vendor เดียวกันที่ build สองครั้งจึงต่างกันได้ตาม host ที่มา อีกอย่างคือ packing ไม่เปลี่ยนอะไรใน structure ที่ field ทั้งหมดเป็น pointer-width ซึ่งมีอยู่ส่วนใหญ่ ดังนั้น test ที่แตะเฉพาะ CK_SLOT_INFO อาจผ่านสบายทั้งที่ 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 เมื่อ pack, offset 8 เมื่อ align
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... table มีลำดับตายตัว ประกาศ prefix ถึง C_Sign ก็พอ
    // สำหรับ entry point ทั้งหมดที่ 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 ของ C compiler บน platform ซึ่งตรงกับ contract ที่ต้องใช้บน Linux และ macOS ส่วน Delphi ใช้ {$A1} แบบ unconditional เพราะ PDFiumPas build ของ Delphi target Windows ขณะที่ FPC มี build บน Linux และ macOS และบรรทัด restore ด้านล่างไม่ใช่ของตกแต่ง หากปล่อย unit ให้อยู่ในสภาพ packed record ทุกตัวที่ประกาศหลังจากจุดนี้จะเปลี่ยน layout อย่างเงียบ ๆ ซึ่งเป็น action-at-a-distance defect แบบเดียวกับที่ การ harden PDFium component binding ต่อ ABI และ memory-safety fault ตั้งใจขจัด

Pkcs11AbiLayout: เปลี่ยน layout ให้เป็น assertion

Pkcs11AbiLayout รายงาน layout ที่ build resolve ได้จริงเป็น string ที่ assert ได้ในรูป ulong=4 attr=16 pss=12 table=2 build Windows 64-bit ต้องรายงานค่านี้พอดี ส่วน target แบบ LP64 ต้องรายงาน ulong=8 attr=24 pss=24 table=8 ค่าอื่นหมายความว่า call ผ่าน function table จะตกลงช่องผิด และ 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 ที่เป็น nil หมายถึง record
// ถูกจัด layout ด้วย packing หรือ CK_ULONG width ที่ผิด จึง reject 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 บน Windows x64 แบบ packed คือ 4 + 8 + 4 ส่วนบน LP64 ที่ align คือ 8 + 8 + 8 pss คือ CK_RSA_PKCS_PSS_PARAMS ที่มี CK_ULONG สาม field จึงเป็น 12 หรือ 24 table คือ offset ของ function pointer แรก และเป็นค่าที่จับ packing mistake ได้ก่อน ค่า test ของ Delphi assert string นี้ใต้ {$IFDEF MSWINDOWS} ส่วน Lazarus suite assert แบบเดียวกัน equality check หนึ่งครั้งครอบคลุม layout ที่ไม่เช่นนั้นต้องตรวจด้วยการอ่าน C header ข้าง Pascal record แล้วเชื่อสายตาตัวเอง load-time check คือครึ่งหลังของแนวคิดเดียวกัน PDFiumPas resolve เฉพาะ C_GetFunctionList ด้วยชื่อผ่าน GetProcAddress หรือ GetProcedureAddress แล้วดึง entry point ตัวอื่นทั้งหมดจาก table ที่ call นี้คืนมา ซึ่งตรงกับวิธีที่ OASIS PKCS #11 base specification ตั้งใจให้ module ถูกเรียก และหลบปัญหาการตั้งชื่อ symbol ที่ต่างกันตาม vendor จากนั้นจึง sanity-check ผลลัพธ์ major version ที่อยู่นอก 2 ถึง 3 หรือ C_Initialize, C_GetSlotList หรือ C_Sign ที่เป็น nil หมายถึง record misalign และ module จะถูกทิ้งแทนการเรียกผ่านมัน

เซ็นผ่าน table: mechanism, DigestInfo และ C_Sign แบบสอง pass

เมื่อ layout ถูกต้อง งาน sign จะเล็ก เพราะ contract ICmsSigner ที่ PDFiumPas ขอให้ backend ทำมีห้า method และสี่ตัวเป็นเพียงการคืน OID กับ signer identifier มีเพียง SignSignedAttrsDigest ที่ทำงานจริง มันรับ SHA-256 digest ขนาด 32 byte ของ signed attribute แล้วคืน signature byte CMS assembly, ASN.1, RFC 3161 timestamping และ DSS/LTV เป็น platform-independent และทำเสร็จอยู่แล้ว นี่เป็นการแบ่งงานแบบเดียวกับที่ทำให้ remote PAdES signing session กับ HSM หรือ cloud key service เสียบเข้ากับ seam เดียวกันได้ รายละเอียด mechanism สามอย่างจะทำให้ verification fail ถ้าข้าม CKM_RSA_PKCS ใช้ PKCS#1 v1.5 padding แต่ไม่สร้าง DigestInfo ดังนั้น caller ต้อง prepend SHA-256 DigestInfo prefix ขนาด 19 byte จาก RFC 8017 เอง หากส่ง bare digest ให้ token จะได้ 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 ต้อง convert และ C_Sign เป็น two-pass โดยตั้งใจ call ครั้งแรกด้วย nil buffer เพื่อถาม token ว่า signature ยาวเท่าไร แล้ว call ครั้งที่สองด้วย 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);
  // log นี้ควรทำก่อนอย่างอื่นเมื่อ token มีอาการผิดบน 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 ตัวแรก module ถูก cache ตาม path เพราะ C_Initialize ต้องทำครั้งเดียวต่อ process ต่อ module และ call ซ้ำจะคืน CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190) ซึ่ง PDFiumPas ถือเป็น success โดยสมมติว่าส่วนอื่นของ host initialize library เดียวกันไปแล้ว token string อย่าง slot description กับ token label เป็น field ความกว้างตายตัวที่เติม space ไม่ใช่ NUL-terminated จึงต้อง trim จากท้าย และ CKO_CERTIFICATE มีค่า 1 ไม่ใช่ 2 โดย 0 คือ CKO_DATA และ 2 คือ CKO_PUBLIC_KEY การเขียน constant นี้จากความจำเป็นความผิดพลาดที่คืน search result ว่างโดยไม่มี error ใดเลย

อะไรถูกตรวจแล้ว และ guarantee หยุดตรงไหน

ต้องเข้าใจ boundary ให้ชัด เพราะมันแคบกว่าคำอธิบาย feature สิ่งที่ PDFiumPas ตรวจแล้ววันนี้คือ ABI layout ตรงกับ C header field ต่อ field บนทั้งสอง branch module ที่หายไปหรือ load ไม่ได้ลดระดับเป็น reported failure แทน crash และทั้ง Delphi กับ FPC toolchain build unit นี้ได้ ส่วน token path จริง ได้แก่ 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 ตอนนี้ cross-platform แล้ว แต่ฝั่ง verification ยังไม่ใช่ CMS verification ภายใน PDFiumPas ยังอยู่ใต้ {$IFDEF MSWINDOWS} และคืน pcsUnsupported บน platform อื่น อีกทั้งไม่มี provider injection point ที่เทียบเท่าฝั่ง signer ดังนั้น Linux service สามารถสร้าง PAdES B-B signature ด้วย key ที่อยู่ใน token แต่ยังตรวจ output ของตัวเองบนเครื่องเดียวกันไม่ได้ วาง verification step บน Windows หรือ external validator ไปก่อนจนกว่าช่องว่างนี้จะถูกปิด

บทเรียนนี้ใช้ได้ไกลกว่า PKCS#11 Pascal record ใด ๆ ที่ mirror C struct แบบมีเงื่อนไขต้องมีสามอย่าง ได้แก่ conditional alias หนึ่งตัวสำหรับ scalar ที่ width ต่างกันตาม platform เพื่อให้ decision เรื่องขนาดอยู่จุดเดียว packing directive ที่ครอบ declaration แล้ว restore หลังจากนั้น และ runtime function ที่รายงาน resolved layout เป็นค่าที่ test assert ได้ comment ที่อ้างว่า struct ตรงกับ header ไม่มีน้ำหนัก SizeOf กับ field offset ที่ print ตอน startup มีน้ำหนักมาก PKCS#11 backend, CNG backend และ signing stack ส่วนที่เหลืออยู่ใน PDFium Component for Delphi และ C++Builder ซึ่งจัดการ ABI plumbing ตาม platform ไว้แล้วเพื่อให้ code ของคุณอยู่ฝั่ง token ไม่ใช่ฝั่งปัญหา