Műszaki cikk

HotPDF tanúsítványtár PDF-aláírás: CNG vs CAPI bájtsorrend

A HotPDF úgy írja alá a PDF-et egy, a Windows tanúsítványtárban már meglévő tanúsítvánnyal, hogy a lenyomatot (digest) átadja magának a Windowsnak, a Windows pedig ezt a kérést a két privátkulcs-háttérrendszer egyikén keresztül teljesíti: a CNG-n, amely az RSA-aláírást big-endian formátumban adja vissza, vagy a régebbi CryptoAPI CSP-n, amely little-endian formátumban adja vissza. Ha a kettőt összekeverjük, a HotPDF által beágyazott CMS-aláírás bájtsorrendje megfordul aszerint, hogy melyik háttérrendszer válaszolt ténylegesen, így egy szabványkövető ellenőrző az aláírást érvénytelennek jelenti, pedig a dokumentum bájtjaihoz senki nem nyúlt

Ez az egyetlen mondat mögött két, egymással össze nem függő probléma bújik meg, és a HotPDF rendszertanúsítvány-alapú aláírójának mindkettőt meg kell oldania, mielőtt bármit aláírna. A bájtsorrend-eltérés csendes: az aláíró hívás továbbra is True értéket ad vissza, a PDF továbbra is megnyílik, és a hiba csak akkor jelentkezik, amikor egy megjelenítő végigjárja a CMS-struktúrát, és elutasítja azt. A második probléma hangos, és kifejezetten C++Builderhez kötődik: fél tucat crypt32-függvény nem hajlandó linkelődni, mert a RAD Studio által mellékelt importkönyvtár nem exportálja azokat. Egyik probléma sem jelentkezik, ha valaki mindig csak PFX-fájllal ír alá, ezért tipikusan azokat a fejlesztőket éri váratlanul, akik a PFX-alapú, egyetlen hívásos aláírásról térnek át egy olyan tanúsítványra, amelyet az informatikai osztály már telepített a felhasználó profiljába

Tanúsítvány kiválasztása a tárból

A HotPDF ezt az utat a HPDFSignPDFStreamWithSystemCertificate és HPDFSignPDFFileWithSystemCertificate függvényeken keresztül teszi elérhetővé, mindkettőt egy THPDFCertificateStoreSelector rekord vezérli: Location (cslCurrentUser vagy cslLocalMachine), StoreName (alapértelmezetten 'MY', a személyes tár), egy SHA-1 Thumbprint, valamint egy AllowUI jelző. A lenyomat (thumbprint) belsőleg normalizálódik, így a Certificate Manager felületéről közvetlenül kimásolt kötőjelek vagy szóközök az összehasonlítás előtt eltávolításra kerülnek

var
  Selector: THPDFCertificateStoreSelector;
  Options: THPDFCMSSignOptions;
begin
  Selector := THPDFCertificateStoreSelector.Default;  // cslCurrentUser, store 'MY'
  Selector.Thumbprint := 'A1B2C3D4E5F6A7B8C9D0E1F2A3B4C5D6E7F8A9B0';
  Selector.AllowUI := False;

  Options := HPDFCMSDefaultOptions(palBaseline_B_B);
  if not HPDFSignPDFFileWithSystemCertificate('invoice.pdf',
    'invoice-signed.pdf', Selector, Options) then
    raise Exception.Create('Certificate-store signing failed');
end;

Az AllowUI = False beállítás fontosabb, mint amilyennek elsőre tűnik, mert közvetlenül a CRYPT_ACQUIRE_SILENT_FLAG-re képződik le, a Windows pedig ezt szó szerint betartja: ha az egyező tanúsítvány privátkulcsa egy olyan intelligens kártyán vagy tokenen ül, amely PIN-bekérést igényel, és a Windows még nem gyorsítótárazta azt, a CryptAcquireCertificatePrivateKey elbukik ahelyett, hogy párbeszédablakot dobna fel egy esetleg szolgáltatásfolyamatból. Ez a hiba hangos, azonnal látható EHPDFCMSError formájában, de könnyű "tanúsítvány nem található" hibaként félreértelmezni, miközben a valódi ok egy token, amely egy soha be nem gépelt PIN-re vár

Miért nem ért egyet a CNG és a CAPI a bájtsorrendben?

Az, hogy melyik háttérrendszer válaszol, nem találgatás kérdése: a CryptAcquireCertificatePrivateKey ezt közvetlenül egy KeySpec kimeneti paraméterrel jelzi, és a HotPDF aláírója pontosan erre az egyetlen értékre ágazik el. Egy CNG kulcstároló szolgáltató (Key Storage Provider) kulcsa a CERT_NCRYPT_KEY_SPEC ($FFFFFFFF) őrértékkel tér vissza a KeySpec-ben; minden más érték hagyományos CryptoAPI CSP-kulcsot jelent. A legtöbb, mai Windows-telepítésen kiállított vagy importált személyes tanúsítvány CNG-re oldódik fel, még akkor is, ha a kompatibilitás kedvéért a régi CSP-áthidalás is létezik, ezért kéri a HotPDF a CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG-ot a CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG-gal együtt, mielőtt megnézné, melyik érték jött vissza

