Tehnični članak

PKCS#11 v Delphiju: past CK_ULONG in pakiranja struct

PDFiumPas dokumente PAdES podpisuje prek žetona PKCS#11 v sistemih Windows, Linux in macOS, pri čemer o tem, ali vezava sploh deluje, odločata dve dejstvi platforme: CK_ULONG je C-jev unsigned long, torej 4 bajti v sistemu Windows in 8 bajtov v Linuxu ter macOS, glave PKCS#11 pa #pragma pack(1) uporabijo samo v sistemu Windows, kar premakne vsak kazalec v tabeli funkcij. Zgrešite katerega koli in modul se še vedno naloži, klici se še vedno vrnejo, številke, ki pridejo nazaj, pa so smeti. To je oblika napake, ki jo morate pričakovati. Nihče vam ne izroči napake povezovalnika, ker ni nič povezano: modul je .so, .dylib ali .dll, ki ga med izvajanjem odprete po poti, celotna površina pa je zapis funkcijskih kazalcev, ki jih pretvorite in pokličete. Prevajalnik nima pojma, kako je videti C-jeva glava na drugi strani. Vsaka neusklajenost molči vse do sesutja

Zakaj vezava PKCS#11 odpove z naključnimi kodami CKR namesto s čisto napako?

Ker neusklajenost ABI-ja sploh ne ustvari pogoja napake, temveč napačen naslov ali napačen odmik, žeton pa vestno odgovori na kakršno koli vprašanje, ki se je izkazalo iz tega. Med deklaracijo vašega zapisa in modulom ni plasti, ki bi lahko opazila nesoglasje. Iz tega prideta dva različna načina odpovedi. Če je pakiranje napačno, mesto, ki ga berete kot C_GetSlotList, vsebuje šest bajtov enega kazalca in dva naslednjega, klic pa skoči v nepreslikani pomnilnik ali, še huje, v sredino neke druge funkcije. To je kršitev dostopa. Če je širina CK_ULONG napačna, so naslovi pravilni, podatki pa ne: izhodni parameter var Count: CK_ULONG, deklariran kot širok 4 bajte, dobi od modula LP64 tihih 8 bajtov, s čimer prepiše naslednje štiri bajte vašega sklada, predloga CK_ATTRIBUTE, katere ValueLen sedi na napačnem odmiku, pa modul prisili, da polje dolžine prebere iz vašega kazalca Value. Žeton nato vrne povsem legitimni CKR_BUFFER_TOO_SMALL ali CKR_ATTRIBUTE_VALUE_INVALID za vprašanje, ki ga nikoli niste postavili. Te kode ljudi za več ur pošljejo iskati po konfiguraciji žetona. Napaka je štiri vrstice višje v deklaraciji tipa

CK_ULONG je C-jev unsigned long, ne tip fiksne širine

Glave PKCS#11 CK_ULONG definirajo kot C-jev unsigned long, kar pomeni, da njegova širina sledi podatkovnemu modelu platforme, ne specifikaciji. Windows je LLP64, zato unsigned long ostane 32-biten tudi v 64-bitnem procesu. Linux in macOS sta LP64, zato sledi širini kazalca in postane 64-biten. To je najpomembnejša vrstica v celotni enoti, ker je v PKCS#11 skoraj vsak skalar CK_ULONG: ID-ji mest, ročaji sej, ročaji objektov, razredi objektov, tipi ključev, tipi atributov, tipi mehanizmov, dolžine medpomnilnikov in celo sama vrnjena vrednost CK_RV

type
{$IFDEF MSWINDOWS}
  // Windows je LLP64: C-jev unsigned long tam ostane 32-biten
  CK_ULONG = LongWord;
{$ELSE}
  // Linux in macOS sta LP64: unsigned long sledi širini kazalca
  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;

Poanta je, da je vsak od teh vzdevek tipa CK_ULONG, ne neposredno LongWord ali UInt64. Tako se pogoj pojavi natanko enkrat. Napišite katerega koli konkretno in zapisali ste minsko polje, na katerega bo stopil prihodnji prenos, in stopil bo prav na mestu, ki ste ga pozabili

Kaj pragma pack(1) naredi tabeli funkcij PKCS#11?

