Tehnički članak

Potpisivanje PDF-a iz skladišta sertifikata uz HotPDF: redosled bajtova CNG naspram CAPI-ja

HotPDF potpisuje PDF u odnosu na sertifikat koji se već nalazi u Windows skladištu sertifikata tako što prosleđuje sažetak samom Windowsu, a Windows taj zahtev završava kroz jednu od dve pozadine za privatni ključ: CNG, koji vraća RSA potpis u big-endian redosledu, ili nasleđeni CryptoAPI CSP, koji ga vraća u little-endian redosledu. Ako ih pomešate, CMS potpis koji HotPDF ugrađuje biće obrnut po bajtovima u odnosu na pozadinu koja je stvarno odgovorila, pa usklađeni validator prijavljuje potpis kao nevažeći iako bajtovi dokumenta nikada nisu menjani

Dva nepovezana problema kriju se iza te jedne rečenice, a HotPDF-ov potpisivač sistemskih sertifikata mora da reši oba pre nego što uopšte počne sa potpisivanjem. Neusklađenost redosleda bajtova je neprimetna: poziv za potpisivanje i dalje vraća True, PDF se i dalje otvara, a greška se pojavljuje tek kada pregledač obradi CMS strukturu i odbije je. Drugi problem je glasan i specifičan za C++Builder: nekoliko funkcija iz crypt32 biblioteke odbija povezivanje, jer import biblioteka koju isporučuje RAD Studio ne izvozi te funkcije. Nijedan od tih problema ne postoji ako potpisujete samo PFX datotekom, zbog čega najčešće dočekaju programere koji prelaze sa jednokratnog potpisivanja zasnovanog na PFX-u na sertifikat koji je IT odeljenje već instaliralo u profil korisnika

Izbor sertifikata iz skladišta

HotPDF ovaj put izlaže preko funkcija HPDFSignPDFStreamWithSystemCertificate i HPDFSignPDFFileWithSystemCertificate, kojima upravlja zapis THPDFCertificateStoreSelector: Location (cslCurrentUser ili cslLocalMachine), StoreName ('MY', podrazumevano lično skladište), SHA-1 Thumbprint i zastavica AllowUI. Otisak je interno normalizovan, pa se crtice ili razmaci kopirani direktno iz korisničkog interfejsa Certificate Managera uklanjaju pre poređenja

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;

AllowUI = False važniji je nego što na prvi pogled izgleda, jer se direktno preslikava u CRYPT_ACQUIRE_SILENT_FLAG, a Windows to doslovno poštuje: ako se privatni ključ pronađenog sertifikata nalazi na pametnoj kartici ili tokenu kojem je potreban PIN koji Windows još nije keširao, CryptAcquireCertificatePrivateKey ne uspeva umesto da prikaže dijalog iz procesa koji bi možda radio kao servis. Ta greška je jasna, dobijate EHPDFCMSError odmah, ali je lako pogrešno protumačiti kao „sertifikat nije pronađen“ kada je pravi uzrok token koji čeka na PIN koji niko neće uneti

Zašto se CNG i CAPI ne slažu oko redosleda bajtova?

Koja pozadina odgovara nije stvar nagađanja: CryptAcquireCertificatePrivateKey to direktno prijavljuje kroz izlazni parametar KeySpec, a upravo ta jedna vrednost određuje granu HotPDF-ovog potpisivača. Ključ CNG provajdera za čuvanje ključeva vraća se sa vrednošću KeySpec postavljenom na sentinel CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); sve ostalo predstavlja tradicionalni ključ CryptoAPI CSP-a. Većina ličnih sertifikata izdatih ili uvezenih na savremenoj Windows instalaciji rešava se kao CNG, iako zbog kompatibilnosti i dalje postoji nasleđeni CSP sloj, zbog čega HotPDF zahteva CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG zajedno sa CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG pre nego što proveri koja je vrednost vraćena

Dve pozadine ne pozivaju samo različite funkcije, NCryptSignHash za CNG ključ i CryptSignHashA za CSP ključ, već vraćaju sirovi RSA potpis u suprotnom redosledu bajtova. Izlaz CNG-a već odgovara onome što očekuje PKCS#1: oktetni niz u big-endian redosledu, sa najznačajnijim bajtom na početku, tačno onako kako RFC 8017 definiše I2OSP konverziju i kako CMS SignerInfo (RFC 5652) zahteva u polju potpisa prema ISO 32000-1 §12.8.3. Nasuprot tome, CryptSignHash funkcije CryptoAPI-ja vraćaju potpis u little-endian redosledu, što je dokumentovana posebnost nasleđena iz načina na koji su klasični CSP-ovi interno predstavljali velike brojeve. Ako preskočite obrtanje na CAPI putanji, svaki bajt potpisa nalazi se na pogrešnom mestu; RSA matematika je i dalje ispravna, ali oktetni niz koji validator čita nije onaj koji PKCS#1 definiše

// 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;

Šta je sa povratnim pozivom prilagođenog potpisivača?

Svako ko zaobiđe HotPDF-ov ugrađeni potpisivač za sertifikate iz skladišta preuzima isto pravilo o redosledu bajtova. HPDFCMSSignPDFStreamWithExternalSigner prima THPDFCMSSignDigestCallback, zatvaranje tipa reference to function(const SignedAttributesSHA256: TBytes): TBytes, za potpisivanje kroz HSM, međusloj pametne kartice ili bilo šta drugo što nije sertifikat za koji Windows skladište može da vam preda handle ključa. Bez obzira na pozadinu iza tog povratnog poziva, bajtovi koje vraća moraju biti u big-endian redosledu pre nego što ih HotPDF uklopi u CMS strukturu

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);

