Tehnički članak

Samopotpisani test sertifikati u Delphi-ju uz CryptoAPI

Funkcija PLCreateSelfSignedCertificate u PDF Library for Delphi-u gradi samopotpisani RSA/SHA-256 sertifikat i izvozi ga, zajedno sa privatnim ključem, direktno u PFX datoteku zaštićenu lozinkom, koristeći samo Win32 CryptoAPI koji je već instaliran na svakom Windows računaru. Nisu potrebni spoljni alat, sertifikaciono telo ni ručni korak sa makecert ili OpenSSL: jedan poziv funkcije i jedan sertifikat dovoljan za test potpisivanja

Situacija u kojoj je ova funkcija najkorisnija gotovo je uvek CI cevovod. Probni test potpisivanja zahteva pravi PFX sa pravim privatnim ključem, a čuvanje takve datoteke u repozitorijumu samo po sebi predstavlja bezbednosni problem, jer privatni ključ postaje procureo čim se nađe u komitu. Pozivanje makecert.exe ili OpenSSL-a iz skripte za izgradnju takođe radi, ali cevovod tada zavisi od alata koji mora biti instaliran, pronađen na PATH-u i usklađen po verziji na svakom agentu izgradnje. Generisanje sertifikata u istom procesu koji izvršava test, istim Win32 CryptoAPI pozivima koje Windows već isporučuje, potpuno uklanja tu zavisnost

Šta PLCreateSelfSignedCertificate zaista proizvodi

PLCreateSelfSignedCertificate pravi PFX datoteku zaštićenu lozinkom koja sadrži samopotpisani RSA sertifikat i privatni ključ, potpisan algoritmom sha256RSA, na osnovu pet parametara: SubjectName, PFXFileName, PFXPassword, ValidDays i KeyBits, a vraća običnu Boolean zastavicu uspeha. SubjectName prihvata punu X.500 vrednost kao što je 'CN=Alice, O=Example', a golo ime bez znaka = automatski dobija prefiks CN=. Vrednost ValidDays manja od 1 vraća se na 365, a KeyBits izvan opsega od 1024 do 16384 vraća se na 2048. PDF Library for Delphi isporučuje ovu funkciju od v3.224.0, dostupnu ne samo iz Delphi jedinice već i kroz DLL i ActiveX površine, a njen komentar u dokumentaciji jasno navodi granicu korisnosti: svi glavni čitači samopotpisani sertifikat označavaju kao nepouzdan dok ga neko izričito ne instalira, pa ga koristite za izvršavanje putanje koda, a ne za potpis kome bi iko izvan tima trebalo da veruje

var
  Success: Boolean;
begin
  Success := PLCreateSelfSignedCertificate(
    'CN=PDF Library for Delphi CI Test, O=Example Corp',
    'ci-test-signer.pfx',
    'a-strong-throwaway-password',
    365,     // ValidDays
    2048);   // KeyBits
  if not Success then
    raise Exception.Create('Self-signed certificate generation failed');
end;

Zašto CryptGenKey kodira dužinu ključa u parametru zastavica

CryptGenKey pakuje dve nepovezane postavke u jedan parametar dwFlags. Donja reč sadrži zastavice ponašanja, među njima CRYPT_EXPORTABLE, dok gornja reč za RSA ključ razmene nosi traženu dužinu ključa u bitovima. Ako 2048 prosledite kao da je samo još jedna zastavica, završava u donjoj reči, gde ne odgovara nijednoj zastavici ponašanja koju CryptoAPI definiše, pa poziv pravi ključ podrazumevane dužine na koju se dobavljač vraća, a ne dužine koju je pozivalac mislio da je zatražio. Za stvarni RSA ključ od 2048 bitova broj prvo mora da se pomeri u gornju reč

