Технічна стаття

HotPDF і сховище сертифікатів: порядок байтів CNG проти CAPI

HotPDF підписує PDF сертифікатом, який уже перебуває у сховищі сертифікатів Windows, передаючи дайджест самій Windows, а та виконує запит через один із двох бекендів приватного ключа: CNG, що повертає RSA-підпис у порядку big-endian, або застарілий CryptoAPI CSP, що повертає його в порядку little-endian. Переплутайте ці два варіанти — і CMS-підпис, який вбудовує HotPDF, виявиться перевернутим побайтово для того бекенда, який фактично відповів, тому валідатор, що відповідає специфікації, повідомить про недійсний підпис, хоча самі байти документа ніхто не чіпав

За цим одним реченням ховаються дві непов'язані проблеми, і підписувач HotPDF, що працює із системними сертифікатами, мусить розв'язати обидві ще до того, як щось підпише. Розбіжність порядку байтів проходить непомітно: виклик підпису все одно повертає True, PDF все одно відкривається, а збій проявляється лише тоді, коли переглядач розбирає структуру CMS і відхиляє її. Друга проблема — гучна і специфічна саме для C++Builder: пів десятка функцій crypt32 відмовляються компонуватися, бо бібліотека імпорту, яку постачає RAD Studio, їх не експортує. Жодної з цих проблем не виникає, якщо підписувати виключно PFX-файлом, тому вони зазвичай застають зненацька розробників, які переходять від підписування одним викликом на основі PFX до сертифіката, який ІТ-відділ уже встановив у профіль користувача

Вибір сертифіката зі сховища

HotPDF відкриває цей шлях через HPDFSignPDFStreamWithSystemCertificate і HPDFSignPDFFileWithSystemCertificate, обидва керуються записом THPDFCertificateStoreSelector: Location (cslCurrentUser або cslLocalMachine), StoreName (за замовчуванням 'MY', особисте сховище), відбиток SHA-1 Thumbprint та прапорець AllowUI. Відбиток нормалізується внутрішньо, тож дефіси чи пробіли, скопійовані прямо з інтерфейсу Диспетчера сертифікатів, видаляються перед порівнянням

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 сприймає це буквально: якщо приватний ключ знайденого сертифіката розташований на смарт-карті чи токені, що потребує вводу PIN-коду, який Windows ще не закешувала, CryptAcquireCertificatePrivateKey завершується помилкою, а не показує діалогове вікно з процесу, який, можливо, є службою. Ця помилка гучна — EHPDFCMSError, яку ви бачите одразу, — але її легко неправильно потрактувати як «сертифікат не знайдено», хоча справжня причина в тому, що токен чекає на PIN-код, який ніхто не збирається вводити

Чому CNG і CAPI розходяться щодо порядку байтів?

Який саме бекенд відповідає — це не здогадка: CryptAcquireCertificatePrivateKey повідомляє про це напряму через вихідний параметр KeySpec, і саме на цьому значенні розгалужується підписувач HotPDF. Ключ постачальника сховища ключів CNG повертається зі значенням KeySpec, встановленим у сигнальне CERT_NCRYPT_KEY_SPEC ($FFFFFFFF); усе інше — це традиційний ключ CryptoAPI CSP. Більшість особистих сертифікатів, виданих або імпортованих на сучасній інсталяції Windows, розв'язуються саме в CNG, хоча застарілий CSP-шим досі існує заради сумісності — саме тому HotPDF запитує CRYPT_ACQUIRE_ALLOW_NCRYPT_KEY_FLAG разом із CRYPT_ACQUIRE_PREFER_NCRYPT_KEY_FLAG, перш ніж дивитися, яке значення повернулося

Два бекенди не просто викликають різні функції — NCryptSignHash для ключа CNG, CryptSignHashA для ключа CSP, — вони повертають сирий RSA-підпис у протилежному порядку байтів. Вихід CNG уже відповідає тому, чого очікує PKCS#1: рядок октетів у форматі big-endian, найзначущий байт першим, саме те, що дає перетворення I2OSP з RFC 8017 і що потрібно полю підпису CMS SignerInfo (RFC 5652) відповідно до ISO 32000-1 §12.8.3. CryptSignHash із CryptoAPI, навпаки, повертає підпис у форматі little-endian — задокументована особливість, що сягає корінням того, як класичні CSP внутрішньо представляли великі числа. Пропустіть перевертання на шляху CAPI — і кожен байт підпису опиниться не на своєму місці; математика RSA залишиться коректною, але рядок октетів, який прочитає верифікатор, не буде тим, що визначає 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;

А що з власним колбеком підписувача?

