PDFiumPas signiert PAdES-Dokumente über ein PKCS#11-Token unter Windows, Linux und macOS, und zwei Plattformfakten entscheiden, ob die Bindung überhaupt funktioniert: CK_ULONG ist das C-unsigned long, also 4 Bytes unter Windows und 8 Bytes unter Linux und macOS, und die PKCS#11-Header verwenden #pragma pack(1) nur unter Windows, wodurch sich jeder Zeiger in der Funktionstabelle verschiebt. Macht man eines davon falsch, lädt das Modul weiterhin, die Aufrufe liefern weiterhin Rückgaben und die Zahlen sind Müll. Das ist die Form des Fehlers, mit der man rechnen sollte. Niemand gibt einen Linkerfehler aus, denn es wird nichts gelinkt: Das Modul ist eine .so, .dylib oder .dll, die zur Laufzeit über einen Pfad geöffnet wird, und die gesamte Oberfläche ist ein Struct aus Funktionszeigern, das man castet und aufruft. Der Compiler weiß nicht, wie der C-Header auf der anderen Seite aussah. Jede Abweichung bleibt still, bis sie zum Absturz wird
Warum scheitert eine PKCS#11-Bindung mit zufälligen CKR-Codes statt mit einem klaren Fehler?
Weil eine ABI-Abweichung überhaupt keinen Fehlerzustand erzeugt, sondern eine falsche Adresse oder einen falschen Offset. Das Token beantwortet pflichtgemäß die Frage, die sich daraus ergibt. Zwischen Recorddeklaration und Modul gibt es keine Schicht, die die Abweichung bemerken könnte. Daraus entstehen zwei unterschiedliche Fehlerbilder. Ist das Packing falsch, enthält der Slot, den Sie als C_GetSlotList lesen, sechs Bytes des einen Zeigers und zwei des nächsten. Der Aufruf springt dann in nicht gemappten Speicher oder, schlimmer, mitten in eine andere Funktion. Das ist die Access Violation. Ist CK_ULONG in der falschen Breite deklariert, stimmen die Adressen, aber die Daten nicht: Ein als 4 Bytes deklarierter var Count: CK_ULONG-Out-Parameter erhält von einem LP64-Modul 8 geschriebene Bytes und überschreibt still die nächsten vier Bytes Ihres Stackframes. Ein CK_ATTRIBUTE-Template, dessen ValueLen am falschen Offset liegt, lässt das Modul ein Längenfeld aus Ihrem Value-Zeiger lesen. Das Token liefert dann völlig legitime CKR_BUFFER_TOO_SMALL- oder CKR_ATTRIBUTE_VALUE_INVALID-Codes auf eine Frage, die Sie nie gestellt haben. Diese Codes schicken Menschen stundenlang auf die Suche in der Tokenkonfiguration. Der Fehler liegt vier Zeilen weiter oben in einer Typdeklaration
CK_ULONG ist C unsigned long und kein Fixed-Width-Typ
CK_ULONG ist in den PKCS#11-Headern als C-unsigned long definiert, daher folgt seine Breite dem Datenmodell der Plattform und nicht der Spezifikation. Windows ist LLP64, also bleibt unsigned long auch in einem 64-Bit-Prozess 32 Bit breit. Linux und macOS sind LP64, daher folgt der Typ der Zeigerbreite und wird 64 Bit groß. Das ist die folgenreichste einzelne Zeile der gesamten Unit, denn in PKCS#11 sind praktisch alle Skalare CK_ULONG: Slot-IDs, Session-Handles, Objekt-Handles, Objektklassen, Schlüsseltypen, Attributtypen, Mechanismustypen, Pufferlängen und der Rückgabewert CK_RV selbst
type
{$IFDEF MSWINDOWS}
// Windows ist LLP64: Ein C unsigned long bleibt dort 32 Bit breit
CK_ULONG = LongWord;
{$ELSE}
// Linux und macOS sind LP64: unsigned long folgt der Zeigerbreite
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;
Alle diese Typen auf CK_ULONG und nicht direkt auf LongWord oder UInt64 abzubilden, ist der Sinn der Übung. Die Bedingung erscheint dadurch exakt einmal. Schreibt man einen davon konkret aus, hat man eine Landmine gelegt, auf die ein zukünftiger Port genau an der vergessenen Stelle tritt
Was macht pragma pack(1) mit der PKCS#11-Funktionstabelle?
Es verschiebt jeden Funktionszeiger in CK_FUNCTION_LIST, weil die Tabelle mit einer zwei Bytes großen CK_VERSION beginnt. Unter natürlicher Ausrichtung fügt der Compiler nach dieser Version sechs Paddingbytes ein, daher liegt der erste Funktionszeiger bei Offset 8. Unter Byte-Packing gibt es kein Padding, daher liegt er bei Offset 2. Jeder folgende Eintrag übernimmt dieselbe Verschiebung, deshalb ist ein Packingfehler kein Einzelfeldproblem, sondern ein Problem der ganzen Tabelle. Die PKCS#11-Header wenden #pragma pack(1) aber nur unter Windows an. Es ist ein Plattformunterschied und kein Modulunterschied: Zwei Builds derselben Herstellerbibliothek unterscheiden sich je nach Host. Beachten Sie außerdem, dass Packing bei Strukturen mit ausschließlich zeigerbreiten Feldern nichts verändert – das sind die meisten –, sodass ein naiver Test, der nur CK_SLOT_INFO berührt, problemlos besteht, während die Tabelle darunter um sechs Bytes verschoben ist
{$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; // zwei Bytes und der Grund, warum sich die Tabelle verschiebt
C_Initialize: Pointer; // Offset 2 gepackt, Offset 8 ausgerichtet
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... die Tabelle hat eine feste Reihenfolge; die Deklaration bis
// C_Sign reicht für alles, was dieses Backend aufruft
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Drei Dinge in diesem Block sind wichtiger, als sie aussehen. {$PACKRECORDS C} bedeutet nicht "keine Direktive"; es weist Free Pascal an, die Ausrichtungsregeln des Plattform-C-Compilers zu befolgen, und genau das ist der benötigte Vertrag unter Linux und macOS. Der Delphi-Zweig verwendet bedingungslos {$A1}, weil Delphi-Builds von PDFiumPas auf Windows zielen, während FPC die Linux- und macOS-Builds trägt. Die Wiederherstellungszeile am Ende ist ebenfalls nicht kosmetisch: Lässt man die Unit gepackt, ändert sich das Layout jedes danach deklarierten Records still, genau die Art von Fernwirkung, die die Absicherung einer PDFium-Komponentenbindung gegen ABI- und Memory-Safety-Fehler beseitigen soll
Pkcs11AbiLayout: das Layout in eine Assertion verwandeln
Pkcs11AbiLayout meldet das tatsächlich aufgelöste Layout des Builds als prüfbaren String der Form ulong=4 attr=16 pss=12 table=2. Ein 64-Bit-Windows-Build muss exakt das melden; ein LP64-Ziel muss ulong=8 attr=24 pss=24 table=8 liefern. Alles andere bedeutet, dass ein Aufruf über die Funktionstabelle auf dem falschen Slot landen würde. Die Funktion existiert, damit ein Unit-Test das laut sagen kann, statt dass ein Kommentar es behauptet
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;
// Zur Ladezeit, nachdem C_GetFunctionList die Tabelle zurückgegeben hat:
// Eine unplausible Version oder ein Nil-Einstiegspunkt bedeutet, dass der Record
// mit falschem Packing oder falscher CK_ULONG-Breite ausgelegt wurde; Modul ablehnen
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;
Die vier Zahlen sind nicht willkürlich. attr ist die Größe von CK_ATTRIBUTE, das einen CK_ULONG, einen Zeiger und einen CK_ULONG enthält: gepackt unter Windows x64 4 + 8 + 4, ausgerichtet unter LP64 8 + 8 + 8. pss ist CK_RSA_PKCS_PSS_PARAMS mit drei CK_ULONG-Feldern, also 12 oder 24. table ist der Offset des ersten Funktionszeigers und der Wert, der einen Packingfehler zuerst entdeckt. Der Delphi-Testfall prüft den String unter {$IFDEF MSWINDOWS}; die Lazarus-Suite prüft dasselbe. Eine Gleichheit deckt ein Layout ab, das sonst nur durch das Nebeneinanderlegen eines C-Headers und eines Pascal-Records sowie Vertrauen in die eigene Sorgfalt verifizierbar wäre. Die Prüfung zur Ladezeit ist die zweite Hälfte derselben Idee. PDFiumPas löst über GetProcAddress oder GetProcedureAddress nur C_GetFunctionList per Namen auf und nimmt jeden weiteren Einstiegspunkt aus der von diesem Aufruf gelieferten Tabelle. So soll ein Modul laut OASIS-PKCS-#11-Basisspezifikation erreicht werden, und herstellerspezifische Symbolnamen werden vermieden. Anschließend wird das Ergebnis plausibilisiert. Eine Major-Version außerhalb 2 bis 3 oder ein Nil von C_Initialize, C_GetSlotList oder C_Sign bedeutet eine falsch ausgerichtete Struktur; das Modul wird verworfen statt darüber aufgerufen
Über die Tabelle signieren: Mechanismen, DigestInfo und der zweistufige C_Sign
Sobald das Layout stimmt, ist der Signiervorgang klein, denn der von PDFiumPas geforderte ICmsSigner-Vertrag hat fünf Methoden, von denen vier nur OIDs und die Signer-ID zurückgeben. Nur SignSignedAttrsDigest arbeitet wirklich: Sie nimmt den 32 Byte langen SHA-256-Digest der signierten Attribute und liefert Signaturbytes zurück. CMS-Aufbau, ASN.1, RFC-3161-Zeitstempel und DSS/LTV sind plattformunabhängig bereits erledigt. Dieselbe Arbeitsteilung ermöglicht entfernte PAdES-Signatursitzungen gegen einen HSM- oder Cloud-Schlüsseldienst an derselben Schnittstelle. Drei Mechanismusdetails kosten eine fehlgeschlagene Verifizierung, wenn man sie überspringt. CKM_RSA_PKCS wendet PKCS#1-v1.5-Padding an, erzeugt aber keine DigestInfo, daher fügt der Aufrufer selbst das 19 Byte lange SHA-256-DigestInfo-Präfix aus RFC 8017 voran. Gibt man dem Token den reinen Digest, erhält man eine wohlgeformte Signatur über das Falsche. CKM_RSA_PKCS_PSS und CKM_ECDSA nehmen den Digest wie vorgelegt, aber CKM_ECDSA antwortet mit dem rohen r||s-Paar und CMS benötigt die ECDSA-Sig-Value-SEQUENCE aus RFC 3279 §2.2.3, daher konvertiert PDFiumPas. Und C_Sign ist absichtlich zweistufig: Zuerst mit Nil-Puffer nach der Signaturlänge fragen, dann noch einmal mit einem Puffer dieser Größe aufrufen
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);
// Dies protokollieren, bevor sonst etwas geschieht, wenn ein Token auf einer neuen Plattform fehlschlägt
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;
Ein paar kleinere Dinge sollte man vor dem ersten Token kennen. Module werden nach Pfad gecacht, weil C_Initialize pro Prozess und Modul einmalig ist; ein wiederholter Aufruf liefert CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), das PDFiumPas unter der Annahme als Erfolg behandelt, dass ein anderer Teil des Hosts dieselbe Bibliothek bereits initialisiert hat. Tokenstrings wie Slotbeschreibung und Tokenlabel sind mit Leerzeichen aufgefüllte Felder fester Breite und nicht NUL-terminiert, daher müssen sie am Ende getrimmt werden. Außerdem ist CKO_CERTIFICATE 1 und nicht 2 – 0 ist CKO_DATA, 2 ist CKO_PUBLIC_KEY. Diese Konstante aus dem Gedächtnis zu schreiben, liefert ein leeres Suchergebnis und keinen Fehler
Was wird geprüft und wo endet die Garantie?
Die Grenze sollte klar sein, denn sie ist enger als die Featurebeschreibung klingt. PDFiumPas prüft heute, dass das ABI-Layout auf beiden Zweigen Feld für Feld zu den C-Headern passt, dass ein fehlendes oder nicht ladbares Modul zu einem gemeldeten Fehler statt zu einem Absturz führt und dass sowohl Delphi- als auch FPC-Toolchain die Unit bauen. Die echten Tokenpfade – C_Login, Objektsuche und C_Sign gegen Hardware – wurden nicht ausgeführt, weil auf dem Entwicklungsrechner überhaupt kein PKCS#11-Modul installiert ist. Bringen Sie zuerst SoftHSM2 hoch und bestätigen Sie Pkcs11AbiLayout, bevor Sie ein physisches Token anschließen, damit ABI- und Tokenproblem nie gleichzeitig diagnostiziert werden müssen. Eine weitere Asymmetrie sollte man benennen. Die Signierseite ist jetzt plattformübergreifend, die Verifizierungsseite nicht. Die CMS-Verifizierung in PDFiumPas ist weiterhin an {$IFDEF MSWINDOWS} gebunden und liefert anderswo pcsUnsupported zurück; einen Provider-Injection-Punkt analog zum Signer-Backend gibt es nicht. Ein Linux-Dienst kann daher eine PAdES-B-B-Signatur mit einem tokengehaltenen Schlüssel erzeugen und die eigene Ausgabe auf derselben Maschine noch nicht prüfen. Planen Sie die Verifizierung auf Windows oder in einen externen Validator ein, bis diese Lücke geschlossen ist
Die Lehre gilt über PKCS#11 hinaus. Jeder Pascal-Record, der eine bedingt gepackte C-Struktur spiegelt, benötigt drei Dinge: einen bedingten Alias für den plattformabhängigen Skalar, damit die Breitenentscheidung genau einmal existiert, Packing-Direktiven, die die Deklarationen umschließen und danach wiederhergestellt werden, sowie eine Laufzeitfunktion, die das aufgelöste Layout als prüfbaren Wert meldet. Kommentare, die behaupten, ein Struct stimme mit seinem Header überein, sind wertlos; SizeOf und ein beim Start ausgegebener Feldoffset sind viel wert. Das PKCS#11-Backend, das CNG-Backend und der übrige Signierstack gehören zur PDFium Component for Delphi and C++Builder, deren ABI-Verkabelung bereits bedingt eingerichtet ist, damit Ihr Code auf der Tokenseite des Problems bleiben kann