Odborný článok

Podpisovanie PDF certifikátom z úložiska v HotPDF: poradie bajtov CNG vs CAPI

HotPDF podpisuje PDF certifikátom, ktorý sa už nachádza v úložisku certifikátov Windows, tak, že odovzdá digest priamo Windows, a Windows túto požiadavku spracuje cez jeden z dvoch backendov privátneho kľúča: CNG, ktorý vráti podpis RSA vo formáte big-endian, alebo staršie CryptoAPI CSP, ktoré ho vráti vo formáte little-endian. Ak si tieto dva pomýlite, CMS podpis, ktorý HotPDF vloží do dokumentu, bude mať prehodené bajty podľa toho, ktorý backend v skutočnosti odpovedal, takže validátor rešpektujúci normu ohlási podpis ako neplatný, hoci sa bajty dokumentu vôbec nezmenili

Za touto jednou vetou sa skrývajú dva nesúvisiace problémy a signer systémových certifikátov v HotPDF musí vyriešiť oba skôr, než čokoľvek podpíše. Nesúlad v poradí bajtov je tichý: volanie na podpis stále vráti True, PDF sa stále otvorí a chyba sa prejaví až vo chvíli, keď prehliadač prejde štruktúru CMS a odmietne ju. Druhý problém je hlučný a špecifický pre C++Builder: pol tucta funkcií z crypt32 sa odmietne zlinkovať, pretože importná knižnica dodávaná s RAD Studiom ich neexportuje. Ani jeden z týchto problémov sa neprejaví, ak podpisujete výhradne súborom PFX, čo je dôvod, prečo tento problém zvyčajne zasiahne vývojárov, ktorí prechádzajú od jednorazového podpisovania pomocou PFX k certifikátu, ktorý už IT oddelenie nainštalovalo do profilu používateľa

Výber certifikátu z úložiska

HotPDF sprístupňuje túto cestu ako HPDFSignPDFStreamWithSystemCertificate a HPDFSignPDFFileWithSystemCertificate, obe riadené záznamom THPDFCertificateStoreSelector: Location (cslCurrentUser alebo cslLocalMachine), StoreName (predvolene 'MY', osobné úložisko), odtlačok SHA-1 Thumbprint a príznak AllowUI. Odtlačok sa interne normalizuje, takže pomlčky alebo medzery skopírované priamo z rozhrania Správcu certifikátov sa pred porovnaním odstránia

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 má väčší význam, než sa zdá, pretože sa priamo mapuje na CRYPT_ACQUIRE_SILENT_FLAG a Windows to berie doslovne: ak privátny kľúč zodpovedajúceho certifikátu leží na čipovej karte alebo tokene, ktorý vyžaduje zadanie PIN, ktorý Windows ešte nemá uložený vo vyrovnávacej pamäti, CryptAcquireCertificatePrivateKey zlyhá namiesto toho, aby zobrazil dialóg z procesu, ktorý môže byť službou. Toto zlyhanie je hlučné, ide o EHPDFCMSError, ktorý uvidíte okamžite, no ľahko sa zle interpretuje ako „certifikát sa nenašiel“, hoci skutočnou príčinou je token čakajúci na PIN, ktorý nikto nezadá

Prečo si CNG a CAPI protirečia v poradí bajtov?

To, ktorý backend odpovie, nie je náhoda: CryptAcquireCertificatePrivateKey to priamo hlási cez výstupný parameter KeySpec, a práve podľa tejto jedinej hodnoty sa signer v HotPDF rozhoduje. Kľúč z CNG Key Storage Provider sa vráti s hodnotou KeySpec nastavenou na strážnu hodnotu CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); čokoľvek iné je tradičný kľúč CryptoAPI CSP. Väčšina osobných certifikátov vydaných alebo importovaných na aktuálnej inštalácii Windows sa vyrieši ako CNG, hoci kvôli kompatibilite stále existuje aj staršia CSP vrstva, a preto HotPDF žiada CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG spolu s CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG ešte predtým, než sa pozrie, ktorá hodnota sa vrátila

Oba backendy nielenže volajú odlišné funkcie, NCryptSignHash pre kľúč CNG, CryptSignHashA pre kľúč CSP, ale aj vracajú surový podpis RSA v opačnom poradí bajtov. Výstup z CNG už zodpovedá tomu, čo očakáva PKCS#1: reťazec oktetov vo formáte big-endian, najvýznamnejší bajt prvý, presne to, čo produkuje konverzia I2OSP z RFC 8017 a čo potrebuje pole podpisu v CMS SignerInfo (RFC 5652) podľa ISO 32000-1 §12.8.3. Naproti tomu CryptSignHash z CryptoAPI vracia podpis vo formáte little-endian, čo je zdokumentovaná zvláštnosť siahajúca až k tomu, ako staršie CSP interne reprezentovali veľké čísla. Ak na ceste cez CAPI vynecháte otočenie poradia, každý bajt podpisu skončí na nesprávnom mieste; matematika RSA je stále v poriadku, ale reťazec oktetov, ktorý číta overovateľ, už nie je ten, ktorý definuje 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 v prípade vlastného callbacku na podpisovanie?

Každý, kto obíde vstavaný signer certifikátov z úložiska v HotPDF, dedí to isté pravidlo o poradí bajtov. HPDFCMSSignPDFStreamWithExternalSigner prijíma THPDFCMSSignDigestCallback, uzáver typu reference to function(const SignedAttributesSHA256: TBytes): TBytes, na podpisovanie cez HSM, middleware čipovej karty alebo čokoľvek iné, čo nie je certifikát, na ktorý vám úložisko Windows dokáže odovzdať handle kľúča. Nech je backend za týmto callbackom akýkoľvek, bajty, ktoré vráti, musia byť pred tým, ako ich HotPDF vloží do štruktúry CMS, v poradí big-endian

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

