Technický článek

HotPDF Cert Store PDF Signing: CNG vs CAPI pořadí bajtů

HotPDF podepisuje PDF pomocí certifikátu, který už leží v úložišti certifikátů Windows, tak, že předá digest samotnému Windows, a Windows tento požadavek dokončí přes jeden ze dvou backendů privátního klíče: buď CNG, který vrátí RSA podpis big-endian, nebo starší CryptoAPI CSP, který jej vrátí little-endian. Pokud se tyto dva případy zamění, je CMS podpis, který HotPDF vloží, přesně pro ten backend, který odpověděl, přehozený po bajtech — takže validátor odpovídající standardu ohlásí podpis jako neplatný, přestože se bajtů dokumentu nikdo ani nedotkl

Za touto jednou větou se skrývají dva nesouvisející problémy a signer HotPDF pro systémové certifikáty musí vyřešit oba dřív, než vůbec něco podepíše. Chyba v pořadí bajtů je tichá: volání podpisu stále vrátí True, PDF se stále otevře a selhání se projeví až ve chvíli, kdy prohlížeč projde strukturu CMS a podpis odmítne. Druhý problém je hlasitý a týká se konkrétně C++Builderu: půl tuctu funkcí z crypt32 odmítá slinkovat, protože import knihovna, kterou RAD Studio dodává, je neexportuje. Žádný z těchto problémů nenastane, pokud podepisujete jen pomocí PFX souboru, což je důvod, proč obvykle zaskočí vývojáře přecházející z podepisování jedním voláním na základě PFX na certifikát, který IT oddělení už nainstalovalo do profilu uživatele

Výběr certifikátu z úložiště

HotPDF tuto cestu vystavuje jako HPDFSignPDFStreamWithSystemCertificate a HPDFSignPDFFileWithSystemCertificate, obě řízené záznamem THPDFCertificateStoreSelector: Location (cslCurrentUser nebo cslLocalMachine), StoreName (výchozí je 'MY', osobní úložiště), otisk SHA-1 Thumbprint a příznak AllowUI. Otisk se interně normalizuje, takže spojovníky nebo mezery zkopírované přímo z uživatelského rozhraní Správce certifikátů se před porovnáním odstraní

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 znamená víc, než by se na první pohled zdálo, protože se mapuje přímo na CRYPT_ACQUIRE_SILENT_FLAG a Windows to bere doslova: pokud privátní klíč odpovídajícího certifikátu leží na čipové kartě nebo tokenu, který vyžaduje PIN, jenž Windows ještě nemá uložený v mezipaměti, CryptAcquireCertificatePrivateKey selže, místo aby vyskočilo dialogové okno z toho, co může být servisní proces. Toto selhání je hlasité — okamžitě uvidíte EHPDFCMSError — ale snadno se zaměnitelně vyloží jako „certifikát nenalezen", zatímco skutečnou příčinou je token čekající na PIN, který nikdo nezadá

Proč se CNG a CAPI liší v pořadí bajtů?

Který backend odpoví, není otázka hádání: CryptAcquireCertificatePrivateKey to hlásí přímo přes výstupní parametr KeySpec, a právě podle této jedné hodnoty se signer HotPDF rozhoduje. Klíč od CNG Key Storage Provider se vrátí s KeySpec nastaveným na sentinelovou hodnotu CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); cokoli jiného je tradiční klíč CryptoAPI CSP. Většina osobních certifikátů vydaných nebo importovaných na aktuální instalaci Windows se překlopí na CNG, přestože kvůli kompatibilitě stále existuje i vrstva pro starší CSP — proto HotPDF požaduje CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG spolu s CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG ještě předtím, než se podívá, jaká hodnota se vrátila

Oba backendy nejenže volají jiné funkce — NCryptSignHash pro klíč CNG, CryptSignHashA pro klíč CSP — ale navíc vracejí surový RSA podpis v opačném pořadí bajtů. Výstup CNG už odpovídá tomu, co očekává PKCS#1: big-endian oktetový řetězec, nejvýznamnější bajt první, přesně to, co produkuje konverze I2OSP z RFC 8017 a co potřebuje pole podpisu v CMS SignerInfo (RFC 5652) podle ISO 32000-1 §12.8.3. Naproti tomu CryptSignHash z CryptoAPI vrací podpis little-endian — zdokumentovaná zvláštnost, která sahá až k tomu, jak klasické CSP interně reprezentovaly velká čísla. Pokud na cestě přes CAPI vynecháte obrácení pořadí, ocitne se každý bajt podpisu na špatném místě; RSA matematika je pořád správná, ale oktetový řetězec, který čte ověřovatel, už neodpovídá tomu, co 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;

Co vlastní zpětné volání pro podepisování?

Kdo obejde vestavěný signer HotPDF pro úložiště certifikátů, zdědí stejné pravidlo o pořadí bajtů. HPDFCMSSignPDFStreamWithExternalSigner přebírá THPDFCMSSignDigestCallback, closure typu reference to function(const SignedAttributesSHA256: TBytes): TBytes, pro podepisování přes HSM, middleware čipové karty nebo cokoli jiného, co není certifikát, ke kterému by vám úložiště Windows dokázalo dodat handle klíče. Ať už za tímto zpětným voláním stojí jakýkoli backend, bajty, které vrátí, musí být v pořadí big-endian dřív, než je HotPDF vloží do struktury CMS

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