Dijagram PDF Library for Delphi o CryptGenKey dwFlags parametru koji se deli na visoku reč koja nosi KeyBits shl 16 kao dužinu RSA ključa i nisku reč koja nosi zastavice ponašanja poput CRYPT_EXPORTABLE
Goli 2048 sleće u polovicu zastavica ponašanja, pa se davalac tiho vraća na svoju podrazumevanu dužinu ključa
// Dužina ključa se nalazi u gornjih 16 bita zastavica CryptGenKey;
// donja reč nosi zastavice ponašanja poput CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
    (Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
  Exit;

Šta se dešava ako zaboravite CRYPT_EXPORTABLE

Ako iz iste vrednosti zastavica izostavite CRYPT_EXPORTABLE, CryptGenKey i dalje uspeva, ali privatni ključ označava kao neizvoziv na nivou CSP-a. Sve nizvodno takođe nastavlja da prijavljuje uspeh: CertCreateSelfSignCertificate vraća važeći kontekst sertifikata, a PFXExportCertStoreEx, čak i kada se pozove sa EXPORT_PRIVATE_KEYS, uspeva i upisuje PFX datoteku koja se otvara, parsira i izgleda sasvim obično. Ono što joj nedostaje jeste privatni ključ, jer CSP nije dozvolio da napusti kontejner ključa, a PFXExportCertStoreEx to odbijanje nikada ne tretira kao razlog da obori ceo izvoz

Greška se pojavljuje tek kasnije i na sasvim drugom mestu: poziv za potpisivanje otvori taj PFX, pronađe sertifikat bez pridruženog privatnog ključa i prijavi istu grešku koju biste dobili od oštećenog ili pogrešnog PFX-a, a ne od zastavice koja nedostaje tri sloja iznad. Ko otklanja grešku samo sa strane potpisivanja može provesti popodne tražeći problem u pogrešnoj datoteci pre nego što shvati da je stvarna greška jedan nedostajući bit u trenutku generisanja ključa, u potpuno drugom pozivu funkcije, možda i u potpuno drugoj skripti za izgradnju

Dijagram PDF Library for Delphi o tihom kaskadnom neuspehu okinutom izostavljanjem CRYPT_EXPORTABLE, gde generisanje ključa, stvaranje sertifikata i PFX izvoz svi prijavljuju uspeh dok kasniji poziv potpisivanja ne pronađe privatni ključ unutar PFX-a
Izostavljanje CRYPT_EXPORTABLE proizvodi PFX običnog izgleda čiji nepostojeći privatni ključ izranja tek pri potpisivanju

Zašto ProvType mora da se podudara između CryptAcquireContextW i sertifikata

ProvType mora da se podudara zato što CertCreateSelfSignCertificate privatni ključ novog sertifikata razrešava kroz zapis CRYPT_KEY_PROV_INFO, a jedno polje tog zapisa, ProvType, mora da navede potpuno istu vrednost tipa CSP-a koja je prosleđena funkciji CryptAcquireContextW pri otvaranju kontejnera ključa, u implementaciji PDF Library for Delphi-a PROV_RSA_AES, brojčano 24. Ako ProvType postavite na nulu ili na bilo koju konstantu dobavljača koja se razlikuje od one kojoj kontejner zaista pripada, sertifikat i dalje može biti napravljen, ali zapisana veza nazad do privatnog ključa više ne vodi do kontejnera koji ga sadrži, pa se kasnije javlja greška pri potpisivanju ili izvozu koja nema veze sa stvarnim kriptografskim sadržajem sertifikata

// Tip dobavljača korišćen za otvaranje kontejnera ključa mora da se poklapa sa
// tipom dobavljača zabeleženim u podacima o ključu-dobavljaču sertifikata.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generiši ključ, izgradi blok imena subjekta, zatim:
KeyProvInfo.ProvType := PROV_RSA_AES;   // ista konstanta, oba mesta poziva

Sastavljanje celine: od GUID kontejnera do PFX-a zaštićenog lozinkom

Lanac poziva unutar funkcije PLCreateSelfSignedCertificate prati jednu ravnu putanju: otvara svež kontejner ključa nazvan po novom GUID-u, tako da se paralelna CI izvršavanja nikada ne sudare oko imena kontejnera, generiše RSA par ključeva u njemu pomoću dve prethodno opisane zastavice, kodira SubjectName u X.500 blok imena kroz CertStrToNameW i poziva CertCreateSelfSignCertificate sa periodom važenja izračunatim iz ValidDays i prosleđenim kao obična struktura oblika SYSTEMTIME. Dobijeni kontekst sertifikata smešta se u memorijsku prodavnicu sertifikata otvorenu pomoću CertOpenStore i CERT_STORE_PROV_MEMORY, samo da bi PFXExportCertStoreEx imao prodavnicu iz koje izvozi, jer taj API radi sa rukohvatom prodavnice, a ne sa samostalnim kontekstom sertifikata

// Svaki poziv otvara jednokratni kontejner nazvan po svežem GUID-u:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generiši ključ, samopotpiši sertifikat, izvezi PFX ...
// zatim obriši kontejner čim PFX nosi sopstvenu kopiju ključa:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
  PROV_RSA_AES, CRYPT_DELETEKEYSET);

