PDFiumPas firma documenti PAdES tramite un token PKCS#11 su Windows, Linux e macOS, e due fatti di piattaforma decidono se il binding funzionerà: CK_ULONG è l'unsigned long C, quindi è largo 4 byte su Windows e 8 byte su Linux e macOS, mentre gli header PKCS#11 applicano #pragma pack(1) solo su Windows, spostando ogni puntatore nella function table. Sbagliate uno dei due e il modulo continua a caricarsi, le chiamate continuano a restituire valori e i numeri che tornano sono spazzatura. Questa è la forma del bug da aspettarsi. Non arriva un errore del linker, perché non si collega nulla: il modulo è un .so, .dylib o .dll aperto a runtime per path e l'intera superficie è una struct di function pointer che si fa cast e si chiama. Il compilatore non sa come appariva l'header C dall'altra parte. Ogni mismatch resta silenzioso fino al crash
Perché un binding PKCS#11 fallisce con codici CKR casuali invece di un errore chiaro?
Perché un mismatch ABI non produce affatto una condizione di errore: produce un indirizzo o un offset errato, e il token risponde diligentemente a qualunque domanda si trovi davanti. Non esiste alcun layer tra la vostra dichiarazione del record e il modulo che possa notare il disaccordo. Ne derivano due failure mode distinti. Se il packing è sbagliato, lo slot letto come C_GetSlotList contiene sei byte di un puntatore e due del successivo, e chiamarlo salta in memoria non mappata o, peggio, nel mezzo di un'altra funzione. Quello è l'access violation. Se CK_ULONG ha la dimensione sbagliata, gli indirizzi sono corretti ma i dati no: un out-parameter var Count: CK_ULONG dichiarato largo 4 byte riceve 8 byte da un modulo LP64 e sovrascrive silenziosamente i quattro byte successivi dello stack frame, mentre un template CK_ATTRIBUTE con ValueLen all'offset errato induce il modulo a leggere un campo length dal vostro puntatore Value. Il token restituisce allora un perfettamente legittimo CKR_BUFFER_TOO_SMALL o CKR_ATTRIBUTE_VALUE_INVALID per una domanda che non avete mai posto. Quei codici mandano le persone a cercare per ore nella configurazione del token. Il bug è quattro righe più in alto, in una dichiarazione di tipo
CK_ULONG è l'unsigned long C, non un tipo a dimensione fissa
CK_ULONG è definito dagli header PKCS#11 come unsigned long C, il che significa che la sua dimensione segue il data model della piattaforma invece della specifica. Windows è LLP64, quindi unsigned long resta a 32 bit anche in un processo a 64 bit. Linux e macOS sono LP64, quindi segue il puntatore e diventa a 64 bit. È la singola riga più importante dell'intera unità, perché in PKCS#11 praticamente ogni scalare è un CK_ULONG: slot ID, session handle, object handle, object class, key type, attribute type, mechanism type, buffer length e perfino il valore di ritorno CK_RV
type
{$IFDEF MSWINDOWS}
// Windows è LLP64: un unsigned long C resta a 32 bit
CK_ULONG = LongWord;
{$ELSE}
// Linux e macOS sono LP64: unsigned long segue la dimensione del puntatore
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;
Aliasare ciascuno di questi tipi a CK_ULONG invece che direttamente a LongWord o UInt64 è il punto dell'esercizio. Significa che la condizione esiste esattamente una volta. Scrivetene uno in forma concreta e avrete piantato una mina che una porta futura farà esplodere, nel punto preciso che avete dimenticato
Cosa fa pragma pack(1) alla function table PKCS#11?
Sposta ogni function pointer in CK_FUNCTION_LIST, perché la tabella inizia con un CK_VERSION da due byte. Con l'allineamento naturale il compilatore inserisce sei byte di padding dopo quella versione, quindi il primo function pointer finisce all'offset 8. Con il byte packing non c'è padding, quindi finisce all'offset 2. Ogni entry successiva eredita lo stesso scarto, motivo per cui un errore di packing non riguarda un solo campo ma l'intera tabella. Il tranello è che gli header PKCS#11 applicano #pragma pack(1) solo su Windows. È una differenza di piattaforma, non di modulo: due build della stessa libreria vendor possono non concordare su questo a seconda dell'host da cui provengono. Notate anche che il packing non cambia nulla per le strutture i cui campi sono tutti pointer-width, la maggior parte, quindi un test ingenuo che tocchi solo CK_SLOT_INFO passerà tranquillamente mentre la tabella sottostante è spostata di sei 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; // due byte, la ragione dello spostamento della tabella
C_Initialize: Pointer; // offset 2 packed, offset 8 allineato
C_Finalize: Pointer;
C_GetInfo: Pointer;
C_GetFunctionList: Pointer;
C_GetSlotList: Pointer;
// ... la tabella ha un ordine fisso; dichiarare il prefisso
// fino a C_Sign basta per raggiungere ciò che chiama questo backend
C_SignInit: Pointer;
C_Sign: Pointer;
end;
PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;
{$IFDEF FPC}{$PACKRECORDS DEFAULT}{$ELSE}{$A8}{$ENDIF}
Tre cose in quel blocco contano più di quanto sembri. {$PACKRECORDS C} non equivale a "nessuna direttiva": dice a Free Pascal di seguire le regole di allineamento del compilatore C della piattaforma, che è esattamente il contratto necessario su Linux e macOS. Il ramo Delphi è incondizionatamente {$A1} perché le build Delphi di PDFiumPas puntano a Windows, mentre FPC porta le build Linux e macOS. Inoltre la riga di restore in fondo non è cosmetica: lasciare l'unità packed fa cambiare silenziosamente il layout a ogni record dichiarato dopo questo punto, esattamente la classe di difetto action-at-a-distance che l'hardening di un binding PDFium VCL contro problemi ABI e di memory safety vuole eliminare
Pkcs11AbiLayout: trasformare il layout in un'asserzione
Pkcs11AbiLayout riporta il layout effettivamente risolto dalla build come una stringa verificabile nella forma ulong=4 attr=16 pss=12 table=2. Una build Windows a 64 bit deve riportare esattamente quello, mentre un target LP64 deve riportare ulong=8 attr=24 pss=24 table=8. Qualsiasi altro risultato significa che una chiamata attraverso la function table finirebbe nello slot sbagliato, e la funzione esiste perché un unit test possa dirlo ad alta voce invece di affidarsi a un commento
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;
// A runtime, dopo che C_GetFunctionList ha restituito la tabella:
// una versione implausibile o un entry point nil significa che il record è stato
// disposto con packing o dimensione CK_ULONG errati, quindi rifiutare il modulo
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;
I quattro numeri non sono arbitrari. attr è la dimensione di CK_ATTRIBUTE, che contiene un CK_ULONG, un puntatore e un CK_ULONG: 4 + 8 + 4 packed su Windows x64, 8 + 8 + 8 allineato su LP64. pss è CK_RSA_PKCS_PSS_PARAMS, tre campi CK_ULONG, quindi 12 o 24. table è l'offset del primo function pointer ed è il valore che intercetta per primo un errore di packing. Il test case Delphi asserisce la stringa sotto {$IFDEF MSWINDOWS}; la suite Lazarus asserisce lo stesso. Un controllo di uguaglianza copre un layout che altrimenti si potrebbe verificare solo leggendo un header C accanto a un record Pascal e fidandosi di se stessi. Il controllo a runtime è la seconda metà della stessa idea. PDFiumPas risolve per nome solo C_GetFunctionList tramite GetProcAddress o GetProcedureAddress e prende ogni altro entry point dalla tabella restituita da quella chiamata, come intende la specifica base OASIS PKCS #11, evitando i nomi dei simboli specifici dei vendor. Poi controlla ciò che è arrivato. Una versione major fuori dall'intervallo 2-3 o un C_Initialize, C_GetSlotList o C_Sign nil significa che il record è disallineato, e il modulo viene scartato invece di essere chiamato
Firmare attraverso la tabella: mechanism, DigestInfo e il C_Sign a due passaggi
Una volta corretto il layout, il lavoro di firma è piccolo, perché il contratto ICmsSigner che PDFiumPas chiede a un backend di soddisfare ha cinque metodi e quattro restituiscono soltanto OID e signer identifier. Solo SignSignedAttrsDigest fa qualcosa: riceve il digest SHA-256 da 32 byte degli attributi firmati e restituisce i byte della firma. Assemblaggio CMS, ASN.1, timestamp RFC 3161 e DSS/LTV sono indipendenti dalla piattaforma e già completati, la stessa divisione del lavoro che consente alle sessioni di firma PAdES remote contro un HSM o un servizio cloud di collegarsi allo stesso seam. Tre dettagli sui mechanism costeranno una verifica fallita se li saltate. CKM_RSA_PKCS applica il padding PKCS#1 v1.5 ma non costruisce il DigestInfo, quindi il caller antepone da solo il prefisso DigestInfo SHA-256 di 19 byte da RFC 8017; passate al token il digest nudo e otterrete una firma ben formata sulla cosa sbagliata. CKM_RSA_PKCS_PSS e CKM_ECDSA ricevono il digest così com'è, ma CKM_ECDSA risponde con la coppia raw r||s, mentre CMS richiede la SEQUENCE ECDSA-Sig-Value di RFC 3279 §2.2.3, quindi PDFiumPas converte. E C_Sign è a due passaggi per progetto: chiamatelo con un buffer nil per chiedere al token la lunghezza della firma, poi di nuovo con un buffer di quella dimensione
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);
// Registrare questo prima di ogni altra cosa quando un token si comporta male su una piattaforma nuova
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;
Prima del primo token vale la pena sapere ancora alcune cose minori. I moduli vengono messi in cache per path perché C_Initialize avviene una volta per processo e modulo, e una chiamata ripetuta restituisce CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), che PDFiumPas tratta come successo presumendo che un'altra parte dell'host abbia già inizializzato la stessa libreria. Le stringhe del token, come la descrizione dello slot e il token label, sono campi a larghezza fissa riempiti di spazi, non terminati da NUL, quindi vanno rifilati dalla coda. Inoltre CKO_CERTIFICATE è 1, non 2: 0 è CKO_DATA e 2 è CKO_PUBLIC_KEY. Scrivere quella costante a memoria è un errore che produce un risultato di ricerca vuoto senza alcun errore
Cosa è verificato e dove finisce la garanzia
È importante chiarire il confine, perché è più stretto di quanto suggerisca la descrizione della feature. Oggi PDFiumPas verifica che il layout ABI corrisponda campo per campo agli header C su entrambi i rami, che un modulo assente o non caricabile degradi a un errore riportato invece che a un crash e che entrambe le toolchain Delphi e FPC compilino l'unità. I percorsi reali del token, C_Login, object search e C_Sign contro l'hardware, non sono stati esercitati, perché sull'host di sviluppo non è installato alcun modulo PKCS#11. Avviate prima SoftHSM2 e confermate Pkcs11AbiLayout prima di collegare un token fisico, così un problema ABI e un problema del token non dovranno essere diagnosticati contemporaneamente. Va nominata un'altra asimmetria. Il lato firma ora è cross-platform, quello della verifica no. La verifica CMS dentro PDFiumPas è ancora protetta da {$IFDEF MSWINDOWS} e altrove restituisce pcsUnsupported, senza un punto d'iniezione provider equivalente a quello del backend di firma. Quindi un servizio Linux può produrre una firma PAdES B-B con una chiave custodita dal token ma non può ancora controllare il proprio output sulla stessa macchina. Pianificate il passaggio di verifica su Windows o su un validator esterno finché il divario non verrà colmato
La lezione va oltre PKCS#11. Ogni record Pascal che rispecchia una struct C con packing condizionale richiede tre cose: un alias condizionale per lo scalare la cui piattaforma varia, in modo che la decisione sulla dimensione esista in un solo punto, direttive di packing che racchiudano le dichiarazioni e vengano ripristinate dopo, e una funzione runtime che riporti il layout risolto come qualcosa che un test possa asserire. I commenti che affermano che una struct corrisponde all'header non valgono nulla; SizeOf e un offset di campo stampato all'avvio valgono molto. Il backend PKCS#11, il backend CNG e il resto dello stack di firma sono inclusi nel PDFium Component per Delphi e C++Builder, dove la parte ABI è già condizionata e il vostro codice può restare dal lato token del problema