Stojí za to zde jasně pojmenovat jednu hranici: obě vestavěné cesty podepisování HotPDF — CNG přes NCryptSignHash s paddingem PKCS#1 a CAPI přes CryptSignHashA — cílí na RSA klíče podepisující 32bajtový SHA-256 digest. Žádná z nich nevyjednává formát podpisu ECDSA. Certifikát, jehož privátní klíč je založen na eliptické křivce, potřebuje signer, který si napíšete sami proti HPDFCMSSignPDFStreamWithExternalSigner, a v něm zakódujete ECDSA podpis tak, jak jej očekává CMS, místo abyste předpokládali pevně dlouhý RSA bajtový řetězec — nečekejte tedy, že vestavěný signer pro úložiště certifikátů udělá správnou věc pro token s certifikátem EC

Proč se C++Builderu nedaří slinkovat CertOpenStore?

Protože výchozí import knihovna C++Builderu z RAD Studia, import32.lib, neexportuje CertOpenStore ani pět jejích sousedů: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore a CryptAcquireCertificatePrivateKey. Sestavení v Delphi tento problém nikdy nepotkají, protože dcc32/dcc64 vyřeší statický import external 'crypt32.dll' přímo do tabulky importů PE. C++Builder je jiný: Delphi kompilátor pro sestavení balíčku vygeneruje OMF .obj, ten pak slinkuje ilink32, a v tu chvíli je stejná deklarace external jen nerozřešeným symbolem čekajícím na import knihovnu na příkazové řádce. Ani nasměrování linkeru na adresář psdk ve Windows SDK, kde plná crypt32.lib skutečně exportuje všech šest symbolů, to nevyřeší: ilink32 linkuje jen import knihovny skutečně uvedené na příkazové řádce, ve výchozím stavu import32.lib cp32mt.lib, a přidání vyhledávací cesty nezpůsobí, že by si odtud přitáhl cokoli navíc. Spuštění tdump nad import32.lib tuto mezeru potvrdí přímo — nula zásahů pro CertOpenStore proti šesti čistým zásahům v crypt32.lib ze SDK

HotPDF to řeší stejně, jako už jinde v knihovně řeší výčet certifikátů: místo aby o tyto symboly žádal linker, načte je za běhu. Interní záznam THPDFCryptoProcs nese handle crypt32.dll, handle advapi32.dll a jedenáct polí ukazatelů na funkce; LoadCryptoProcs načte obě DLL a přesně jednou přeloží každý vstupní bod přes GetProcAddress, hned na začátku HPDFSignPDFStreamWithSystemCertificate, a pokud cokoli chybí, okamžitě vyvolá EHPDFCMSError, místo aby selhalo později s access violation hluboko uvnitř průběhu podepisování

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čítání proběhne jednou za volání, nikoli líně uvnitř každého pomocníka, protože closure, která vybírá mezi CNG a CAPI, zachytává načtenou tabulku funkcí podle hodnoty a musí zůstat naživu po celý průběh podepisování, včetně zpětného volání do HPDFCMSSignPDFStreamWithExternalSigner; oba handly DLL se uvolní v nejvzdálenějším bloku finally, jakmile podepisování skončí nebo vyvolá výjimku. Nic z toho se nedotýká veřejného rozhraní: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate a THPDFCertificateStoreSelector si zachovávají přesně stejné signatury jako předtím, takže pro stávající volající znamená převzetí opravy jen nové sestavení, nikoli změnu kódu

Co tento článek nepokrývá

Správné pořadí bajtů a funkční linkování v C++Builderu vyprodukuje CMS SignerInfo, který dokáže validátor rozebrat, a podpis, který dokáže aritmeticky ověřit; to ale neříká nic o tom, zda by validátor měl důvěřovat certifikátu za ním, protože budování řetězu důvěry, kontrola revokace a politika časového razítka jsou samostatné záležitosti vrstvené navrch přes volby CMS — správnost pořadí bajtů vám je nekoupí zdarma. Stejně důležité jako kryptografie jsou dva úkoly správy zdrojů: PCCERT_CONTEXT vrácený vyhledáním certifikátu se musí uvolnit přes CertFreeCertificateContext ještě před uzavřením úložiště, a získaný handle klíče CNG nebo CSP, pokud API hlásí, že jej vlastní volající, se musí uvolnit přes vlastní volání odpovídajícího backendu, nikdy přes to druhé. Pokud se výsledek svValid, který po tom všem dostanete zpět, ukáže užší, než jste čekali, článek o ověřování digitálních podpisů PDF přesně popisuje, co tento příznak slibuje a co ne. Protože certifikát po celou dobu zůstává ve správě Windows, podepisování z úložiště certifikátů obchází celou plochu útoku: není zde žádný soubor PKCS#12 k rozboru a žádné ASN.1, kterým byste museli sami procházet — což je problém, který pro cestu podepisování přes soubor PFX řeší zpevnění HotPDF pro PKCS#12 a ASN.1

Podepisování z úložiště certifikátů, podepisování PFX a zpětná volání externího signeru jsou tři dveře do stejné pipeline CMS/PKCS#7 uvnitř komponenty HotPDF pro PDF pro Delphi a C++Builder, a výběr té správné se z velké části odvíjí od toho, kdo smí držet privátní klíč: váš proces, soubor PFX, nebo samotný Windows