Premakne vsak funkcijski kazalec v CK_FUNCTION_LIST, ker se tabela začne z dvobajtnim CK_VERSION. Pri naravni poravnavi prevajalnik za različico vstavi šest bajtov polnjenja, zato prvi funkcijski kazalec pristane na odmiku 8. Pri pakiranju po bajtih polnjenja ni, zato pristane na odmiku 2. Vsak naslednji vnos podeduje isti premik, zato napaka pakiranja ni problem enega polja, temveč problem celotne tabele. Past je v tem, da glave PKCS#11 #pragma pack(1) uporabijo samo v sistemu Windows. To je razlika platforme, ne modula: dve gradnji iste knjižnice ponudnika se glede tega ne strinjata, odvisno od gostitelja, iz katerega izvirata. Upoštevajte tudi, da pakiranje ne spremeni ničesar pri strukturah, katerih vsa polja so široka kot kazalec, kar velja za večino, zato bo naivni test, ki se dotakne samo CK_SLOT_INFO, veselo uspešen, medtem ko bo tabela pod njim premaknjena za šest bajtov

{$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;      // dva bajta in razlog, da se tabela premakne
    C_Initialize: Pointer;    // odmik 2 pri pakiranju, odmik 8 pri poravnavi
    C_Finalize: Pointer;
    C_GetInfo: Pointer;
    C_GetFunctionList: Pointer;
    C_GetSlotList: Pointer;
    // ... tabela je v fiksnem vrstnem redu; deklaracija predpone
    // do C_Sign zadošča za vse klice tega zaledja
    C_SignInit: Pointer;
    C_Sign: Pointer;
  end;
  PCK_FUNCTION_LIST = ^CK_FUNCTION_LIST;

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

Tri stvari v tem bloku so pomembnejše, kot so videti. {$PACKRECORDS C} ni isto kot »brez direktive«; Free Pascalu pove, naj sledi pravilom poravnave platformnega prevajalnika C, kar je natanko pogodba, ki jo potrebujete na Linuxu in macOS. Veja Delphi je brezpogojni {$A1}, ker so gradnje PDFiumPas za Delphi namenjene sistemu Windows, FPC pa nosi gradnje za Linux in macOS. Vrstica za obnovo na dnu ni kozmetika: če enoto pustite zapakirano, se layout vsakega zapisa, deklariranega za tem, tiho spremeni, kar je natanko vrsta napake z učinkom na daljavo, ki jo želi odpraviti utrjevanje vezave komponente PDFium proti napakam ABI-ja in varnosti pomnilnika

Pkcs11AbiLayout: pretvorba postavitve v trditev

Pkcs11AbiLayout postavitev, ki jo je gradnja dejansko razrešila, sporoči kot en niz, ki ga je mogoče preveriti, v obliki ulong=4 attr=16 pss=12 table=2. 64-bitna gradnja Windows mora prijaviti natanko to, cilj LP64 pa mora prijaviti ulong=8 attr=24 pss=24 table=8. Karkoli drugega pomeni, da bi klic prek tabele funkcij pristal na napačnem mestu, funkcija pa obstaja zato, da test to pove na glas, namesto da komentar trdi nasprotno

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;

// Ob nalaganju, ko C_GetFunctionList vrne tabelo:
// neverjetna različica ali ničelna vstopna točka pomeni, da je bil zapis
// položen z napačnim pakiranjem ali širino CK_ULONG, zato modul zavrni
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;

Štiri številke niso poljubne. attr je velikost CK_ATTRIBUTE, ki vsebuje CK_ULONG, kazalec in CK_ULONG: 4 + 8 + 4 pri pakiranju v Windows x64 ter 8 + 8 + 8 pri poravnavi LP64. pss je CK_RSA_PKCS_PSS_PARAMS, tri polja CK_ULONG, torej 12 ali 24. table je odmik prvega funkcijskega kazalca in vrednost, ki najprej ujame napako pakiranja. Test Delphi niz preveri pod {$IFDEF MSWINDOWS}, zbirka Lazarus pa preveri isto. En pregled enakosti pokrije postavitev, ki bi jo sicer lahko preverili samo tako, da bi C-jevo glavo brali ob Pascalovem zapisu in zaupali sebi. Preverjanje ob nalaganju je druga polovica iste ideje. PDFiumPas po imenu prek GetProcAddress ali GetProcedureAddress razreši samo C_GetFunctionList, vsako drugo vstopno točko pa vzame iz tabele, ki jo ta klic vrne, kar je način, ki ga predvideva osnovna specifikacija OASIS PKCS #11, in se izogne imenovanju simbolov po ponudnikih. Nato preveri, kaj je dobil. Glavna različica zunaj 2 do 3 ali ničelni C_Initialize, C_GetSlotList ali C_Sign pomeni, da je zapis napačno poravnan, zato modul opusti, namesto da bi ga klical

Podpisovanje skozi tabelo: mehanizmi, DigestInfo in dvoprehodni C_Sign

Ko je postavitev pravilna, je delo podpisovanja majhno, saj ima pogodba ICmsSigner, ki jo PDFiumPas zahteva od zaledja, pet metod, štiri pa samo vrnejo OID-je in identifikator podpisnika. Samo SignSignedAttrsDigest kaj naredi: sprejme 32-bajtni zgoščeni izvleček SHA-256 podpisanih atributov in vrne bajte podpisa. Sestavljanje CMS, ASN.1, časovno žigosanje RFC 3161 in DSS/LTV so neodvisni od platforme in že izvedeni, ista delitev dela pa omogoča, da se oddaljene seje podpisovanja PAdES proti HSM ali storitvi ključev v oblaku priklopijo na isti stik. Če preskočite tri podrobnosti mehanizmov, vas bodo stale neuspešnega preverjanja. CKM_RSA_PKCS uporabi polnjenje PKCS#1 v1.5, vendar ne zgradi DigestInfo, zato klicatelj sam doda 19-bajtno predpono SHA-256 DigestInfo iz RFC 8017; podajte žetonu goli izvleček in dobili boste veljaven podpis napačne stvari. CKM_RSA_PKCS_PSS in CKM_ECDSA sprejmeta izvleček, kot je predstavljen, vendar CKM_ECDSA vrne surovi par r||s, CMS pa potrebuje SEQUENCE ECDSA-Sig-Value iz RFC 3279 §2.2.3, zato ga PDFiumPas pretvori. Poleg tega je C_Sign po zasnovi dvoprehoden: pokličite ga z ničelnim medpomnilnikom, da od žetona zahtevate dolžino podpisa, nato še enkrat z medpomnilnikom te velikosti

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);
  // To zabeleži, preden narediš karkoli drugega, ko se žeton na novi platformi vede napačno
  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;

