Techninis straipsnis

HotPDF sertifikatų saugyklos PDF pasirašymas: CNG ir CAPI baitų tvarka

HotPDF pasirašo PDF dokumentą sertifikatu, kuris jau yra Windows sertifikatų saugykloje, perduodamas santrauką pačiai Windows, o Windows užbaigia šią užklausą per vieną iš dviejų privačiojo rakto posistemių: CNG grąžina RSA parašą didėjančia baitų tvarka, o senoji CryptoAPI CSP grąžina jį mažėjančia baitų tvarka. Supainiojus šiuos du atvejus, HotPDF įterpiamas CMS parašas apverčiamas pagal tą posistemę, kuri iš tikrųjų atsakė, todėl standartus atitinkantis tikrintuvas praneša, kad parašas negalioja, nors dokumento baitai niekada nebuvo pakeisti

Už šio vieno sakinio slepiasi dvi nesusijusios problemos, kurias HotPDF sisteminio sertifikato pasirašyklė turi išspręsti prieš apskritai ką nors pasirašydama. Baitų tvarkos neatitikimas yra tylus: pasirašymo iškvietimas vis dar grąžina True, PDF vis dar atsidaro, o klaida išryškėja tik tada, kai peržiūros programa pereina per CMS struktūrą ir ją atmeta. Antroji problema yra triukšminga ir būdinga C++Builder: maždaug pusė krūvos crypt32 funkcijų atsisako susieti, nes RAD Studio pateikiama importo biblioteka jų neeksportuoja. Nė viena problema nekyla, jei visada pasirašote tik PFX failu, todėl su ja dažniausiai susiduria kūrėjai, pereinantys nuo vieno iškvietimo pasirašymo pagal PFX prie sertifikato, kurį IT skyrius jau įdiegė naudotojo profilyje

Sertifikato pasirinkimas iš saugyklos

HotPDF suteikia šį kelią per HPDFSignPDFStreamWithSystemCertificate ir HPDFSignPDFFileWithSystemCertificate, kuriuos abu valdo THPDFCertificateStoreSelector įrašas: Location (cslCurrentUser arba cslLocalMachine), StoreName ('MY', asmeninė saugykla pagal numatytuosius nustatymus), SHA-1 Thumbprint ir AllowUI vėliavėlė. Kontrolinė suma normalizuojama viduje, todėl iš Windows sertifikatų tvarkyklės naudotojo sąsajos tiesiai nukopijuoti brūkšneliai arba tarpai pašalinami prieš vykdant palyginimą

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 yra svarbiau, nei gali pasirodyti, nes ši reikšmė tiesiogiai susiejama su CRYPT_ACQUIRE_SILENT_FLAG, o Windows tai taiko pažodžiui: jei atitinkamo sertifikato privatusis raktas yra išmaniojoje kortelėje arba rakte, kuriam reikia PIN kodo lango, o Windows jo dar nėra įsiminusi, CryptAcquireCertificatePrivateKey nepavyksta, užuot parodžius dialogo langą procese, kuris gali būti tarnyba. Ši klaida yra aiški, tai EHPDFCMSError, kurią matote iš karto, tačiau ją lengva klaidingai palaikyti pranešimu „sertifikatas nerastas“, nors tikroji priežastis yra laukiantis PIN kodo raktas, kurio niekas neįves

Kodėl CNG ir CAPI nesutaria dėl baitų tvarkos?

Į kurią posistemę kreiptasi, nėra spėjimas: CryptAcquireCertificatePrivateKey tai tiesiogiai praneša per KeySpec išvesties parametrą, o būtent ši viena reikšmė lemia, kurią šaką pasirenka HotPDF pasirašyklė. CNG Key Storage Provider raktas grąžinamas su KeySpec, nustatytu į specialią reikšmę CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); bet kuri kita reikšmė reiškia tradicinį CryptoAPI CSP raktą. Dauguma asmeninių sertifikatų, išduotų arba importuotų dabartiniame Windows diegime, nustatomi kaip CNG, nors suderinamumui vis dar egzistuoja senoji CSP tarpinė sąsaja, todėl prieš tikrindama grąžintą reikšmę HotPDF prašo CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG kartu su CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG

