HotPDF подписва PDF с сертификат, който вече се намира в Windows Certificate Store, като предава дайджеста на самия Windows, а Windows изпълнява заявката чрез един от двата backend-а за частен ключ: CNG, който връща RSA подписа в big-endian, или legacy CryptoAPI CSP, който го връща в little-endian. Ако двата backend-а бъдат объркани, CMS подписът, вграден от HotPDF, се оказва с разменен ред на байтовете спрямо реално отговорилия backend, затова съвместим валидатор го отчита като невалиден, въпреки че байтовете на документа изобщо не са променяни
Зад това едно изречение се крият два несвързани проблема и signer-ът на HotPDF за сертификати от системния store трябва да реши и двата, преди изобщо да подпише нещо. Несъответствието в byte order е тихо: извикването за подписване все още връща True, PDF файлът се отваря, а грешката се проявява едва когато viewer обходи CMS структурата и я отхвърли. Вторият проблем е явен и специфичен за C++Builder: около половин дузина функции от crypt32 отказват да се свържат, защото import library-то, което се доставя с RAD Studio, не ги експортира. Нито един от двата проблема не съществува, ако подписвате само с PFX файл, затова те често хващат разработчиците при преминаване от еднократно подписване с PFX към сертификат, който IT отделът вече е инсталирал в профила на потребителя
Избиране на сертификат от хранилището
HotPDF предоставя този път чрез HPDFSignPDFStreamWithSystemCertificate и HPDFSignPDFFileWithSystemCertificate, като и двата използват запис THPDFCertificateStoreSelector: Location (cslCurrentUser или cslLocalMachine), StoreName ('MY', личното хранилище по подразбиране), SHA-1 Thumbprint и флаг AllowUI. Отпечатъкът се нормализира вътрешно, затова тирета или интервали, копирани директно от интерфейса на Certificate Manager, се премахват преди сравнението
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 е по-важен, отколкото изглежда, защото директно се преобразува в CRYPT_ACQUIRE_SILENT_FLAG и Windows спазва това буквално: ако частният ключ на намерения сертификат се намира в smart card или token, който изисква PIN, а Windows още не го е кеширал, CryptAcquireCertificatePrivateKey се проваля, вместо да покаже диалог от процес, който може да е service процес. Тази грешка е явна — веднага виждате EHPDFCMSError — но лесно се разбира погрешно като „сертификатът не е намерен“, когато истинската причина е token, който чака PIN, който никой няма да въведе
Защо CNG и CAPI използват различен byte order?
Кой backend отговаря не се предполага: CryptAcquireCertificatePrivateKey го съобщава директно чрез изходния параметър KeySpec, а именно тази стойност определя разклонението в signer-а на HotPDF. Ключ от CNG Key Storage Provider се връща с KeySpec, зададен на sentinel CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); всяка друга стойност означава традиционен CryptoAPI CSP ключ. Повечето лични сертификати, издадени или импортирани в актуална инсталация на Windows, се разрешават към CNG, въпреки че за съвместимост все още съществува legacy CSP shim, затова HotPDF задава CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG заедно с CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG, преди да провери коя стойност е получена
Двата backend-а не извикват само различни функции — NCryptSignHash за CNG ключ и CryptSignHashA за CSP ключ — а връщат необработения RSA подпис в противоположен ред на байтовете. Изходът на CNG вече съответства на изискването на PKCS#1: big-endian octet string с най-значимия байт първи, точно както го създава преобразуването I2OSP от RFC 8017 и както го изисква CMS SignerInfo (RFC 5652) в полето за подпис съгласно ISO 32000-1 §12.8.3. За разлика от него CryptoAPI връща подписа little-endian, документирана особеност, произлязла от начина, по който класическите CSP представят големи числа вътрешно. Ако пропуснете обръщането по CAPI пътя, всеки байт в подписа е на грешното място; RSA математиката остава правилна, но octet string-ът, който валидаторът прочита, не е определеният от 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;
Какво трябва да прави custom signer callback?
Всеки, който заобикаля вградения cert-store signer на HotPDF, наследява същото правило за реда на байтовете. HPDFCMSSignPDFStreamWithExternalSigner приема THPDFCMSSignDigestCallback, closure от тип reference to function(const SignedAttributesSHA256: TBytes): TBytes, за подписване чрез HSM, middleware за smart card или друг механизъм, който не е сертификат, за който Windows store може да предостави handle към ключ. Независимо кой backend стои зад този callback, върнатите от него байтове трябва да бъдат big-endian, преди HotPDF да ги вгради в 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);
Тук е важно да се изясни една граница: двата вградени пътя за подписване на HotPDF, CNG чрез NCryptSignHash с PKCS#1 padding и CAPI чрез CryptSignHashA, са насочени към RSA ключове, които подписват 32-байтов SHA-256 digest. Нито един от тях не договаря формат за ECDSA подпис. Сертификат с EC частен ключ изисква signer, който да напишете сами върху HPDFCMSSignPDFStreamWithExternalSigner, като кодирате ECDSA подписа по начина, очакван от CMS, вместо да приемате фиксиран по дължина RSA byte string, затова не очаквайте вграденият cert-store signer да работи правилно за token, конфигуриран с EC сертификат
Защо C++Builder не намира CertOpenStore?
Причината е, че стандартната import library на C++Builder от RAD Studio, import32.lib, не експортира CertOpenStore или пет от съседните функции: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore и CryptAcquireCertificatePrivateKey. Delphi build-овете не виждат този проблем, защото dcc32/dcc64 разрешават статичен импорт external 'crypt32.dll' директно в PE import table. C++Builder е различен: Delphi компилаторът генерира OMF .obj за build-а на пакета, ilink32 го свързва, а на този етап същата декларация external е само нерешен символ, който чака import library в командния ред. Посочването на директорията psdk от Windows SDK, където пълната crypt32.lib експортира и шестте символа, също не решава проблема: ilink32 свързва само import library-тата, изрично посочени в командния ред, по подразбиране import32.lib cp32mt.lib, а добавянето на search path не го кара да зарежда нещо допълнително от тази директория. Изпълнението на tdump върху import32.lib потвърждава разликата директно: няма съвпадения за CertOpenStore, срещу шест ясни съвпадения в crypt32.lib от SDK
HotPDF разрешава това по същия начин, по който вече обработва изброяването на сертификати другаде в библиотеката: вместо да иска тези символи от linker-а, ги зарежда по време на изпълнение. Вътрешен запис THPDFCryptoProcs съдържа handle към crypt32.dll, handle към advapi32.dll и единадесет полета с function pointer-и; LoadCryptoProcs зарежда двете DLL-и и разрешава всяка entry point функция чрез GetProcAddress точно веднъж в началото на HPDFSignPDFStreamWithSystemCertificate, като повдига EHPDFCMSError веднага при липса на нещо, вместо по-късно да се стига до access violation дълбоко в потока на подписване
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;
Зареждането се извършва веднъж при всяко извикване, а не лениво във всеки helper, защото closure-ът, който избира между CNG и CAPI, улавя заредената таблица с function pointers по стойност и трябва да остане активен през целия поток на подписване, включително callback-а към HPDFCMSSignPDFStreamWithExternalSigner; и двата DLL handle-а се освобождават във външния finally блок, когато подписването завърши или предизвика изключение. Нищо от това не засяга публичния интерфейс: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate и THPDFCertificateStoreSelector запазват точните си сигнатури, така че прилагането на поправката за съществуващи извикващи приложения означава rebuild, а не промяна в кода
Какво не покрива тази поправка?
Правилният byte order и коректното свързване в C++Builder създават CMS SignerInfo, който валидаторът може да анализира, и подпис, който може да провери аритметично; това не казва нищо дали валидаторът трябва да се довери на сертификата зад него, защото chain building, revocation checking и timestamp policy са отделни въпроси, добавени чрез CMS options, а коректният byte order не ги решава автоматично. Две подробности по управлението на ресурсите са също толкова важни, колкото и криптографията: полученият при търсене на сертификат PCCERT_CONTEXT трябва да се освободи с CertFreeCertificateContext, преди store-ът да бъде затворен, а получен CNG или CSP key handle, когато API съобщи, че отговорността е на извикващия, трябва да се освободи чрез функцията на съответния backend, а не чрез функцията на другия. Ако резултатът svValid, който получавате след всичко това, се окаже по-ограничен, отколкото сте очаквали, статията за проверка на PDF подписи описва точно какво обещава и какво не обещава този флаг. Тъй като сертификатът остава под управлението на Windows през цялото време, cert-store подписването заобикаля цяла повърхност за атаки: няма PKCS#12 файл за анализиране и няма ASN.1, който да обхождате сами, а именно този проблем разглежда статията на HotPDF за втвърдяване на PKCS#12 и ASN.1 при пътя за подписване с PFX
Cert-store подписването, PFX подписването и callback-ите на external signer са три входа към един и същ CMS/PKCS#7 pipeline в HotPDF PDF component за Delphi и C++Builder, а изборът обикновено зависи от това кой има право да държи частния ключ: вашият процес, PFX файлът или самият Windows