HotPDF allekirjoittaa PDF-tiedoston Windowsin sertifikaattivarastossa jo olevaa sertifikaattia vasten antamalla tiivisteen (digest) Windowsin itsensä käsiteltäväksi, ja Windows suorittaa pyynnön jommallakummalla kahdesta yksityisavaimen taustajärjestelmästä: CNG:llä, joka palauttaa RSA-allekirjoituksen big-endian-muodossa, tai vanhalla CryptoAPI CSP:llä, joka palauttaa sen little-endian-muodossa. Jos nämä kaksi sekoitetaan keskenään, HotPDF:n upottama CMS-allekirjoitus on tavujärjestykseltään käänteinen sen mukaan, kumpi taustajärjestelmä todella vastasi, jolloin standardinmukainen validaattori ilmoittaa allekirjoituksen virheelliseksi, vaikka asiakirjan tavuihin ei ole koskettu lainkaan
Tuon yhden virkkeen taakse kätkeytyy kaksi toisistaan riippumatonta ongelmaa, ja HotPDF:n järjestelmäsertifikaatilla allekirjoittavan komponentin on ratkaistava molemmat ennen kuin mitään allekirjoitetaan. Tavujärjestysvirhe on äänetön: allekirjoituskutsu palauttaa yhä True-arvon, PDF avautuu edelleen, ja virhe paljastuu vasta kun katseluohjelma käy CMS-rakenteen läpi ja hylkää sen. Toinen ongelma on äänekäs ja koskee nimenomaan C++Builderia: puoli tusinaa crypt32-funktiota kieltäytyy linkittymästä, koska RAD Studion mukana tuleva tuontikirjasto ei vie niitä ulos. Kumpaakaan ongelmaa ei esiinny, jos allekirjoitat aina vain PFX-tiedostolla, minkä vuoksi tämä yllättää tyypillisesti kehittäjät, jotka siirtyvät PFX-pohjaisesta yhden kutsun allekirjoituksesta sertifikaattiin, jonka IT-osasto on jo asentanut käyttäjän profiiliin
Sertifikaatin valitseminen varastosta
HotPDF tarjoaa tämän polun funktioina HPDFSignPDFStreamWithSystemCertificate ja HPDFSignPDFFileWithSystemCertificate, joita molempia ohjaa THPDFCertificateStoreSelector-tietue: Location (cslCurrentUser tai cslLocalMachine), StoreName (oletuksena 'MY', henkilökohtainen varasto), SHA-1-Thumbprint sekä AllowUI-lippu. Sormenjälki normalisoidaan sisäisesti, joten suoraan Certificate Manager -käyttöliittymästä kopioidut väliviivat tai välilyönnit poistetaan ennen vertailua
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 on merkitykseltään suurempi kuin miltä näyttää, koska se vastaa suoraan CRYPT_ACQUIRE_SILENT_FLAG-lippua, ja Windows noudattaa tätä kirjaimellisesti: jos vastaavan sertifikaatin yksityisavain sijaitsee älykortilla tai tokenissa, joka vaatii PIN-kehotteen, jota Windows ei ole vielä välimuistittanut, CryptAcquireCertificatePrivateKey epäonnistuu sen sijaan, että se avaisi valintaikkunan prosessista, joka saattaa olla palveluprosessi. Tämä virhe on äänekäs, näet EHPDFCMSError-poikkeuksen välittömästi, mutta se on helppo tulkita väärin "sertifikaattia ei löytynyt" -virheeksi, kun todellinen syy on token, joka odottaa PIN-koodia, jota kukaan ei ole syöttämässä
Miksi CNG ja CAPI ovat eri mieltä tavujärjestyksestä?
Se, kumpi taustajärjestelmä vastaa, ei ole arvausta: CryptAcquireCertificatePrivateKey ilmoittaa sen suoraan KeySpec-ulostuloparametrin kautta, ja juuri tämän yhden arvon perusteella HotPDF:n allekirjoituskomponentti haarautuu. CNG Key Storage Provider -avain palautuu KeySpec-arvolla, joka on asetettu sentinel-arvoon CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); mikä tahansa muu on perinteinen CryptoAPI CSP -avain. Useimmat nykyisellä Windows-asennuksella myönnetyt tai tuodut henkilökohtaiset sertifikaatit selviävät CNG:nä, vaikka vanha CSP-yhteensopivuuskerros on yhä olemassa taaksepäin yhteensopivuuden vuoksi, minkä vuoksi HotPDF pyytää CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG-lipun yhdessä CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG-lipun kanssa ennen kuin se tarkistaa, kumpi arvo palautui
Nämä kaksi taustajärjestelmää eivät vain kutsu eri funktioita, NCryptSignHash CNG-avainta vasten, CryptSignHashA CSP-avainta vasten, vaan ne palauttavat myös raa'an RSA-allekirjoituksen vastakkaisessa tavujärjestyksessä. CNG:n tulos vastaa jo sitä, mitä PKCS#1 odottaa: big-endian-muotoinen oktettijono, merkitsevin tavu ensin, täsmälleen se, minkä RFC 8017:n I2OSP-muunnos tuottaa ja mitä CMS SignerInfo (RFC 5652) tarvitsee allekirjoituskentässään ISO 32000-1 §12.8.3:n mukaisesti. CryptoAPI:n CryptSignHash sen sijaan palauttaa allekirjoituksen little-endian-muodossa, dokumentoitu erikoisuus, joka juontaa juurensa siitä, miten klassiset CSP:t esittivät suuria lukuja sisäisesti. Jos tavujärjestyksen kääntäminen jätetään väliin CAPI-polulla, jokainen allekirjoituksen tavu on väärässä paikassa; RSA-matematiikka on yhä oikein, mutta oktettijono, jonka varmentaja lukee, ei ole se, jonka PKCS#1 määrittelee
// 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;
Entä mukautettu allekirjoitus-callback?
Jokainen, joka ohittaa HotPDF:n sisäänrakennetun sertifikaattivarastoallekirjoittajan, perii saman tavujärjestyssäännön. HPDFCMSSignPDFStreamWithExternalSigner ottaa parametrikseen THPDFCMSSignDigestCallback-tyyppisen sulkeuman, tyyppiä reference to function(const SignedAttributesSHA256: TBytes): TBytes, allekirjoittamista varten HSM:n, älykorttiväliohjelmiston tai minkä tahansa muun kautta, joka ei ole sertifikaatti, jolle Windows-varasto voi antaa avainkahvan. Riippumatta siitä, mikä taustajärjestelmä callbackin takana on, sen palauttamien tavujen on oltava big-endian-järjestyksessä ennen kuin HotPDF taittaa ne CMS-rakenteeseen
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);
Kannattaa olla tässä täsmällinen rajauksen suhteen: HotPDF:n kaksi sisäänrakennettua allekirjoituspolkua, CNG NCryptSignHash:n kautta PKCS#1-täytteellä ja CAPI CryptSignHashA:n kautta, kohdistuvat molemmat RSA-avaimiin, jotka allekirjoittavat 32-tavuisen SHA-256-tiivisteen. Kumpikaan ei neuvottele ECDSA-allekirjoitusmuotoa. Sertifikaatti, jonka yksityisavain perustuu elliptiseen käyrään (EC), tarvitsee itse kirjoitetun allekirjoittajan HPDFCMSSignPDFStreamWithExternalSigner-funktiota vasten, joka koodaa ECDSA-allekirjoituksen CMS:n odottamalla tavalla sen sijaan, että oletettaisiin kiinteän pituinen RSA-tavujono, joten älä odota sisäänrakennetun sertifikaattivarastoallekirjoittajan toimivan oikein EC-sertifikaatilla varustetulle tokenille
Miksi C++Builder ei onnistu linkittämään CertOpenStorea?
Koska RAD Studion oletusarvoinen C++Builderin tuontikirjasto, import32.lib, ei vie ulos CertOpenStore-funktiota eikä viittä sen naapuria: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore ja CryptAcquireCertificatePrivateKey. Delphi-käännökset eivät koskaan näe tätä, koska dcc32/dcc64 ratkaisee staattisen external 'crypt32.dll'-tuonnin suoraan PE-tuontitauluun. C++Builder on erilainen: Delphi-kääntäjä tuottaa pakettikäännökselle OMF-.obj-tiedoston, ilink32 linkittää sen, ja tässä vaiheessa sama external-julistus on vain ratkaisematon symboli, joka odottaa tuontikirjastoa komentorivillä. Linkkerin osoittaminen Windows SDK:n psdk-hakemistoon, jossa täysi crypt32.lib todella vie ulos kaikki kuusi symbolia, ei myöskään korjaa asiaa: ilink32 linkittää vain komentorivillä nimenomaisesti mainitut tuontikirjastot, oletuksena import32.lib cp32mt.lib, eikä hakupolun lisääminen saa sitä poimimaan mitään ylimääräistä kyseisestä polusta. tdump:n ajaminen import32.lib:tä vasten vahvistaa aukon suoraan: nolla osumaa CertOpenStore:lle, kun taas SDK:n crypt32.lib:ssä on kuusi puhdasta osumaa
HotPDF ratkaisee tämän samalla tavalla kuin se jo käsittelee sertifikaattien luettelointia muualla kirjastossa: sen sijaan, että se pyytäisi näitä symboleita linkkeriltä, se lataa ne ajonaikaisesti. Sisäinen THPDFCryptoProcs-tietue kantaa crypt32.dll-kahvan, advapi32.dll-kahvan ja yksitoista funktio-osoitinkenttää; LoadCryptoProcs lataa molemmat DLL:t ja ratkaisee jokaisen sisääntulopisteen GetProcAddress-funktiolla tasan kerran, HPDFSignPDFStreamWithSystemCertificate-funktion alussa, nostaen EHPDFCMSError-poikkeuksen välittömästi, jos jokin puuttuu, sen sijaan että epäonnistuisi myöhemmin access violation -virheeseen jossain syvällä allekirjoitusprosessissa
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;
Lataus tapahtuu kerran per kutsu sen sijaan, että se tehtäisiin laiskasti jokaisen apufunktion sisällä, koska CNG:n ja CAPI:n välillä valitseva sulkeuma kaappaa ladatun funktiotaulun arvon perusteella ja sen on pysyttävä hengissä koko allekirjoitusprosessin ajan, mukaan lukien callback HPDFCMSSignPDFStreamWithExternalSigner:iin; molemmat DLL-kahvat vapautetaan uloimmassa finally-lohkossa, kun allekirjoitus päättyy tai nostaa poikkeuksen. Mikään tästä ei kosketa julkista rajapintaa: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate ja THPDFCertificateStoreSelector säilyttävät täsmälleen samat allekirjoitukset kuin ennen, joten korjauksen käyttöönotto tarkoittaa olemassa oleville kutsujille pelkkää uudelleenkäännöstä, ei koodimuutosta
Mitä tämä ei kata
Tavujärjestyksen ja C++Builder-linkityksen korjaaminen tuottaa CMS SignerInfo -rakenteen, jonka validaattori voi jäsentää, ja allekirjoituksen, jonka se voi tarkistaa aritmeettisesti; se ei kerro mitään siitä, pitäisikö kyseisen validaattorin luottaa sen takana olevaan sertifikaattiin, sillä ketjun rakentaminen, sulkulistan (revocation) tarkistus ja aikaleimakäytäntö ovat erillisiä, CMS-asetusten kautta päälle kerrostettavia asioita, eivät jotain, minkä tavujärjestyksen oikeellisuus antaa ilmaiseksi. Kaksi ylläpidollista yksityiskohtaa ovat yhtä tärkeitä kuin itse kryptografia: sertifikaattihaun palauttama PCCERT_CONTEXT on vapautettava CertFreeCertificateContext-funktiolla ennen varaston sulkemista, ja hankittu CNG- tai CSP-avainkahva on, kun API ilmoittaa kutsujan omistavan sen, vapautettava vastaavan taustajärjestelmän omalla kutsulla, ei koskaan toisen. Jos kaiken tämän jälkeen saatu svValid-tulos osoittautuu suppeammaksi kuin odotit, artikkeli PDF-allekirjoitusten varmentamisesta selittää tarkasti, mitä tuo lippu lupaa ja mitä ei. Koska sertifikaatti pysyy koko ajan Windowsin hallussa, sertifikaattivarastosta allekirjoittaminen kiertää kokonaisen hyökkäyspinnan: mitään PKCS#12-tiedostoa ei tarvitse jäsentää eikä mitään ASN.1-rakennetta tarvitse itse käydä läpi, mikä on ongelma, jonka HotPDF:n PKCS#12- ja ASN.1-koveneminen ratkaisee sen sijaan PFX-tiedostoon perustuvalle allekirjoituspolulle
Sertifikaattivarastosta allekirjoittaminen, PFX-allekirjoitus ja ulkoisen allekirjoittajan callbackit ovat kolme ovea samaan CMS/PKCS#7-putkeen HotPDF-PDF-komponentissa Delphille ja C++Builderille, ja oikean vaihtoehdon valinta riippuu pääasiassa siitä, kenellä on lupa pitää yksityisavainta hallussaan: omalla prosessillasi, PFX-tiedostolla vai Windowsilla itsellään