Artykuł techniczny

PKCS#11 w Delphi: pułapka CK_ULONG i pakowania

PDFiumPas podpisuje dokumenty PAdES przez token PKCS#11 w Windows, Linuxie i macOS, a o tym, czy to wiązanie w ogóle zadziała, decydują dwa fakty platformowe: CK_ULONG jest C unsigned long, więc ma 4 bajty w Windows i 8 bajtów w Linuxie oraz macOS, a nagłówki PKCS#11 stosują #pragma pack(1) tylko w Windows, przesuwając każdy wskaźnik w tabeli funkcji. Pomyłka w którymkolwiek z nich nie przeszkadza modułowi się załadować, wywołania nadal wracają, a zwracane liczby są śmieciami. Taki kształt ma ten błąd. Nikt nie poda ci błędu linkera, bo nic nie jest linkowane: moduł jest plikiem .so, .dylib albo .dll, otwieranym w czasie działania po ścieżce, a cała powierzchnia jest strukturą wskaźników do funkcji, które rzutujesz i wywołujesz. Kompilator nie wie, jak wyglądał nagłówek C po drugiej stronie. Każda niezgodność milczy aż do chwili awarii

Dlaczego wiązanie PKCS#11 zwraca losowe kody CKR zamiast czystego błędu?

Ponieważ niezgodność ABI w ogóle nie produkuje warunku błędu, tylko zły adres albo zły offset, a token posłusznie odpowiada na pytanie, które z tego wyniknie. Nie ma warstwy między deklaracją rekordu a modułem, która mogłaby zauważyć rozbieżność. Powstają dwa różne tryby awarii. Gdy pakowanie jest złe, slot odczytywany jako C_GetSlotList zawiera sześć bajtów jednego wskaźnika i dwa następnego, a wywołanie skacze do niezamapowanej pamięci albo, co gorsza, do środka innej funkcji. To naruszenie dostępu. Gdy szerokość CK_ULONG jest zła, adresy są poprawne, ale dane nie: parametr wyjściowy var Count: CK_ULONG zadeklarowany jako 4-bajtowy dostaje od modułu LP64 osiem zapisanych bajtów, po cichu nadpisując kolejne cztery bajty ramki stosu, a template CK_ATTRIBUTE, którego ValueLen leży pod złym offsetem, sprawia, że moduł odczytuje pole długości ze wskaźnika Value. Token zwraca wtedy całkowicie prawidłowy CKR_BUFFER_TOO_SMALL albo CKR_ATTRIBUTE_VALUE_INVALID dla pytania, którego nigdy nie zadałeś. Takie kody wysyłają ludzi na wielogodzinne poszukiwania w konfiguracji tokena. Błąd leży cztery linie wyżej, w deklaracji typu

CK_ULONG to C unsigned long, a nie typ o stałej szerokości

CK_ULONG jest definiowany przez nagłówki PKCS#11 jako C unsigned long, co oznacza, że jego szerokość wynika z modelu danych platformy, a nie ze specyfikacji. Windows używa LLP64, więc unsigned long pozostaje 32-bitowy nawet w procesie 64-bitowym. Linux i macOS używają LP64, więc podąża za wskaźnikiem i staje się 64-bitowy. To najważniejsza pojedyncza linia w całym unicie, ponieważ w PKCS#11 praktycznie każdy skalar jest CK_ULONG: identyfikatory slotów, uchwyty sesji, uchwyty obiektów, klasy obiektów, typy kluczy, typy atrybutów, typy mechanizmów, długości buforów oraz sama wartość zwrotna CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows używa LLP64: C unsigned long pozostaje tam 32-bitowy
  CK_ULONG = LongWord;
{$ELSE}
  // Linux i macOS używają LP64: unsigned long podąża za szerokością wskaźnika
  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 każdego z tych typów do CK_ULONG zamiast bezpośrednio do LongWord albo UInt64 jest sednem ćwiczenia. Dzięki temu warunek występuje dokładnie raz. Wypisz którykolwiek konkretnie, a zbudujesz minę, na którą nadepnie przyszły port, dokładnie w tym jednym miejscu, o którym zapomniałeś

Co pragma pack(1) robi z tabelą funkcji PKCS#11?

Przesuwa każdy wskaźnik funkcji w CK_FUNCTION_LIST, ponieważ tabela zaczyna się od dwubajtowego CK_VERSION. Przy naturalnym wyrównaniu kompilator wstawia sześć bajtów paddingu po tej wersji, więc pierwszy wskaźnik funkcji ląduje pod offsetem 8. Przy pakowaniu bajtowym nie ma paddingu i ląduje pod offsetem 2. Każdy kolejny wpis dziedziczy to samo przesunięcie, dlatego błąd pakowania nie dotyczy jednego pola, lecz całej tabeli. Pułapka polega na tym, że nagłówki PKCS#11 stosują #pragma pack(1) tylko w Windows. To różnica platformy, a nie modułu: dwa buildy tej samej biblioteki dostawcy różnią się w zależności od hosta, z którego pochodzą. Zauważ też, że pakowanie niczego nie zmienia w strukturach, których pola mają wyłącznie szerokość wskaźnika, a to większość z nich, więc naiwny test dotykający tylko CK_SLOT_INFO przejdzie z uśmiechem, podczas gdy leżąca pod spodem tabela jest przesunięta o sześć bajtów