PFXExportCertStoreEx prati uobičajenu Win32 konvenciju sa dva prolaza: prvi put se poziva sa baferom dužine nula da bi se saznalo koliko bajtova PFX zahteva, zatim se alocira toliko prostora i funkcija poziva drugi put da popuni bafer. Kada su bajtovi na disku, PDF Library for Delphi briše privremeni kontejner ključa pomoću CRYPT_DELETEKEYSET umesto da ga ostavi, jer PFX već nosi sopstvenu kopiju svakog bajta materijala ključa koji je kontejner sadržao. Ako se čišćenje preskoči, svaki poziv funkcije PLCreateSelfSignedCertificate ostavlja napušten kontejner ključa sa imenom GUID-a u profilu korisnika koji poziva funkciju, upravo onu vrstu curenja koju CI agent koji ovo izvršava pri svakoj izgradnji može mesecima da gomila pre nego što je neko primeti

Dijagram PDF Library for Delphi o PLCreateSelfSignedCertificate lancu poziva od kontejnera ključa nazvanog po GUID-u preko CryptGenKey, CertStrToNameW i CertCreateSelfSignCertificate sa odgovarajućim ProvType do dvoprolaznog PFX izvoza, praćeno brisanjem kontejnera
Jednokratni GUID kontejner hrani pravilan CryptoAPI lanac koji se završava u PFX i briše se odmah potom

Da li je samopotpisani sertifikat bezbedan za potpisivanje u produkciji

Ne: samopotpisani sertifikat je bezbedan za proveru putanje koda za potpisivanje, ali nije bezbedan za potpis kome bi neko izvan tima trebalo da veruje, jer ga ništa ne povezuje sa korenom kojem softver strane koja se oslanja na sertifikat već veruje. Prirodan sledeći korak za ovakav PFX jeste stvarni poziv za potpisivanje, obrađen u tekstu izgradnje radnog okruženja za usaglašenost i potpisivanje u Delphi-ju sa PDF Library for Delphi-om, gde ovako napravljen PFX pokreće polovinu cevovoda za potpisivanje koja takođe izvršava PDF/A preflight i ByteRange provere. Potpisivanje je ipak samo polovina priče oko sertifikata, a druga polovina je upravo mesto na kojem samopotpisani krajnji sertifikat treba da padne: PAdES potpisivanje i validacija u Delphi-ju sa PDF Library for Delphi-om pokrivaju provere lanca poverenja koje izvršava validator usaglašenosti, a validator koji lanac prati do pouzdanog korena nema razlog da veruje sertifikatu koji je ova funkcija pre pet minuta izmislila ni iz čega

PLCreateSelfSignedCertificate je jedna od funkcija među API-jima za sertifikate i potpisivanje u PDF biblioteci PDF Library for Delphi za Delphi i C++Builder, i postoji upravo zbog ovde opisane praznine: testa potpisivanja kome je potreban pravi par ključeva, bez spoljnog generatora