PDFiumPas ký document PAdES qua token PKCS#11 trên Windows, Linux và macOS, còn hai fact về platform quyết định binding có hoạt động hay không: CK_ULONG là C unsigned long, nên dài 4 byte trên Windows và 8 byte trên Linux cùng macOS, còn header PKCS#11 chỉ áp dụng #pragma pack(1) trên Windows, làm mọi pointer trong function table dịch vị trí. Sai một trong hai, module vẫn load, call vẫn return, còn số liệu trả về là rác. Đó là hình dạng bug bạn nên chờ. Không có linker error vì không gì được link: module là .so, .dylib hoặc .dll mở runtime theo path, toàn bộ surface là một struct function pointer được cast rồi call. Compiler không biết C header ở phía bên kia trông ra sao. Mọi mismatch chỉ im lặng cho tới khi thành crash
Vì sao PKCS#11 binding fail bằng CKR code ngẫu nhiên thay vì lỗi rõ ràng?
Vì ABI mismatch hoàn toàn không tạo error condition; nó tạo ra address hoặc offset sai, còn token ngoan ngoãn trả lời bất cứ câu hỏi nào mà kết quả đó tình cờ trở thành. Không có layer nào giữa record declaration và module để nhận ra hai bên bất đồng. Hai failure mode riêng biệt sinh ra từ đó. Nếu packing sai, slot bạn đọc như C_GetSlotList chứa sáu byte của một pointer và hai byte của pointer kế, còn call vào đó sẽ nhảy vào memory chưa map hoặc tệ hơn, vào giữa một function nào đó. Đó là access violation. Nếu CK_ULONG sai width, address vẫn đúng nhưng data sai: một out-parameter var Count: CK_ULONG khai báo rộng 4 byte bị module LP64 ghi 8 byte, âm thầm overwrite bốn byte kế của stack frame, còn template CK_ATTRIBUTE có ValueLen ở sai offset khiến module đọc length field từ pointer Value. Token sau đó trả về CKR_BUFFER_TOO_SMALL hoặc CKR_ATTRIBUTE_VALUE_INVALID hoàn toàn hợp lệ cho một câu hỏi bạn chưa từng hỏi. Các code đó khiến người ta đào vào token configuration hàng giờ. Bug nằm cách đó bốn dòng trong type declaration
CK_ULONG là C unsigned long, không phải fixed-width type
CK_ULONG được header PKCS#11 định nghĩa là C unsigned long, nghĩa là width đi theo data model của platform chứ không theo specification. Windows là LLP64, nên unsigned long vẫn 32-bit ngay cả trong process 64-bit. Linux và macOS là LP64, nên nó đi theo pointer và thành 64-bit. Đây là dòng có hệ quả lớn nhất trong toàn unit vì trong PKCS#11 gần như mọi scalar đều là CK_ULONG: slot ID, session handle, object handle, object class, key type, attribute type, mechanism type, buffer length và cả return value CK_RV
type
{$IFDEF MSWINDOWS}
// Windows là LLP64: C unsigned long ở đó vẫn 32-bit
CK_ULONG = LongWord;
{$ELSE}
// Linux và macOS là LP64: unsigned long theo width của 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;
Alias mọi thứ đó về CK_ULONG thay vì viết trực tiếp LongWord hoặc UInt64 mới là mục đích bài tập. Nó khiến conditional xuất hiện đúng một lần. Viết bất kỳ type nào thành concrete type và bạn đã đặt một quả mìn mà port tương lai sẽ giẫm vào, đúng tại nơi bạn quên
pragma pack(1) làm gì với PKCS#11 function table?
Nó dịch mọi function pointer trong CK_FUNCTION_LIST vì table bắt đầu bằng CK_VERSION dài hai byte. Dưới natural alignment, compiler chèn sáu byte padding sau version, nên function pointer đầu tiên ở offset 8. Dưới byte packing không có padding, nó ở offset 2. Mọi entry sau đó cùng mang displacement, vì vậy packing mistake không phải vấn đề một field mà là vấn đề cả table. Cái bẫy là header PKCS#11 chỉ áp dụng #pragma pack(1) trên Windows. Đây là khác biệt platform chứ không phải module: hai build của cùng vendor library sẽ khác điều này tùy host chúng đến từ đâu. Cũng lưu ý packing không thay đổi gì với struct mà mọi field đều pointer-width, vốn là phần lớn struct, nên test hời hợt chỉ chạm CK_SLOT_INFO vẫn pass trong khi table bên dưới đã lệch sáu 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; // hai byte, và là lý do table dịch
C_Initialize: Pointer; // offset 2 packed, offset 8 aligned
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... table có thứ tự cố định; khai báo prefix
// tới C_Sign là đủ để chạm mọi entry point backend gọi
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Có ba thứ trong block đó quan trọng hơn vẻ ngoài. {$PACKRECORDS C} không giống "không có directive"; nó bảo Free Pascal theo alignment của C compiler trên platform, đúng contract cần có trên Linux và macOS. Nhánh Delphi là {$A1} vô điều kiện vì build PDFiumPas bằng Delphi nhắm Windows, còn FPC mang build Linux và macOS. Dòng restore cuối không phải cosmetic: để unit ở trạng thái packed sẽ khiến mọi record khai báo sau đó âm thầm đổi layout, đúng kiểu action-at-a-distance defect mà hardening PDFium component binding chống lỗi ABI và memory safety muốn loại bỏ
Pkcs11AbiLayout: biến layout thành assertion
Pkcs11AbiLayout báo layout build thực sự resolve dưới dạng một string có thể assert, dạng ulong=4 attr=16 pss=12 table=2. Build Windows 64-bit phải báo đúng string đó, còn target LP64 phải báo ulong=8 attr=24 pss=24 table=8. Bất kỳ kết quả nào khác nghĩa là call qua function table sẽ rơi vào slot sai, còn function này tồn tại để unit test nói thẳng điều đó thay vì một comment tự khẳng định
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;
// Lúc load, sau khi C_GetFunctionList trả table về:
// version vô lý hoặc entry point nil nghĩa là record có packing hoặc
// CK_ULONG width sai, nên từ chối 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;
Bốn con số không phải arbitrary. attr là size của CK_ATTRIBUTE, chứa CK_ULONG, pointer và CK_ULONG: packed trên Windows x64 là 4 + 8 + 4, còn aligned trên LP64 là 8 + 8 + 8. pss là CK_RSA_PKCS_PSS_PARAMS, ba field CK_ULONG, nên là 12 hoặc 24. table là offset của function pointer đầu tiên và là value bắt packing mistake trước tiên. Delphi test case assert string dưới {$IFDEF MSWINDOWS}, Lazarus suite cũng assert điều tương tự. Một equality check bao phủ layout mà nếu không sẽ chỉ có thể verify bằng cách đặt C header cạnh Pascal record rồi tin vào bản thân. Load-time check là nửa thứ hai của cùng ý tưởng. PDFiumPas chỉ resolve C_GetFunctionList theo name qua GetProcAddress hoặc GetProcedureAddress, lấy mọi entry point khác từ table call đó trả về, đúng cách OASIS PKCS #11 base specification muốn module được tiếp cận và tránh per-vendor symbol naming. Sau đó nó sanity-check kết quả. Major version ngoài 2 đến 3 hoặc C_Initialize, C_GetSlotList hay C_Sign là nil đều nghĩa record misaligned, module bị drop thay vì bị call qua
Signing qua table: mechanism, DigestInfo và C_Sign hai pass
Khi layout đúng, công việc signing nhỏ vì contract ICmsSigner mà PDFiumPas yêu cầu backend đáp ứng có năm method, trong đó bốn method chỉ trả OID và signer identifier. Chỉ SignSignedAttrsDigest làm việc thật: nhận SHA-256 digest 32 byte của signed attribute rồi trả signature byte. CMS assembly, ASN.1, RFC 3161 timestamping và DSS/LTV đều platform-independent và đã làm xong, cùng sự phân chia trách nhiệm cho phép remote PAdES signing session qua HSM hoặc cloud key service cắm vào đúng seam. Bỏ qua ba chi tiết mechanism này sẽ làm verification fail. CKM_RSA_PKCS áp PKCS#1 v1.5 padding nhưng không dựng DigestInfo, nên caller phải tự prepend prefix DigestInfo SHA-256 19 byte từ RFC 8017; đưa bare digest cho token sẽ tạo signature hợp lệ về hình thức nhưng ký nhầm thứ. CKM_RSA_PKCS_PSS và CKM_ECDSA nhận digest nguyên trạng, nhưng CKM_ECDSA trả raw cặp r||s còn CMS cần ECDSA-Sig-Value SEQUENCE của RFC 3279 §2.2.3, nên PDFiumPas phải convert. C_Sign cố ý chạy hai pass: gọi với buffer nil để hỏi token signature length, rồi gọi lần nữa với buffer đúng 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);
// Khi token cư xử sai trên platform mới, log dòng này trước mọi thứ khác
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;
Có vài điều nhỏ nên biết trước token đầu tiên. Module được cache theo path vì C_Initialize chỉ chạy một lần mỗi process cho mỗi module, còn call lặp trả CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), được PDFiumPas coi là success với giả định phần khác của host đã init cùng library. Token string như slot description và token label là field fixed-width được pad bằng space chứ không NUL-terminated, nên phải trim ở cuối. Và CKO_CERTIFICATE là 1 chứ không phải 2 — 0 là CKO_DATA còn 2 là CKO_PUBLIC_KEY. Viết constant đó theo trí nhớ là lỗi tạo empty search result mà hoàn toàn không có error
Đã verify gì, và guarantee dừng ở đâu
Cần rõ boundary vì nó hẹp hơn feature description gợi ý. PDFiumPas hiện đã verify ABI layout khớp C header từng field trên cả hai nhánh, module vắng hoặc không load được degrade thành failure được báo cáo thay vì crash, và cả toolchain Delphi lẫn FPC đều build được unit. Các token path thật — C_Login, object search, C_Sign trên hardware — chưa được exercise vì development host hoàn toàn không cài PKCS#11 module. Hãy bring up SoftHSM2 trước và xác nhận Pkcs11AbiLayout trước khi cắm physical token, để ABI problem và token problem không phải chẩn đoán cùng lúc. Có một bất đối xứng nữa cần gọi tên. Signing side giờ cross-platform, verification side thì chưa. CMS verification bên trong PDFiumPas vẫn guard bằng {$IFDEF MSWINDOWS} và trả pcsUnsupported ở nơi khác, còn không có provider injection point tương đương signer backend. Vì vậy Linux service có thể tạo chữ ký PAdES B-B bằng key trong token nhưng chưa thể tự check output trên cùng machine. Hãy đưa bước verification sang Windows hoặc validator bên ngoài cho tới khi khoảng trống này được lấp
Bài học vượt ra ngoài PKCS#11. Bất kỳ Pascal record nào mirror C struct có packing phụ thuộc điều kiện đều cần ba thứ: một conditional alias cho scalar thay đổi theo platform để quyết định width chỉ xuất hiện một nơi, packing directive bao quanh declaration và được restore sau đó, cùng runtime function báo layout đã resolve dưới dạng test có thể assert. Comment tuyên bố struct khớp header không đáng giá; SizeOf và field offset in lúc startup đáng giá hơn nhiều. PKCS#11 backend, CNG backend cùng phần còn lại của signing stack nằm trong PDFium Component cho Delphi và C++Builder, nơi ABI plumbing đã được condition để code của bạn chỉ phải lo phía token