{$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;      // dwa bajty i powód przesunięcia tabeli
    C_Initialize: Pointer;    // offset 2 przy pakowaniu, offset 8 przy wyrównaniu
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... tabela ma stałą kolejność; deklaracja prefiksu
    // aż do C_Sign wystarcza, aby dotrzeć do wszystkiego, czego używa ten backend
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Trzy rzeczy w tym bloku są ważniejsze, niż wyglądają. {$PACKRECORDS C} nie znaczy „brak dyrektywy”; mówi Free Pascalowi, aby podążał za regułami wyrównania platformowego kompilatora C, czyli dokładnie za kontraktem potrzebnym w Linuxie i macOS. Gałąź Delphi to bezwarunkowe {$A1}, ponieważ buildy PDFiumPas dla Delphi celują w Windows, podczas gdy FPC obsługuje buildy Linuxa i macOS. Linia przywracająca na dole również nie jest ozdobnikiem: pozostawienie unitu w stanie spakowanym po cichu zmieni layout każdego rekordu zadeklarowanego później, dokładnie w taki sposób, jaki ma wyeliminować wzmacnianie wiązania komponentu PDFium przeciw błędom ABI i bezpieczeństwa pamięci

Pkcs11AbiLayout: zamiana layoutu w asercję

Pkcs11AbiLayout raportuje layout faktycznie rozwiązany przez build jako jeden możliwy do asercji łańcuch w postaci ulong=4 attr=16 pss=12 table=2. Build 64-bitowego Windows musi zgłosić dokładnie to, a cel LP64 musi zgłosić ulong=8 attr=24 pss=24 table=8. Wszystko inne oznacza, że wywołanie przez tabelę funkcji trafiłoby do niewłaściwego slotu, a ta funkcja istnieje po to, aby test jednostkowy powiedział to głośno, zamiast aby komentarz tylko twierdził, że tak jest

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;

// W czasie ładowania, po przekazaniu tabeli przez C_GetFunctionList:
// nieprawdopodobna wersja albo pusty punkt wejścia oznacza, że rekord ułożono
// z niewłaściwym pakowaniem albo szerokością CK_ULONG, więc odrzuć moduł
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;

Cztery liczby nie są przypadkowe. attr to rozmiar CK_ATTRIBUTE, który zawiera CK_ULONG, wskaźnik i CK_ULONG: 4 + 8 + 4 przy pakowaniu w Windows x64 oraz 8 + 8 + 8 przy wyrównaniu LP64. pss to CK_RSA_PKCS_PSS_PARAMS, trzy pola CK_ULONG, więc 12 albo 24. table to offset pierwszego wskaźnika funkcji i to właśnie ta wartość jako pierwsza łapie błąd pakowania. Przypadek testowy Delphi asercjuje łańcuch pod {$IFDEF MSWINDOWS}; zestaw Lazarusa asercjuje to samo. Jedno sprawdzenie równości obejmuje layout, który inaczej można byłoby zweryfikować wyłącznie przez czytanie nagłówka C obok rekordu Pascala i zaufanie samemu sobie. Sprawdzenie podczas ładowania jest drugą połową tej samej idei. PDFiumPas rozwiązuje po nazwie tylko C_GetFunctionList przez GetProcAddress albo GetProcedureAddress, a każdy pozostały punkt wejścia bierze z tabeli zwróconej przez to wywołanie, dokładnie tak, jak przewiduje bazowa specyfikacja OASIS PKCS #11, omijając nazewnictwo symboli zależne od dostawcy. Następnie sprawdza, co wróciło. Wersja główna poza zakresem od 2 do 3 albo pusty C_Initialize, C_GetSlotList lub C_Sign oznacza źle wyrównany rekord, więc moduł zostaje odrzucony zamiast wywołany przez

Podpisywanie przez tabelę: mechanizmy, DigestInfo i dwuprzebiegowe C_Sign

Gdy layout jest poprawny, praca związana z podpisem jest niewielka, ponieważ kontrakt ICmsSigner, który PDFiumPas każe spełnić backendowi, ma pięć metod, a cztery z nich tylko zwracają OID-y i identyfikator podpisującego. Tylko SignSignedAttrsDigest coś robi: przyjmuje 32-bajtowy digest SHA-256 podpisanych atrybutów i zwraca bajty podpisu. Składanie CMS, ASN.1, znakowanie czasem RFC 3161 oraz DSS/LTV są niezależne od platformy i już wykonane, co jest tym samym podziałem obowiązków, który pozwala zdalnym sesjom podpisu PAdES względem HSM albo usługi klucza w chmurze podłączyć się do identycznego miejsca. Jeśli pominiesz trzy szczegóły mechanizmów, zapłacisz nieudaną weryfikacją. CKM_RSA_PKCS stosuje padding PKCS#1 v1.5, ale nie konstruuje DigestInfo, więc wywołujący sam dołącza 19-bajtowy prefiks DigestInfo SHA-256 z RFC 8017; przekaż tokenowi goły digest, a dostaniesz poprawny strukturalnie podpis nad niewłaściwą rzeczą. CKM_RSA_PKCS_PSS i CKM_ECDSA przyjmują digest w podanej postaci, lecz CKM_ECDSA odpowiada surową parą r||s, a CMS potrzebuje sekwencji ECDSA-Sig-Value z RFC 3279 §2.2.3, więc PDFiumPas wykonuje konwersję. A C_Sign celowo ma dwa przebiegi: wywołaj go z pustym buforem, aby zapytać token o długość podpisu, a potem ponownie z buforem o tym rozmiarze

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);
  // Zapisz to przed wszystkim innym, gdy token źle działa na nowej platformie
  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;