Будь-хто, хто обходить вбудований підписувач сертифікатного сховища HotPDF, успадковує те саме правило порядку байтів. HPDFCMSSignPDFStreamWithExternalSigner приймає THPDFCMSSignDigestCallback — замикання типу reference to function(const SignedAttributesSHA256: TBytes): TBytes — для підписування через HSM, стек проміжного ПЗ смарт-карти чи будь-що інше, що не є сертифікатом, для якого сховище Windows може видати дескриптор ключа. Який би бекенд не стояв за цим колбеком, байти, які він повертає, мають опинитися в порядку 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 та CAPI через CryptSignHashA — орієнтовані на RSA-ключі, що підписують 32-байтовий дайджест SHA-256. Жоден з них не узгоджує формат підпису ECDSA. Сертифікат із приватним ключем на основі еліптичних кривих потребує підписувача, написаного власноруч на основі HPDFCMSSignPDFStreamWithExternalSigner, який кодує ECDSA-підпис так, як цього очікує CMS, а не припускає рядок байтів фіксованої довжини RSA — тож не очікуйте, що вбудований підписувач сертифікатного сховища зробить усе правильно для токена, налаштованого з EC-сертифікатом

Чому C++Builder не може скомпонувати CertOpenStore?

Тому що типова бібліотека імпорту C++Builder у RAD Studio, import32.lib, не експортує CertOpenStore та ще п'ять сусідніх функцій: CertEnumCertificatesInStore, CertGetCertificateContextProperty, CertFreeCertificateContext, CertCloseStore та CryptAcquireCertificatePrivateKey. Збірки Delphi ніколи цього не бачать, бо dcc32/dcc64 розв'язують статичний імпорт external 'crypt32.dll' прямо в таблицю імпорту PE. C++Builder працює інакше: компілятор Delphi формує OMF-файл .obj для збірки пакета, ilink32 компонує його, і на цьому етапі те саме оголошення external стає просто нерозв'язаним символом, що чекає на бібліотеку імпорту в командному рядку. Вказати компонувальнику на каталог psdk Windows SDK, де повний crypt32.lib дійсно експортує всі шість символів, теж не допомагає: ilink32 компонує лише бібліотеки імпорту, явно вказані в командному рядку, за замовчуванням import32.lib cp32mt.lib, і додавання шляху пошуку не змушує його підтягувати щось додаткове з цього шляху. Запуск tdump над import32.lib напряму підтверджує прогалину — нуль збігів для CertOpenStore, проти шести чистих збігів у crypt32.lib з SDK

HotPDF розв'язує це так само, як уже обробляє перелічення сертифікатів в інших місцях бібліотеки: замість того щоб просити компонувальник дати ці символи, він завантажує їх під час виконання. Внутрішній запис THPDFCryptoProcs тримає дескриптор crypt32.dll, дескриптор advapi32.dll та одинадцять полів вказівників на функції; LoadCryptoProcs завантажує обидві DLL і розв'язує кожну точку входу через GetProcAddress рівно один раз, на початку HPDFSignPDFStreamWithSystemCertificate, одразу піднімаючи EHPDFCMSError, якщо чогось бракує, замість того щоб пізніше впасти з порушенням доступу глибоко всередині процесу підписування

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;

Завантаження відбувається один раз на виклик, а не лінькувато всередині кожного допоміжного методу, бо замикання, яке обирає між CNG і CAPI, захоплює завантажену таблицю функцій за значенням і мусить залишатися живим протягом усього процесу підписування, включно з колбеком у HPDFCMSSignPDFStreamWithExternalSigner; обидва дескриптори DLL звільняються в найзовнішньому блоці finally, коли підписування завершується або підіймає виняток. Ніщо з цього не торкається публічної поверхні: HPDFSignPDFStreamWithSystemCertificate, HPDFSignPDFFileWithSystemCertificate та THPDFCertificateStoreSelector зберігають ті самі сигнатури, що й раніше, тож застосування виправлення для наявного коду — це просто перезбірка, а не зміна коду

Що це не покриває

Правильний порядок байтів і правильне компонування в C++Builder дають CMS SignerInfo, який валідатор може розібрати, і підпис, який можна перевірити арифметично; це нічого не каже про те, чи має цей валідатор довіряти сертифікату за ним, оскільки побудова ланцюга, перевірка відкликання та політика штампів часу — окремі питання, що накладаються зверху через опції CMS, а не те, що коректність порядку байтів дає безкоштовно. Дві господарські деталі важать не менше за криптографію: PCCERT_CONTEXT, повернений пошуком сертифіката, треба звільнити через CertFreeCertificateContext ще до закриття сховища, а отриманий дескриптор ключа CNG чи CSP, коли API повідомляє, що ним володіє викликач, треба звільняти через власний виклик відповідного бекенда, ніколи через виклик іншого. Якщо результат svValid, отриманий після всього цього, виявиться вужчим, ніж ви очікували, стаття про перевірку цифрових підписів PDF детально пояснює, що саме цей прапорець обіцяє, а що ні. Оскільки сертифікат весь цей час залишається під опікою Windows, підписування через сховище сертифікатів обходить цілу поверхню атаки: тут немає PKCS#12-файлу для розбору й немає ASN.1 для самостійного обходу — саме цю проблему для шляху підписування PFX-файлом вирішує посилення безпеки PKCS#12 та ASN.1 у HotPDF

Підписування через сховище сертифікатів, підписування PFX-файлом та колбеки зовнішнього підписувача — це три двері в один і той самий конвеєр CMS/PKCS#7 усередині компонента HotPDF для Delphi та C++Builder, і вибір правильних дверей здебільшого залежить від того, кому дозволено тримати приватний ключ: вашому процесу, PFX-файлу чи самій Windows