HotPDF podpisuje PDF certyfikatem znajdującym się już w magazynie certyfikatów Windows, przekazując skrót (digest) samemu systemowi Windows, a Windows realizuje to żądanie przez jeden z dwóch backendów kluczy prywatnych: CNG, który zwraca podpis RSA w kolejności big-endian, albo starszy CryptoAPI CSP, który zwraca go w kolejności little-endian. Pomylenie tych dwóch przypadków sprawia, że osadzany przez HotPDF podpis CMS ma bajty odwrócone względem tego, który backend faktycznie odpowiedział, więc zgodny z normą walidator zgłasza podpis jako nieprawidłowy, mimo że bajty samego dokumentu nigdy nie zostały naruszone
Za tym jednym zdaniem kryją się dwa niezależne od siebie problemy, które moduł podpisujący certyfikatem systemowym w HotPDF musi rozwiązać, zanim w ogóle cokolwiek podpisze. Niezgodność kolejności bajtów jest cicha: wywołanie podpisujące nadal zwraca True, PDF nadal się otwiera, a błąd ujawnia się dopiero, gdy przeglądarka PDF przejdzie strukturę CMS i ją odrzuci. Drugi problem jest głośny i dotyczy wyłącznie C++Buildera: pół tuzina funkcji z crypt32 odmawia linkowania, ponieważ biblioteka importu dostarczana przez RAD Studio ich nie eksportuje. Żaden z tych problemów nie występuje, jeśli podpisuje się wyłącznie plikiem PFX, dlatego zwykle łapie on programistów przechodzących z jednowywołaniowego podpisywania opartego na PFX na certyfikat, który dział IT zainstalował już w profilu użytkownika
Wybór certyfikatu z magazynu
HotPDF udostępnia tę ścieżkę jako HPDFSignPDFStreamWithSystemCertificate i HPDFSignPDFFileWithSystemCertificate, obie sterowane rekordem THPDFCertificateStoreSelector: Location (cslCurrentUser lub cslLocalMachine), StoreName (domyślnie 'MY', magazyn osobisty), odcisk SHA-1 Thumbprint oraz flaga AllowUI. Odcisk palca jest normalizowany wewnętrznie, więc myślniki lub spacje skopiowane bezpośrednio z interfejsu Menedżera certyfikatów są usuwane przed porównaniem
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 ma większe znaczenie, niż mogłoby się wydawać, ponieważ mapuje się bezpośrednio na CRYPT_ACQUIRE_SILENT_FLAG, a Windows traktuje to dosłownie: jeśli klucz prywatny dopasowanego certyfikatu znajduje się na karcie inteligentnej lub tokenie wymagającym podania PIN-u, którego Windows jeszcze nie zapamiętał, CryptAcquireCertificatePrivateKey kończy się błędem zamiast wyświetlić okno dialogowe z poziomu procesu, który może być usługą działającą w tle. Ten błąd jest głośny, natychmiast widoczny jako EHPDFCMSError, ale łatwo go błędnie odczytać jako „certyfikat nie znaleziony", podczas gdy prawdziwą przyczyną jest token czekający na PIN, którego nikt nie zamierza wpisać
Dlaczego CNG i CAPI różnią się co do kolejności bajtów?
To, który backend odpowiada, nie jest kwestią domysłów: CryptAcquireCertificatePrivateKey zgłasza to bezpośrednio przez parametr wyjściowy KeySpec, i właśnie od tej jednej wartości zależy rozgałęzienie modułu podpisującego HotPDF. Klucz z dostawcy magazynu kluczy CNG (Key Storage Provider) wraca z KeySpec ustawionym na wartość wartowniczą CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); każda inna wartość oznacza tradycyjny klucz CryptoAPI CSP. Większość certyfikatów osobistych wydanych lub zaimportowanych na aktualnej instalacji Windows rozwiązuje się do CNG, mimo że warstwa zgodności ze starszym CSP nadal istnieje, dlatego HotPDF żąda CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG razem z CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG, zanim jeszcze sprawdzi, jaka wartość wróciła
Oba backendy nie tylko wywołują różne funkcje — NCryptSignHash dla klucza CNG, CryptSignHashA dla klucza CSP — lecz zwracają surowy podpis RSA w przeciwnej kolejności bajtów. Wynik z CNG jest już zgodny z tym, czego oczekuje PKCS#1: łańcuch oktetów big-endian, z najbardziej znaczącym bajtem na początku, dokładnie to, co daje konwersja I2OSP z RFC 8017 i czego wymaga pole podpisu w strukturze SignerInfo CMS (RFC 5652) zgodnie z ISO 32000-1 §12.8.3. Natomiast CryptSignHash z CryptoAPI zwraca podpis w kolejności little-endian — udokumentowana osobliwość wywodząca się z tego, jak klasyczne CSP reprezentowały wewnętrznie duże liczby. Pominięcie odwrócenia bajtów na ścieżce CAPI sprawia, że każdy bajt podpisu ląduje w złym miejscu; matematyka RSA nadal jest poprawna, ale łańcuch oktetów odczytywany przez weryfikator nie jest tym, który definiuje 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;
A co z niestandardowym callbackiem podpisującym?
Każdy, kto omija wbudowany moduł podpisujący certyfikatem z magazynu HotPDF, dziedziczy tę samą zasadę dotyczącą kolejności bajtów. HPDFCMSSignPDFStreamWithExternalSigner przyjmuje THPDFCMSSignDigestCallback, domknięcie typu reference to function(const SignedAttributesSHA256: TBytes): TBytes, do podpisywania przez HSM, stos oprogramowania pośredniczącego karty inteligentnej lub cokolwiek innego, co nie jest certyfikatem, dla którego magazyn Windows może wydać uchwyt klucza. Niezależnie od tego, jaki backend stoi za tym callbackiem, zwracane przez niego bajty muszą trafić w kolejności big-endian, zanim HotPDF włączy je 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);
Warto wprost wskazać tu granicę: dwie wbudowane ścieżki podpisywania HotPDF — CNG przez NCryptSignHash z wypełnieniem PKCS#1 oraz CAPI przez CryptSignHashA — obsługują klucze RSA podpisujące 32-bajtowy skrót SHA-256. Żadna z nich nie negocjuje formatu podpisu ECDSA. Certyfikat, którego klucz prywatny jest oparty na krzywych eliptycznych, wymaga podpisywacza napisanego samodzielnie na bazie HPDFCMSSignPDFStreamWithExternalSigner, kodującego podpis ECDSA tak, jak oczekuje tego CMS, a nie zakładającego łańcuch bajtów RSA o stałej długości — nie oczekuj więc, że wbudowany moduł podpisujący z magazynu certyfikatów zadziała poprawnie dla tokena z certyfikatem EC
Dlaczego C++Builder nie linkuje CertOpenStore?
Ponieważ domyślna biblioteka importu C++Buildera z RAD Studio, import32.lib, nie eksportuje CertOpenStore ani pięciu jej sąsiadów: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore i CryptAcquireCertificatePrivateKey. Kompilacje w Delphi nigdy tego nie widzą, ponieważ dcc32/dcc64 rozwiązują statyczny import external 'crypt32.dll' bezpośrednio do tabeli importów PE. C++Builder działa inaczej: kompilator Delphi generuje plik OMF .obj dla kompilacji pakietu, ilink32 go linkuje, a w tym momencie ta sama deklaracja external to po prostu nierozwiązany symbol czekający na bibliotekę importu w linii poleceń. Wskazanie linkerowi katalogu psdk z Windows SDK, gdzie pełna crypt32.lib eksportuje wszystkie sześć symboli, też tego nie naprawia: ilink32 linkuje wyłącznie biblioteki importu jawnie wymienione w linii poleceń, domyślnie import32.lib cp32mt.lib, a dodanie ścieżki wyszukiwania nie sprawia, że pobierze cokolwiek dodatkowego z tego katalogu. Uruchomienie tdump na import32.lib bezpośrednio potwierdza tę lukę: zero trafień dla CertOpenStore, wobec sześciu czystych trafień w crypt32.lib z SDK
HotPDF rozwiązuje to tak samo, jak już obsługuje enumerację certyfikatów gdzie indziej w bibliotece: zamiast prosić linker o te symbole, wczytuje je w czasie działania programu. Wewnętrzny rekord THPDFCryptoProcs przechowuje uchwyt crypt32.dll, uchwyt advapi32.dll oraz jedenaście pól wskaźników do funkcji; LoadCryptoProcs ładuje obie biblioteki DLL i rozwiązuje każdy punkt wejścia przez GetProcAddress dokładnie raz, na początku HPDFSignPDFStreamWithSystemCertificate, zgłaszając natychmiast EHPDFCMSError, jeśli czegokolwiek brakuje, zamiast zawieść później naruszeniem dostępu głęboko wewnątrz procesu podpisywania
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;
Wczytywanie następuje raz na wywołanie, a nie leniwie wewnątrz każdej funkcji pomocniczej, ponieważ domknięcie wybierające między CNG a CAPI przechwytuje wczytaną tabelę funkcji przez wartość i musi pozostać żywe przez cały proces podpisywania, w tym przez wywołanie zwrotne do HPDFCMSSignPDFStreamWithExternalSigner; oba uchwyty DLL są zwalniane w najbardziej zewnętrznym bloku finally, gdy podpisywanie się zakończy lub zgłosi wyjątek. Nic z tego nie dotyka publicznej powierzchni API: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate i THPDFCertificateStoreSelector zachowują dokładnie te same sygnatury co wcześniej, więc przyjęcie tej poprawki to dla istniejących wywołań tylko rekompilacja, a nie zmiana kodu
Czego to nie obejmuje
Poprawna kolejność bajtów i zadziałanie linkowania w C++Builderze dają w wyniku strukturę SignerInfo CMS, którą walidator potrafi rozebrać, i podpis, który potrafi zweryfikować arytmetycznie; nie mówi to nic o tym, czy walidator powinien zaufać stojącemu za nim certyfikatowi, ponieważ budowanie łańcucha, sprawdzanie unieważnień i polityka znaczników czasu to osobne kwestie dokładane przez opcje CMS, a nie coś, co poprawność kolejności bajtów daje za darmo. Dwa drobne, ale równie ważne co kryptografia szczegóły porządkowe: PCCERT_CONTEXT zwrócony przez wyszukanie certyfikatu musi zostać zwolniony przez CertFreeCertificateContext, zanim magazyn zostanie zamknięty, a pozyskany uchwyt klucza CNG lub CSP, gdy API zgłasza, że wywołujący jest jego właścicielem, musi zostać zwolniony przez własne wywołanie odpowiedniego backendu, nigdy tego drugiego. Jeśli wynik svValid, który otrzymujesz po tym wszystkim, okaże się węższy, niż się spodziewałeś, artykuł o weryfikacji podpisów cyfrowych PDF dokładnie opisuje, co ta flaga obiecuje, a czego nie. Ponieważ certyfikat przez cały czas pozostaje w gestii Windows, podpisywanie z magazynu certyfikatów omija całą powierzchnię ataku: nie ma tu pliku PKCS#12 do sparsowania ani ASN.1 do samodzielnego przejścia, co jest właśnie problemem, który wzmocnienie PKCS#12 i ASN.1 w HotPDF rozwiązuje dla ścieżki podpisywania plikiem PFX
Podpisywanie z magazynu certyfikatów, podpisywanie PFX i callbacki zewnętrznego podpisywacza to trzy drzwi do tego samego potoku CMS/PKCS#7 wewnątrz komponentu PDF HotPDF dla Delphi i C++Buildera, a wybór właściwych sprowadza się głównie do tego, kto ma prawo przechowywać klucz prywatny: twój proces, plik PFX czy sam Windows