A két háttérrendszer nemcsak eltérő függvényeket hív meg, NCryptSignHash-t egy CNG-kulcs esetén, CryptSignHashA-t egy CSP-kulcs esetén, hanem az RSA nyers aláírását is ellentétes bájtsorrendben adja vissza. A CNG kimenete már megfelel annak, amit a PKCS#1 vár: egy big-endian oktett-sorozatot, a legjelentősebb bájttal elöl, pontosan azt, amit az RFC 8017 I2OSP átalakítása előállít, és amire egy CMS SignerInfónak (RFC 5652) szüksége van az aláírás mezőjében az ISO 32000-1 §12.8.3 alapján. Ezzel szemben a CryptoAPI CryptSignHash függvénye little-endian formátumban adja vissza az aláírást, ez egy dokumentált furcsaság, amely a klasszikus CSP-k nagy számainak belső ábrázolási módjára vezethető vissza. Ha a CAPI-útvonalon kihagyjuk a megfordítást, az aláírás minden bájtja rossz helyre kerül; az RSA-matematika még helyes marad, de az oktett-sorozat, amit egy ellenőrző beolvas, nem az, amit a PKCS#1 meghatároz

// CryptSignHashA returns the RSA signature least-significant byte first;
// CMS/PKCS#7 (ISO 32000-1 Section 12.8.3) needs it most-significant byte first.
for I := 0 to (Length(Signature) div 2) - 1 do
begin
  Temp := Signature[I];
  Signature[I] := Signature[High(Signature) - I];
  Signature[High(Signature) - I] := Temp;
end;

Mi a helyzet az egyéni aláíró visszahívással (callback)?

Aki megkerüli a HotPDF beépített tanúsítványtár-aláíróját, ugyanazt a bájtsorrend-szabályt örökli. A HPDFCMSSignPDFStreamWithExternalSigner egy THPDFCMSSignDigestCallback-et vár, ez egy reference to function(const SignedAttributesSHA256: TBytes): TBytes típusú lezárás (closure), amely HSM-en, intelligenskártya-middleware rétegen vagy bármi máson keresztül végzi az aláírást, ami nem olyan tanúsítvány, amelyhez a Windows tár kulcs-handle-t tudna adni. Bármilyen háttérrendszer is áll a callback mögött, az általa visszaadott bájtoknak big-endian sorrendbe kell kerülniük, mielőtt a HotPDF beilleszti azokat a CMS-struktúrába

Signer :=
  function(const SignedAttributesSHA256: TBytes): TBytes
  begin
    if UsesCngKeyStorageProvider then
      Result := SignWithMyCngKey(SignedAttributesSHA256)       // already big-endian
    else
      Result := ReverseBytes(SignWithMyLegacyToken(SignedAttributesSHA256));
  end;
HPDFCMSSignPDFStreamWithExternalSigner(InputStream, OutputStream,
  CertificateDER, Signer, Options);

Érdemes itt egy határvonalat kifejezetten kimondani: a HotPDF két beépített aláírási útvonala, a CNG a NCryptSignHash-en keresztül PKCS#1 kitöltéssel, és a CAPI a CryptSignHashA-n keresztül, egyaránt RSA-kulcsokat céloz meg, amelyek egy 32 bájtos SHA-256-lenyomatot írnak alá. Egyik sem tárgyal ECDSA-aláírásformátumot. Egy olyan tanúsítványhoz, amelynek privátkulcsa EC-alapú, saját magadnak kell aláírót írnod a HPDFCMSSignPDFStreamWithExternalSigner ellen, az ECDSA-aláírást úgy kódolva, ahogyan a CMS elvárja, nem pedig fix hosszúságú RSA-bájtsorozatot feltételezve, tehát ne várd, hogy a beépített tanúsítványtár-aláíró helyesen kezelje az EC-tanúsítvánnyal ellátott tokent

Miért nem tudja a C++Builder linkelni a CertOpenStore-t?

Azért, mert a RAD Studio alapértelmezett C++Builder importkönyvtára, az import32.lib, nem exportálja a CertOpenStore-t, sem öt szomszédját: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore, és CryptAcquireCertificatePrivateKey. A Delphi-fordítások ezzel soha nem szembesülnek, mert a dcc32/dcc64 egy statikus external 'crypt32.dll' importot közvetlenül a PE importtáblába old fel. A C++Builder más: a Delphi fordító egy OMF .obj fájlt állít elő a csomag buildjéhez, ezt az ilink32 linkeli, és ekkor ugyanaz az external deklaráció csupán egy feloldatlan szimbólum, amely egy parancssorban megadott importkönyvtárra vár. Az sem oldja meg a problémát, ha a linkert a Windows SDK psdk könyvtárára irányítjuk, ahol a teljes crypt32.lib mind a hat szimbólumot exportálja: az ilink32 csak azokat az importkönyvtárakat linkeli, amelyek ténylegesen szerepelnek a parancssorán, alapértelmezetten import32.lib cp32mt.lib, és egy keresési útvonal hozzáadása nem eredményezi, hogy bármi extrát behúzzon onnan. A tdump futtatása az import32.lib ellen közvetlenül megerősíti a hiányt, nulla találat a CertOpenStore-ra, szemben az SDK crypt32.lib-jének hat tiszta találatával