Pred prvim žetonom je vredno poznati nekaj manjših stvari. Moduli se predpomnijo po poti, ker je C_Initialize enkrat na proces in modul, ponovljeni klic pa vrne CKR_CRYPTOKI_ALREADY_INITIALIZED (0x00000190), kar PDFiumPas obravnava kot uspeh ob predpostavki, da je drug del gostitelja isto knjižnico že inicializiral. Nizi žetona, kot sta opis mesta in oznaka žetona, so fiksna polja, dopolnjena s presledki, ne nizi, zaključeni z NUL, zato jih je treba obrezati z repa. In CKO_CERTIFICATE je 1, ne 2 — 0 je CKO_DATA, 2 pa CKO_PUBLIC_KEY. Zapis te konstante po spominu je napaka, ki ustvari prazen rezultat iskanja brez kakršne koli napake

Kaj je preverjeno in kje se zagotovilo konča

Jasno določite mejo, ker je ožja, kot nakazuje opis funkcije. PDFiumPas danes preveri, da se layout ABI-ja ujema s C-jevimi glavami po poljih na obeh vejah, da se odsotni ali nenaložljivi modul spremeni v prijavljeno napako namesto sesutja in da enoto zgradita tako veriga Delphi kot FPC. Pravih poti žetona — C_Login, iskanje objektov in C_Sign proti strojni opremi — nismo izvedli, ker na razvojnem gostitelju sploh ni nameščenega modula PKCS#11. Najprej zaženite SoftHSM2 in potrdite Pkcs11AbiLayout, preden priklopite fizični žeton, da težave ABI-ja in žetona nikoli ne bo treba diagnosticirati hkrati. Poimenovati je treba še eno asimetrijo. Stran podpisovanja je zdaj večplatformna, stran preverjanja pa ne. Preverjanje CMS znotraj PDFiumPas je še vedno varovano s {$IFDEF MSWINDOWS} in drugje vrne pcsUnsupported, nima pa vstopne točke za vstavljanje ponudnika, enakovredne zaledju podpisnika. Storitev Linux lahko zato ustvari podpis PAdES B-B nad ključem, ki ga drži žeton, še ne more pa na istem računalniku preveriti lastnega izhoda. Dokler se vrzel ne zapre, korak preverjanja načrtujte na Windows ali v zunanjem validatorju

Nauk presega PKCS#11. Vsak Pascalov zapis, ki zrcali pogojno zapakiran C-jev struct, potrebuje tri stvari: en pogojni vzdevek za skalar, odvisen od platforme, tako da odločitev o širini obstaja na natanko enem mestu, direktive pakiranja, ki deklaracije obdajajo in se nato obnovijo, ter funkcijo izvajanja, ki razrešeni layout sporoči kot nekaj, kar lahko test preveri. Komentarji, ki trdijo, da se struct ujema z glavo, niso vredni nič; SizeOf in odmik polja, izpisan ob zagonu, sta vredna veliko. Zaledje PKCS#11, zaledje CNG in preostali sklad podpisovanja so del komponente PDFium za Delphi in C++Builder, v kateri je ABI-napeljava že pogojena, tako da lahko vaša koda ostane na strani težave z žetonom