Šios dvi posistemės ne tik iškviečia skirtingas funkcijas, NCryptSignHash CNG rakto atveju ir CryptSignHashA CSP rakto atveju; jos grąžina neapdorotą RSA parašą priešingomis baitų tvarkomis. CNG išvestis jau atitinka PKCS#1 reikalavimus: didėjančios baitų tvarkos oktetų eilutę, kurioje reikšmingiausias baitas yra pirmas, būtent tokią, kokią sukuria RFC 8017 I2OSP konversija ir kokios reikia CMS SignerInfo (RFC 5652) parašo lauke pagal ISO 32000-1 §12.8.3. Tuo tarpu CryptoAPI CryptSignHash grąžina parašą mažėjančia baitų tvarka; tai dokumentuota ypatybė, kilusi iš to, kaip klasikinės CSP viduje vaizdavo didelius skaičius. Neapvertus CAPI kelio, kiekvienas parašo baitas atsiduria netinkamoje vietoje; RSA matematika vis dar teisinga, tačiau tikrintuvo skaitoma oktetų eilutė nėra ta, kurią apibrėžia 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;

O kaip dėl pasirinktinės pasirašyklės atgalinio iškvietimo?

Kiekvienas, apeinantis HotPDF įtaisytąją sertifikatų saugyklos pasirašyklę, paveldi tą pačią baitų tvarkos taisyklę. HPDFCMSSignPDFStreamWithExternalSigner priima THPDFCMSSignDigestCallback, reference to function(const SignedAttributesSHA256: TBytes): TBytes tipo uždarymą, skirtą pasirašyti per HSM, išmaniosios kortelės tarpinės programinės įrangos dėklą ar bet ką kitą, kur Windows saugykla negali perduoti rakto rankenos. Nesvarbu, kokia posistemė yra už šio atgalinio iškvietimo, jo grąžinami baitai turi būti didėjančia tvarka prieš HotPDF įtraukiant juos į CMS struktūrą

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

Čia verta aiškiai nubrėžti ribą: abu HotPDF įtaisytieji pasirašymo keliai, CNG per NCryptSignHash su PKCS#1 užpildymu ir CAPI per CryptSignHashA, skirti RSA raktams, pasirašantiems 32 baitų SHA-256 santrauką. Nė vienas iš jų nederina ECDSA parašo formato. Sertifikatui, kurio privatusis raktas pagrįstas EC, reikia pasirašyklės, kurią parašysite patys pagal HPDFCMSSignPDFStreamWithExternalSigner, užkoduodami ECDSA parašą taip, kaip to reikalauja CMS, o ne laikydami jį fiksuoto ilgio RSA baitų eilute, todėl nesitikėkite, kad įtaisytoji sertifikatų saugyklos pasirašyklė tinkamai veiks su raktu, aprūpintu EC sertifikatu

Kodėl C++Builder nepavyksta susieti CertOpenStore?

Nes RAD Studio numatytoji C++Builder importo biblioteka, import32.lib, neeksportuoja CertOpenStore arba penkių jos kaimyninių funkcijų: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore ir CryptAcquireCertificatePrivateKey. Delphi kompiliacijos to nepastebi, nes dcc32/dcc64 išsprendžia statinį external 'crypt32.dll' importą tiesiai į PE importo lentelę. C++Builder veikia kitaip: Delphi kompiliatorius paketui sukuria OMF .obj, ilink32 jį susieja, o tuo metu ta pati external deklaracija tėra neišspręstas simbolis, laukiantis importo bibliotekos komandinėje eilutėje. Nukreipus susiejimo programą į Windows SDK psdk katalogą, kuriame visa crypt32.lib eksportuoja visus šešis simbolius, problema taip pat neišsprendžiama: ilink32 susieja tik importo bibliotekas, kurios iš tikrųjų nurodytos jo komandinėje eilutėje, pagal numatytuosius nustatymus import32.lib cp32mt.lib, o paieškos kelio pridėjimas nepriverčia įtraukti nieko papildomo iš to kelio. Paleidus tdump prieš import32.lib, spraga patvirtinama tiesiogiai: CertOpenStore nerandamas nė karto, o SDK crypt32.lib randami šeši atskiri atitikmenys

