Tehnički članak

HotPDF potpisivanje PDF-a iz spremišta certifikata: redoslijed bajtova CNG naspram CAPI

HotPDF potpisuje PDF pomoću certifikata koji se već nalazi u Windows spremištu certifikata tako da sažetak (digest) preda samom sustavu Windows, a Windows taj zahtjev dovršava kroz jedan od dva pozadinska mehanizma za privatni ključ: CNG, koji RSA potpis vraća u big-endian poretku, ili naslijeđeni CryptoAPI CSP, koji ga vraća u little-endian poretku. Zamijenite li ta dva, CMS potpis koji HotPDF ugrađuje ostaje s obrnutim redoslijedom bajtova za onaj mehanizam koji je stvarno odgovorio, pa usklađeni validator prijavljuje potpis kao nevažeći iako bajtovi dokumenta nikad nisu dirani

Iza te jedne rečenice kriju se dva nepovezana problema, a HotPDF-ov potpisnik koji koristi sustavne certifikate mora riješiti oba prije nego što uopće bilo što potpiše. Neusklađenost redoslijeda bajtova je tiha: poziv za potpisivanje i dalje vraća True, PDF se i dalje otvara, a kvar se pokazuje tek kad preglednik prođe kroz CMS strukturu i odbaci je. Drugi je problem glasan i specifičan za C++Builder: pola tuceta funkcija iz crypt32 odbija se povezati, jer ih uvozna biblioteka koju isporučuje RAD Studio ne izvozi. Nijedan od ova dva problema ne postoji ako uvijek potpisujete isključivo PFX datotekom, zbog čega obično iznenadi programere koji prelaze s potpisivanja jednim pozivom temeljenog na PFX-u na certifikat koji je IT odjel već instalirao u korisnički profil

Odabir certifikata iz spremišta

HotPDF ovaj put izlaže kroz HPDFSignPDFStreamWithSystemCertificate i HPDFSignPDFFileWithSystemCertificate, a oboje pokreće zapis THPDFCertificateStoreSelector: Location (cslCurrentUser ili cslLocalMachine), StoreName (prema zadanim postavkama 'MY', osobno spremište), SHA-1 Thumbprint i zastavica AllowUI. Otisak (thumbprint) se interno normalizira, pa se crtice ili razmaci kopirani izravno iz sučelja upravitelja certifikatima uklanjaju prije nego što se izvrši usporedba

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 izgleda, jer se izravno preslikava na CRYPT_ACQUIRE_SILENT_FLAG, a Windows to shvaća doslovno: ako se privatni ključ podudarajućeg certifikata nalazi na pametnoj kartici ili tokenu koji zahtijeva unos PIN-a koji Windows još nije predmemorirao, CryptAcquireCertificatePrivateKey neuspijeva umjesto da otvori dijaloški okvir iz onoga što bi mogao biti uslužni proces. Taj je neuspjeh glasan — EHPDFCMSError koji vidite odmah — ali lako ga je krivo protumačiti kao „certifikat nije pronađen" kada je stvarni uzrok token koji čeka unos PIN-a koji nitko neće upisati

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

Koji mehanizam odgovara nije stvar nagađanja: CryptAcquireCertificatePrivateKey to izravno prijavljuje kroz izlazni parametar KeySpec, i upravo je ta jedna vrijednost ono na temelju čega se HotPDF-ov potpisnik grana. Ključ CNG pružatelja pohrane ključeva (Key Storage Provider) vraća se s vrijednošću KeySpec postavljenom na graničnu vrijednost CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); sve ostalo je tradicionalni CryptoAPI CSP ključ. Većina osobnih certifikata izdanih ili uvezenih na aktualnoj instalaciji Windowsa razrješava se u CNG, iako naslijeđeni CSP sloj kompatibilnosti (shim) i dalje postoji, zbog čega HotPDF zahtijeva CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG zajedno s CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG prije nego što uopće pogleda koja je vrijednost vraćena

Ta dva mehanizma ne samo da pozivaju različite funkcije — NCryptSignHash za CNG ključ, CryptSignHashA za CSP ključ — nego i vraćaju sirovi RSA potpis u suprotnom redoslijedu bajtova. CNG-ov izlaz već odgovara onome što PKCS#1 očekuje: big-endian niz oktreta, s najznačajnijim bajtom na početku, točno ono što proizvodi I2OSP konverzija iz RFC 8017 i što polje potpisa CMS SignerInfo strukture (RFC 5652) zahtijeva prema ISO 32000-1 §12.8.3. CryptoAPI-jeva funkcija CryptSignHash, s druge strane, potpis vraća u little-endian poretku — dokumentirana osobitost koja potječe iz načina na koji su klasični CSP-ovi interno predstavljali velike brojeve. Izostavite li obrtanje na CAPI putanji, svaki bajt u potpisu završava na pogrešnom mjestu; RSA matematika je i dalje ispravna, no niz oktreta koji verifikator čita nije onaj koji definira PKCS#1

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

Što je s prilagođenim callbackom za potpisivanje?

Svatko tko zaobiđe HotPDF-ov ugrađeni potpisnik iz spremišta certifikata nasljeđuje isto pravilo o redoslijedu bajtova. HPDFCMSSignPDFStreamWithExternalSigner prima THPDFCMSSignDigestCallback, closure tipa reference to function(const SignedAttributesSHA256: TBytes): TBytes, za potpisivanje kroz HSM, sloj middlewarea za pametne kartice ili bilo što drugo što nije certifikat za koji vam Windows spremište može predati handle ključa. Bez obzira koji se mehanizam nalazi iza tog callbacka, bajtovi koje vraća moraju biti u big-endian poretku prije nego što ih HotPDF ugradi 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);