Vredi jasno naglasiti jednu granicu: dve ugrađene HotPDF putanje za potpisivanje, CNG preko NCryptSignHash sa PKCS#1 dopunom i CAPI preko CryptSignHashA, ciljaju RSA ključeve koji potpisuju 32-bajtni SHA-256 sažetak. Nijedna ne pregovara o formatu ECDSA potpisa. Sertifikatu čiji je privatni ključ zasnovan na eliptičkoj krivi potrebna je vaša implementacija potpisivača preko HPDFCMSSignPDFStreamWithExternalSigner, koja kodira ECDSA potpis onako kako CMS očekuje umesto da pretpostavi RSA niz fiksne dužine, zato ne očekujte da ugrađeni potpisivač sertifikata iz skladišta ispravno radi sa tokenom na kojem je obezbeđen EC sertifikat

Zašto C++Builder ne uspeva da poveže CertOpenStore?

Zato što podrazumevana import biblioteka C++Buildera iz RAD Studija, import32.lib, ne izvozi CertOpenStore niti pet njegovih susednih funkcija: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore i CryptAcquireCertificatePrivateKey. Delphi verzije to ne primete, jer dcc32/dcc64 rešavaju statički external 'crypt32.dll' uvoz direktno u PE tabelu uvoza. C++Builder je drugačiji: Delphi kompajler emituje OMF .obj za izgradnju paketa, ilink32 ga povezuje, a tada je ista deklaracija external samo nerešen simbol koji čeka import biblioteku u komandnoj liniji. Usmeravanje povezivača na direktorijum psdk Windows SDK-a, gde potpuna crypt32.lib zaista izvozi svih šest simbola, takođe ne rešava problem: ilink32 povezuje samo import biblioteke eksplicitno navedene u komandnoj liniji, podrazumevano import32.lib cp32mt.lib, a dodavanje putanje za pretragu ne znači da će iz te putanje automatski uključiti još nešto. Pokretanje tdump nad import32.lib direktno potvrđuje nedostatak, sa nula pogodaka za CertOpenStore, nasuprot šest čistih pogodaka u SDK biblioteci crypt32.lib

HotPDF ovo rešava na isti način na koji drugde u biblioteci već obrađuje nabrajanje sertifikata: umesto da od povezivača zahteva te simbole, učitava ih u vreme izvršavanja. Interni zapis THPDFCryptoProcs nosi handle za crypt32.dll, handle za advapi32.dll i jedanaest polja pokazivača na funkcije; LoadCryptoProcs učitava obe DLL biblioteke i tačno jednom razrešava svaku ulaznu tačku pomoću GetProcAddress, na početku funkcije HPDFSignPDFStreamWithSystemCertificate, i odmah podiže EHPDFCMSError ako nešto nedostaje umesto da kasnije dođe do pristupa nevažećoj memoriji duboko u toku potpisivanja

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;

Učitavanje se obavlja jednom po pozivu, a ne lenjo unutar svakog pomoćnog koda, jer anonimna funkcija koja bira između CNG-a i CAPI-ja po vrednosti preuzima tabelu učitanih funkcija i mora da ostane živa tokom celog toka potpisivanja, uključujući povratni poziv ka HPDFCMSSignPDFStreamWithExternalSigner; oba DLL handle-a oslobađaju se u spoljašnjem bloku finally kada se potpisivanje završi ili podigne izuzetak. Ništa od ovoga ne menja javnu površinu: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate i THPDFCertificateStoreSelector zadržavaju potpuno iste potpise kao ranije, pa je preuzimanje ispravke za postojeće pozivaoce samo ponovna izgradnja, a ne promena koda

Šta ovo ne obuhvata

Ispravan redosled bajtova i uspešno povezivanje u C++Builderu daju CMS SignerInfo koji validator može da raščlani i potpis koji može aritmetički da proveri; to ne govori da li validator treba da veruje sertifikatu iza njega, jer su izgradnja lanca, provera opoziva i politika vremenskog žiga odvojene brige koje se nadovezuju kroz CMS opcije, a ispravnost redosleda bajtova ih ne obezbeđuje sama od sebe. Dva detalja održavanja važna su koliko i kriptografija: PCCERT_CONTEXT vraćen pretragom sertifikata mora da se oslobodi pomoću CertFreeCertificateContext pre zatvaranja skladišta, a pribavljeni handle CNG ili CSP ključa, kada API prijavi da je u vlasništvu pozivaoca, mora da se oslobodi odgovarajućim pozivom njegove pozadine, nikada pozivom druge pozadine. Ako se rezultat svValid koji dobijete posle svega ovoga pokaže užim nego što ste očekivali, članak o proveri PDF potpisa precizno navodi šta ta zastavica obećava, a šta ne. Pošto sertifikat sve vreme ostaje pod upravljanjem Windowsa, potpisivanje iz skladišta zaobilazi čitavu površinu napada: nema PKCS#12 datoteke koju treba raščlaniti ni ASN.1 strukture kroz koju morate sami prolaziti, što je problem kojim se bavi HotPDF-ovo ojačavanje bezbednosti PKCS#12 i ASN.1 za putanju potpisivanja iz PFX datoteke

Potpisivanje iz skladišta sertifikata, PFX potpisivanje i povratni pozivi spoljnog potpisivača tri su ulaza u isti CMS/PKCS#7 tok unutar HotPDF PDF komponente za Delphi i C++Builder, a izbor uglavnom zavisi od toga ko sme da drži privatni ključ: vaš proces, PFX datoteka ili sam Windows