Техническая статья

Подпись PDF из хранилища сертификатов в 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. Отпечаток (thumbprint) нормализуется внутри библиотеки, поэтому дефисы или пробелы, скопированные прямо из интерфейса диспетчера сертификатов, удаляются перед сравнением

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: строка октетов в порядке от старшего байта к младшему, ровно то, что даёт преобразование I2OSP из RFC 8017, и то, что требуется в поле подписи CMS SignerInfo (RFC 5652) согласно ISO 32000-1 §12.8.3. CryptSignHash из CryptoAPI, напротив, возвращает подпись в порядке от младшего байта к старшему — документированная особенность, уходящая корнями в то, как классические 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 может выдать дескриптор ключа. Какой бы бэкенд ни стоял за этим callback, возвращаемые им байты должны оказаться в порядке от старшего к младшему прежде, чем 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-структуре — это задача, которую усиление защиты PKCS#12 и ASN.1 в HotPDF решает для пути подписи через PFX-файл

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