Τεχνικό Άρθρο

PKCS#11 στο Delphi: CK_ULONG και η packing παγίδα

Το PDFiumPas υπογράφει PAdES documents μέσω 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 εξακολουθεί να φορτώνει, τα calls εξακολουθούν να επιστρέφουν και οι αριθμοί που έρχονται πίσω είναι garbage. Αυτό είναι το σχήμα του bug που πρέπει να περιμένεις. Δεν σου δίνει κανείς linker error, επειδή τίποτε δεν γίνεται link: το module είναι .so, .dylib ή .dll που ανοίγεις κατά το runtime από path, και ολόκληρη η επιφάνεια είναι struct από function pointers που κάνεις cast και call. Ο compiler δεν γνωρίζει πώς έμοιαζε το C header στην άλλη πλευρά. Κάθε mismatch μένει σιωπηλό μέχρι να γίνει crash

Γιατί ένα PKCS#11 binding αποτυγχάνει με τυχαία CKR codes αντί για καθαρό error

Επειδή ένα ABI mismatch δεν παράγει καθόλου error condition, αλλά λάθος address ή λάθος offset, και το token απαντά ευσυνείδητα ό,τι ερώτηση προκύψει από αυτό. Δεν υπάρχει layer ανάμεσα στη record declaration σου και στο module που θα μπορούσε να παρατηρήσει τη διαφωνία. Προκύπτουν δύο διαφορετικά failure modes. Αν το packing είναι λάθος, το slot που διαβάζεις ως C_GetSlotList κρατά έξι 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 και αθόρυβα γράφει πάνω στα επόμενα τέσσερα bytes του stack frame, ενώ template CK_ATTRIBUTE του οποίου το ValueLen βρίσκεται σε λάθος offset κάνει το module να διαβάζει length field από το Value pointer σου. Το token μετά επιστρέφει απολύτως νόμιμο 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. Αυτή είναι η πιο consequential γραμμή σε ολόκληρη τη unit, επειδή στο PKCS#11 σχεδόν κάθε scalar είναι CK_ULONG: slot IDs, session handles, object handles, object classes, key types, attribute types, mechanism types, buffer lengths και η ίδια η 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, έχεις τοποθετήσει landmine που θα πατήσει future port και θα πατήσει ακριβώς στο σημείο που ξέχασες

Τι κάνει το pragma pack(1) στο PKCS#11 function table

Μετακινεί κάθε function pointer στο CK_FUNCTION_LIST, επειδή το table ανοίγει με two-byte CK_VERSION. Με natural alignment ο compiler βάζει έξι bytes padding μετά από εκείνο το version, οπότε ο πρώτος function pointer βρίσκεται στο offset 8. Με byte packing δεν υπάρχει padding και βρίσκεται στο offset 2. Κάθε επόμενο entry κληρονομεί την ίδια μετατόπιση, γι’ αυτό το packing mistake δεν είναι πρόβλημα ενός field αλλά ολόκληρου table. Η παγίδα είναι ότι τα 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 θα περάσει πρόθυμα ενώ το table από κάτω έχει μετακινηθεί κατά έξι 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;      // δύο 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· η δήλωση μέχρι το 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 rules του platform C compiler, ακριβώς το contract που χρειάζεσαι σε Linux και macOS. Το Delphi branch είναι unconditional {$A1} επειδή τα Delphi builds του PDFiumPas στοχεύουν 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;