Tu stojí za to jasne pomenovať jednu hranicu: obe vstavané cesty podpisovania v HotPDF, CNG cez NCryptSignHash s výplňou PKCS#1 a CAPI cez CryptSignHashA, cielia na kľúče RSA podpisujúce 32-bajtový digest SHA-256. Ani jedna z nich nevyjednáva formát podpisu ECDSA. Certifikát, ktorého privátny kľúč je založený na eliptických krivkách, potrebuje signer, ktorý si napíšete sami cez HPDFCMSSignPDFStreamWithExternalSigner, s kódovaním podpisu ECDSA tak, ako to CMS očakáva, namiesto predpokladu pevne dlhého reťazca bajtov RSA, takže od vstavaného signera certifikátov z úložiska nečakajte, že sa správne postará o token s certifikátom EC

Prečo sa C++Builderu nedarí zlinkovať CertOpenStore?

Pretože predvolená importná knižnica C++Builderu v RAD Studiu, import32.lib, neexportuje CertOpenStore ani päť jej susedov: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore a CryptAcquireCertificatePrivateKey. Zostavenia v Delphi tento problém nikdy nevidia, pretože dcc32/dcc64 rozriešia statický import external 'crypt32.dll' priamo do tabuľky importov PE. C++Builder je iný: kompilátor Delphi vygeneruje pre zostavenie balíčka objekt OMF .obj, zlinkuje ho ilink32 a v tej chvíli je rovnaké vyhlásenie external už len nevyriešeným symbolom čakajúcim na importnú knižnicu na príkazovom riadku. Ani nasmerovanie linkera na adresár psdk Windows SDK, kde plná crypt32.lib naozaj exportuje všetkých šesť symbolov, to nevyrieši: ilink32 linkuje iba importné knižnice skutočne uvedené na svojom príkazovom riadku, predvolene import32.lib cp32mt.lib, a pridanie vyhľadávacej cesty nespôsobí, že z nej niečo naviac natiahne. Spustenie tdump nad import32.lib túto medzeru priamo potvrdzuje, nula zásahov pre CertOpenStore oproti šiestim čistým zásahom v crypt32.lib zo SDK

HotPDF to rieši rovnako, ako už inde v knižnici rieši enumeráciu certifikátov: namiesto toho, aby tieto symboly žiadal od linkera, ich načítava za behu. Interný záznam THPDFCryptoProcs nesie handle crypt32.dll, handle advapi32.dll a jedenásť polí ukazovateľov na funkcie; LoadCryptoProcs načíta obe DLL a presne raz, na začiatku HPDFSignPDFStreamWithSystemCertificate, pomocou GetProcAddress vyrieši každý vstupný bod, pričom okamžite vyvolá EHPDFCMSError, ak čokoľvek chýba, namiesto toho, aby zlyhal neskôr access violation hlboko vnútri toku podpisovania

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;

Načítanie prebehne raz za volanie, nie lenivo v každej pomocnej funkcii, pretože uzáver, ktorý vyberá medzi CNG a CAPI, zachytáva načítanú tabuľku funkcií hodnotou a musí zostať platný počas celého toku podpisovania vrátane callbacku do HPDFCMSSignPDFStreamWithExternalSigner; oba handle DLL sa uvoľnia v najvonkajšom bloku finally hneď po tom, ako sa podpisovanie skončí alebo vyvolá výnimku. Nič z toho sa nedotýka verejného rozhrania: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate a THPDFCertificateStoreSelector si zachovávajú presne rovnaké signatúry ako predtým, takže prevzatie opravy pre existujúcich volajúcich znamená len prekompilovanie, nie zmenu kódu

Čo táto oprava nepokrýva

Správne poradie bajtov a správne zlinkovanie pre C++Builder zaistí CMS SignerInfo, ktorý validátor dokáže rozobrať a ktorého podpis vie aritmeticky overiť; nič to nehovorí o tom, či by validátor mal certifikátu za ním dôverovať, keďže budovanie reťazca certifikátov, kontrola odvolania a politika časovej pečiatky sú samostatné záležitosti nadstavené cez voľby CMS, nie niečo, čo automaticky získate správnym poradím bajtov. Dve administratívne podrobnosti sú rovnako dôležité ako kryptografia: PCCERT_CONTEXT vrátený vyhľadaním certifikátu sa musí uvoľniť pomocou CertFreeCertificateContext ešte pred zatvorením úložiska a získaný handle kľúča CNG alebo CSP, ak API hlási, že ho vlastní volajúci, sa musí uvoľniť cez vlastné volanie príslušného backendu, nikdy cez to druhé. Ak sa vám výsledok svValid, ktorý po tomto všetkom dostanete, javí užší, než ste čakali, článok o overovaní digitálnych podpisov PDF presne rozoberá, čo tento príznak sľubuje a čo nie. Keďže certifikát tu zostáva po celý čas v opatere Windows, podpisovanie z úložiska sa vyhýba celej ploche útoku: nie je potrebné parsovať súbor PKCS#12 ani si sami prechádzať ASN.1, čo je problém, ktorý pre cestu podpisovania súborom PFX namiesto toho rieši spevnenie PKCS#12 a ASN.1 v HotPDF

Podpisovanie z úložiska certifikátov, podpisovanie PFX a callbacky externého signera sú tri dvere do rovnakej pipeline CMS/PKCS#7 vnútri komponentu HotPDF PDF pre Delphi a C++Builder, a výber tej správnej sa v podstate zvrháva na otázku, kto smie držať privátny kľúč: váš proces, súbor PFX, alebo samotný Windows