HotPDF tai išsprendžia taip pat, kaip kitur bibliotekoje jau tvarko sertifikatų išvardijimą: užuot prašiusi susiejimo programos šių simbolių, ji įkelia juos vykdymo metu. Vidiniame THPDFCryptoProcs įraše saugoma crypt32.dll rankena, advapi32.dll rankena ir vienuolika funkcijų rodyklių laukų; LoadCryptoProcs įkelia abi DLL ir tiksliai vieną kartą, HPDFSignPDFStreamWithSystemCertificate pradžioje, išsprendžia kiekvieną įėjimo tašką naudodama GetProcAddress, o jei ko nors trūksta, iš karto iškelia EHPDFCMSError, užuot vėliau sukėlusi prieigos pažeidimą giliai pasirašymo eigoje

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;

Įkeliama kartą per iškvietimą, o ne nerūpestingai kiekviename pagalbiniame metode, nes uždarymas, pasirenkantis CNG arba CAPI, fiksuoja įkeltą funkcijų lentelę pagal reikšmę ir turi išlikti gyvas per visą pasirašymo eigą, įskaitant atgalinį iškvietimą į HPDFCMSSignPDFStreamWithExternalSigner; abi DLL rankenos atlaisvinamos išoriniame finally bloke, kai pasirašymas baigiamas arba iškelia išimtį. Visa tai neliečia viešojo paviršiaus: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate ir THPDFCertificateStoreSelector išlaiko tiksliai tuos pačius parašus, todėl esamiems naudotojams pataisymui pritaikyti reikia perkompiliuoti, o ne keisti kodą

Ko tai neapima

Teisinga baitų tvarka ir C++Builder susiejimas sukuria CMS SignerInfo, kurį tikrintuvas gali išanalizuoti, ir parašą, kurį gali aritmetiškai patikrinti; tai nieko nepasako apie tai, ar tikrintuvas turėtų pasitikėti už jo esančiu sertifikatu, nes grandinės kūrimas, atšaukimo tikrinimas ir laiko žymos politika yra atskiros funkcijos, taikomos per CMS parinktis, o baitų tvarkos teisingumas jų nesuteikia automatiškai. Du priežiūros aspektai yra ne mažiau svarbūs nei kriptografija: paieškos grąžintas PCCERT_CONTEXT turi būti atlaisvintas naudojant CertFreeCertificateContext prieš uždarant saugyklą, o įgyta CNG arba CSP rakto rankena, kai API nurodo, kad už ją atsako iškvietėjas, turi būti atlaisvinta naudojant atitinkamos posistemės iškvietimą, niekada ne kitos posistemės. Jei po viso šio proceso gautas svValid rezultatas pasirodo siauresnis, nei tikėjotės, straipsnyje apie PDF parašų tikrinimą tiksliai paaiškinta, ką ši vėliavėlė garantuoja ir ko negarantuoja. Kadangi sertifikatas visą laiką lieka Windows valdomas, pasirašymas iš sertifikatų saugyklos pašalina visą atakos paviršiaus dalį: nereikia analizuoti PKCS#12 failo ir pačiam pereiti ASN.1 struktūros, o būtent šią problemą HotPDF PKCS#12 ir ASN.1 atsparumo stiprinimas sprendžia pasirašant PFX failu

Pasirašymas iš sertifikatų saugyklos, pasirašymas PFX failu ir išorinės pasirašyklės atgaliniai iškvietimai yra trys durys į tą patį CMS/PKCS#7 konvejerį, esantį HotPDF PDF komponente, skirtame Delphi ir C++Builder, o tinkamo pasirinkimo esmė dažniausiai priklauso nuo to, kas gali laikyti privatųjį raktą: jūsų procesas, PFX failas ar pati Windows