Vrijedi jasno istaknuti jednu granicu: obje HotPDF-ove ugrađene putanje potpisivanja — CNG preko NCryptSignHash s PKCS#1 nadopunom (padding) i CAPI preko CryptSignHashA — ciljaju RSA ključeve koji potpisuju 32-bajtni SHA-256 sažetak. Nijedna ne pregovara o formatu ECDSA potpisa. Certifikat čiji je privatni ključ temeljen na eliptičkim krivuljama (EC) zahtijeva potpisnik koji sami napišete nad HPDFCMSSignPDFStreamWithExternalSigner, kodirajući ECDSA potpis onako kako CMS očekuje, umjesto da pretpostavite RSA niz bajtova fiksne duljine — stoga nemojte očekivati da će ugrađeni potpisnik iz spremišta certifikata ispravno raditi za token opskrbljen EC certifikatom

Zašto se u C++Builderu ne povezuje CertOpenStore?

Zato što zadana uvozna biblioteka za C++Builder u RAD Studiju, import32.lib, ne izvozi CertOpenStore niti pet njezinih susjeda: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore i CryptAcquireCertificatePrivateKey. Delphi projekti ovo nikad ne vide, jer dcc32/dcc64 razrješavaju statički uvoz external 'crypt32.dll' izravno u PE tablicu uvoza. C++Builder je drugačiji: Delphi kompajler za izgradnju paketa generira OMF .obj datoteku, ilink32 je povezuje, i u tom trenutku ista external deklaracija postaje samo nerazriješen simbol koji čeka uvoznu biblioteku na naredbenom retku. Ni usmjeravanje povezivača na direktorij psdk Windows SDK-a, gdje puna crypt32.lib zaista izvozi svih šest simbola, to ne rješava: ilink32 povezuje samo uvozne biblioteke koje su doista navedene na njegovom naredbenom retku — prema zadanim postavkama import32.lib cp32mt.lib — a dodavanje putanje za pretraživanje ne uzrokuje da iz nje povuče bilo što dodatno. Pokretanje tdump nad import32.lib izravno potvrđuje taj propust: nula pogodaka za CertOpenStore, naspram šest čistih pogodaka u SDK-ovoj crypt32.lib

HotPDF ovo rješava na isti način na koji već rukuje nabrajanjem certifikata drugdje u biblioteci: umjesto da od povezivača traži te simbole, učitava ih u vrijeme izvođenja (runtime). Interni zapis THPDFCryptoProcs nosi handle za crypt32.dll, handle za advapi32.dll i jedanaest polja pokazivača na funkcije; LoadCryptoProcs učitava obje DLL datoteke i točno jednom razrješava svaku ulaznu točku pomoću GetProcAddress, na početku funkcije HPDFSignPDFStreamWithSystemCertificate, odmah izbacujući EHPDFCMSError ako bilo što nedostaje, umjesto da kasnije zakaže s narušavanjem pristupa (access violation) duboko unutar tijeka 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 odvija jednom po pozivu, a ne lijeno unutar svakog pomoćnog postupka, jer closure koji bira između CNG-a i CAPI-ja hvata učitanu tablicu funkcija po vrijednosti i mora ostati živ tijekom cijelog tijeka potpisivanja, uključujući callback u HPDFCMSSignPDFStreamWithExternalSigner; oba handlea DLL-a oslobađaju se u najvanjskijem bloku finally čim potpisivanje završi ili izbaci iznimku. Ništa od ovoga ne dira javno sučelje: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate i THPDFCertificateStoreSelector zadržavaju potpuno iste potpise kao i prije, pa je za postojeće pozivatelje prihvaćanje ovog ispravka samo ponovna izgradnja, a ne promjena koda

Što ovo ne pokriva

Ispravan redoslijed bajtova i ispravno povezivanje u C++Builderu daju CMS SignerInfo koji validator može raščlaniti i potpis koji može aritmetički provjeriti; to ništa ne govori o tome bi li taj validator trebao vjerovati certifikatu iza njega, jer su izgradnja lanca, provjera opoziva i politika vremenskog žiga zasebne brige koje se slažu odozgo kroz CMS opcije, a ne nešto što ispravnost redoslijeda bajtova donosi besplatno. Dva detalja održavanja jednako su važna kao i sama kriptografija: PCCERT_CONTEXT koji vraća pretraga certifikata mora se osloboditi pozivom CertFreeCertificateContext prije nego što se spremište zatvori, a stečeni handle CNG ili CSP ključa, kad API prijavi da je pozivatelj njegov vlasnik, mora se osloboditi kroz odgovarajući poziv istog mehanizma, nikad onog drugog. Ako se rezultat svValid koji dobijete nakon svega ovoga pokaže užim nego što ste očekivali, članak o provjeri digitalnih potpisa PDF-a detaljno objašnjava što ta zastavica obećava, a što ne. Budući da certifikat cijelo vrijeme ostaje pod nadzorom Windowsa, potpisivanje iz spremišta certifikata zaobilazi čitavu površinu napada: nema PKCS#12 datoteke koju treba raščlaniti niti ASN.1 kroz koji morate sami proći, a to je upravo problem koji HotPDF-ovo jačanje PKCS#12 i ASN.1 rješava za putanju potpisivanja PFX datotekom

Potpisivanje iz spremišta certifikata, PFX potpisivanje i callbackovi za vanjske potpisnike tri su vrata u isti CMS/PKCS#7 cjevovod unutar HotPDF PDF komponente za Delphi i C++Builder, a odabir pravog uglavnom se svodi na to tko smije držati privatni ključ: vaš proces, PFX datoteka ili sam Windows