A HotPDF ugyanúgy oldja meg ezt, ahogyan a könyvtár más pontjain is kezeli már a tanúsítvány-felsorolást: ahelyett, hogy a linkertől kérné ezeket a szimbólumokat, futásidőben tölti be őket. Egy belső THPDFCryptoProcs rekord hordoz egy crypt32.dll handle-t, egy advapi32.dll handle-t, és tizenegy függvénymutató mezőt; a LoadCryptoProcs mindkét DLL-t betölti, és minden belépési pontot pontosan egyszer old fel GetProcAddress-szel, a HPDFSignPDFStreamWithSystemCertificate elején, azonnal EHPDFCMSError-t dobva, ha bármi hiányzik, ahelyett hogy később, mélyen az aláírási folyamat belsejében, egy hozzáférési hibával (access violation) bukna el

type
  TCertOpenStoreFn = function(lpszStoreProvider: Pointer; dwEncodingType: DWORD;
    hCryptProv: NativeUInt; dwFlags: DWORD; pvPara: Pointer): HCERTSTORE; stdcall;
var
  Crypt32Handle: HMODULE;
  CertOpenStore: TCertOpenStoreFn;
begin
  Crypt32Handle := LoadLibrary('crypt32.dll');
  if Crypt32Handle = 0 then
    raise Exception.Create('crypt32.dll could not be loaded');
  @CertOpenStore := GetProcAddress(Crypt32Handle, 'CertOpenStore');
  // ... use CertOpenStore, then FreeLibrary(Crypt32Handle) when signing returns
end;

A betöltés hívásonként egyszer történik, nem pedig lustán, minden egyes segédfüggvényen belül külön-külön, mert a CNG és a CAPI között választó lezárás (closure) érték szerint befogja a betöltött függvénytáblát, és a teljes aláírási folyamat alatt életben kell maradnia, beleértve a HPDFCMSSignPDFStreamWithExternalSigner-be történő visszahívást is; mindkét DLL-handle a legkülső finally blokkban szabadul fel, amint az aláírás befejeződik vagy kivételt dob. Mindez nem érinti a nyilvános felületet: a HPDFSignPDFStreamWithSystemCertificate, a HPDFSignPDFFileWithSystemCertificate és a THPDFCertificateStoreSelector pontosan azokat a szignatúrákat tartja meg, amelyek korábban is voltak, így a javítás átvétele a meglévő hívók számára egyszerű újrafordítás, nem kódmódosítás

Mit nem fed le ez a megoldás?

A bájtsorrend és a C++Builder-linkelés helyes kezelése egy olyan CMS SignerInfót eredményez, amelyet egy ellenőrző fel tud dolgozni, és amelynek aláírását aritmetikailag ellenőrizni tudja; ez semmit nem mond arról, hogy az ellenőrzőnek meg kellene-e bíznia a mögötte álló tanúsítványban, mivel a láncépítés, a visszavonás-ellenőrzés és az időbélyeg-szabályzat külön, a CMS-beállításokon keresztül rárétegzett kérdések, amelyeket a bájtsorrend helyessége önmagában nem old meg. Két adminisztratív részlet ugyanolyan fontos, mint maga a kriptográfia: a tanúsítványkeresés által visszaadott PCCERT_CONTEXT-et a tár bezárása előtt fel kell szabadítani a CertFreeCertificateContext-tel, és egy beszerzett CNG- vagy CSP-kulcshandle-t, ha az API szerint azt a hívó birtokolja, kizárólag a megfelelő háttérrendszer saját hívásán keresztül szabad felszabadítani, soha nem a másikén. Ha a mindezek után visszakapott svValid eredmény szűkebbnek bizonyul, mint amire számítottál, a PDF-aláírások ellenőrzéséről szóló cikk pontosan leírja, mit ígér ez a jelző, és mit nem. Mivel a tanúsítvány itt mindvégig a Windows felügyelete alatt marad, a tanúsítványtár-alapú aláírás megkerül egy egész támadási felületet: nincs olyan PKCS#12 fájl, amit elemezni kellene, és nincs olyan ASN.1, amit magadnak kellene bejárnod, ez az a probléma, amelyet a HotPDF PKCS#12- és ASN.1-megerősítése a PFX-fájl alapú aláírási útvonalon old meg helyette

A tanúsítványtár-alapú aláírás, a PFX-aláírás és a külső aláíró visszahívások (external signer callback) három kapu ugyanabba a CMS/PKCS#7 folyamatba a Delphi és C++Builder HotPDF PDF komponensen belül, és a helyes kiválasztás nagyrészt azon múlik, ki jogosult tárolni a privátkulcsot: a saját folyamatod, egy PFX-fájl, vagy maga a Windows