Przed pierwszym tokenem warto znać kilka drobiazgów. Moduły są cache’owane po ścieżce, ponieważ C_Initialize wykonuje się raz na proces i moduł, a powtórne wywołanie zwraca CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), co PDFiumPas traktuje jako sukces przy założeniu, że inna część hosta zainicjalizowała już tę samą bibliotekę. Łańcuchy tokena, takie jak opis slotu i etykieta tokena, są stałej szerokości i wypełnione spacjami, a nie zakończone NUL-em, więc trzeba je przycinać od końca. I CKO_CERTIFICATE ma wartość 1, nie 2 — 0 to CKO_DATA, a 2 to CKO_PUBLIC_KEY. Wpisanie tej stałej z pamięci jest błędem, który daje pusty wynik wyszukiwania i absolutnie żadnego błędu

Co jest zweryfikowane i gdzie kończy się gwarancja

Jasno określ granicę, bo jest węższa, niż sugeruje opis funkcji. W PDFiumPas zweryfikowano dotąd, że layout ABI zgadza się z nagłówkami C pole po polu w obu gałęziach, że nieobecny albo niemożliwy do załadowania moduł kończy się zgłoszoną awarią zamiast crashem oraz że oba toolchainy, Delphi i FPC, budują unit. Prawdziwe ścieżki tokena — C_Login, wyszukiwanie obiektów, C_Sign względem sprzętu — nie były ćwiczone, ponieważ na hoście deweloperskim w ogóle nie ma zainstalowanego modułu PKCS#11. Najpierw uruchom SoftHSM2 i potwierdź Pkcs11AbiLayout, zanim podłączysz fizyczny token, aby problem ABI i problem tokena nigdy nie musiały być diagnozowane jednocześnie. Warto nazwać jeszcze jedną asymetrię. Strona podpisywania jest teraz wieloplatformowa, strona weryfikacji nie. Weryfikacja CMS wewnątrz PDFiumPas nadal jest chroniona przez {$IFDEF MSWINDOWS} i gdzie indziej zwraca pcsUnsupported, a także nie ma punktu wstrzykiwania providera odpowiadającego backendowi podpisującemu. Usługa Linux może więc utworzyć podpis PAdES B-B nad kluczem przechowywanym w tokenie, ale nie może jeszcze sprawdzić własnego wyjścia na tej samej maszynie. Do czasu zamknięcia luki zaplanuj weryfikację w Windows albo w zewnętrznym walidatorze

Wniosek wykracza poza PKCS#11. Każdy rekord Pascala odwzorowujący warunkowo pakowaną strukturę C potrzebuje trzech rzeczy: jednego warunkowego aliasu dla skalaru zależnego od platformy, aby decyzja o szerokości istniała dokładnie w jednym miejscu, dyrektyw pakowania otaczających deklaracje i przywracanych później oraz funkcji runtime raportującej rozwiązany layout w formie, którą test może asercjować. Komentarze twierdzące, że struktura pasuje do nagłówka, są bezwartościowe; SizeOf i offset pola wypisany przy starcie są bardzo wartościowe. Backend PKCS#11, backend CNG i reszta stosu podpisywania są częścią komponentu PDFium dla Delphi i C++Builder, gdzie hydraulika ABI jest już uwarunkowana, aby twój kod mógł pozostać po stronie tokena problemu