// Κατά το load, αφού το C_GetFunctionList επιστρέψει το table:
// implausible version ή nil entry point σημαίνει λάθος 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 είναι το 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 κάνει το ίδιο. Ένας equality check καλύπτει layout που διαφορετικά θα επαληθευόταν μόνο διαβάζοντας C header δίπλα σε Pascal record και εμπιστευόμενος τον εαυτό σου. Ο load-time check είναι το δεύτερο μισό της ίδιας ιδέας. Το PDFiumPas επιλύει μόνο το C_GetFunctionList με όνομα μέσω GetProcAddress ή GetProcedureAddress και παίρνει κάθε άλλο entry point από το table που επιστρέφει εκείνο το call, όπως προβλέπει να προσεγγίζεται ένα module η OASIS PKCS #11 base specification, αποφεύγοντας vendor-specific symbol naming. Μετά κάνει sanity-check σε ό,τι επέστρεψε. Major version έξω από 2 έως 3 ή nil C_Initialize, C_GetSlotList ή C_Sign σημαίνει misaligned record, και το module απορρίπτεται αντί να γίνει call μέσω αυτού

Υπογραφή μέσω του table: mechanisms, DigestInfo και το two-pass C_Sign

Μόλις είναι σωστό το layout, η signing δουλειά είναι μικρή, επειδή το contract ICmsSigner που ζητά το PDFiumPas από ένα backend έχει πέντε methods και οι τέσσερις απλώς επιστρέφουν OIDs και signer identifier. Μόνο το SignSignedAttrsDigest κάνει δουλειά: παίρνει το SHA-256 digest 32 bytes των 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 λεπτομέρειες θα σου κοστίσουν failed verification αν τις παραλείψεις. Το CKM_RSA_PKCS εφαρμόζει PKCS#1 v1.5 padding αλλά δεν κατασκευάζει το DigestInfo, άρα ο caller προσθέτει ο ίδιος το 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 κάνει conversion. Και το C_Sign είναι σκόπιμα two-pass: κάλεσέ το με nil buffer για να ζητήσεις από το token το 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);
  // Κατέγραψέ το πριν από οτιδήποτε άλλο όταν token misbehaves σε νέα 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 ανά module, και repeated call απαντά CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), το οποίο το PDFiumPas αντιμετωπίζει ως success με την υπόθεση ότι άλλο μέρος του host έχει ήδη αρχικοποιήσει την ίδια library. Token strings όπως slot description και token label είναι blank-padded fixed-width fields και όχι NUL-terminated, άρα πρέπει να γίνουν trim από το tail. Και το CKO_CERTIFICATE είναι 1 και όχι 2 — το 0 είναι CKO_DATA και το 2 είναι CKO_PUBLIC_KEY. Το να γράψεις αυτή την constant από μνήμη είναι λάθος που παράγει empty search result χωρίς κανένα error

Τι επαληθεύεται και πού σταματά η εγγύηση

Χρειάζεται σαφήνεια για το boundary επειδή είναι στενότερο από όσο υπονοεί η feature description. Αυτό που επαληθεύει σήμερα το PDFiumPas είναι ότι το ABI layout ταιριάζει field προς field με τα C headers και στα δύο branches, ότι absent ή unloadable module υποβαθμίζεται σε reported failure αντί για crash και ότι και οι Delphi και FPC toolchains χτίζουν τη unit. Τα πραγματικά token paths — C_Login, object search και C_Sign απέναντι σε hardware — δεν έχουν ασκηθεί, επειδή ο development host δεν έχει εγκατεστημένο καθόλου PKCS#11 module. Κάνε bring up πρώτα το 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 signature πάνω σε token-held key και ακόμη δεν μπορεί να ελέγξει το output του στο ίδιο machine. Σχεδίασε το verification step σε Windows ή σε external validator μέχρι να κλείσει αυτό το κενό

Το μάθημα γενικεύεται πέρα από το PKCS#11. Κάθε Pascal record που καθρεφτίζει conditionally packed C struct χρειάζεται τρία πράγματα: ένα conditional alias για το platform-variable scalar ώστε η απόφαση width να υπάρχει σε ακριβώς ένα σημείο, packing directives που περιβάλλουν τις declarations και επαναφέρονται μετά και runtime function που αναφέρει το resolved layout ως κάτι που μπορεί να κάνει assert ένα test. 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