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