Функция PLCreateSelfSignedCertificate в PDFlibPas строит самоподписанный сертификат RSA/SHA-256 и экспортирует его, вместе с закрытым ключом, прямо в защищённый паролем файл PFX, используя ничего, кроме Win32 CryptoAPI, уже установленного на каждой машине с Windows. Никакого внешнего инструмента, никакого удостоверяющего центра, никакого ручного шага makecert или OpenSSL: один вызов функции, один сертификат, достаточно хороший, чтобы прогнать тест подписания
Сценарий, ради которого эта функция стоит своего существования, — почти всегда конвейер CI. Дымовому тесту подписания нужен настоящий PFX с реальным закрытым ключом за ним, а закоммитить такой в репозиторий — сама по себе проблема безопасности, поскольку закоммиченный закрытый ключ становится утёкшим закрытым ключом с момента, когда этот коммит попадёт в историю. Обращение к makecert.exe или вызову OpenSSL из скрипта сборки тоже работает, но тогда конвейер зависит от инструмента, который нужно установить, найти в PATH и поддерживать согласованным по версии на каждом агенте сборки. Генерация сертификата внутри того же процесса, что запускает тест, с теми же вызовами Win32 CryptoAPI, что уже поставляются с Windows, полностью устраняет эту зависимость
Что на самом деле производит PLCreateSelfSignedCertificate?
PLCreateSelfSignedCertificate производит защищённый паролем файл PFX, несущий самоподписанный сертификат RSA и его закрытый ключ, подписанный sha256RSA, управляемый пятью параметрами: SubjectName, PFXFileName, PFXPassword, ValidDays и KeyBits, и возвращает обычный булев флаг успеха. SubjectName принимает полную строку X.500, например 'CN=Alice, O=Example', а голое имя без знака = в нём автоматически получает префикс CN=. ValidDays ниже 1 откатывается к 365, а KeyBits вне диапазона от 1024 до 16384 откатывается к 2048. PDFlibPas поставляет эту функцию начиная с v3.224.0, доступную не только из модуля Delphi, но и через поверхности DLL и ActiveX, и собственный комментарий документации к ней прямо говорит о том, где она перестаёт быть полезной: любой массовый просмотрщик помечает самоподписанный сертификат как недоверенный, если только кто-то явно его не установит, так что относитесь к тому, что она производит, как к сертификату для прогона пути кода, а не как к подписи, на которую кому-либо вне вашей команды стоит полагаться
var
Success: Boolean;
begin
Success := PLCreateSelfSignedCertificate(
'CN=PDFlibPas CI Test, O=Example Corp',
'ci-test-signer.pfx',
'a-strong-throwaway-password',
365, // ValidDays
2048); // KeyBits
if not Success then
raise Exception.Create('Self-signed certificate generation failed');
end;
Почему CryptGenKey кодирует длину ключа в параметре флагов?
CryptGenKey упаковывает две не связанные настройки в один параметр dwFlags. Младшее слово несёт флаги поведения, среди них CRYPT_EXPORTABLE, тогда как старшее слово, для ключа обмена ключами RSA, несёт запрошенную длину ключа в битах. Передача 2048 так, будто это просто ещё один флаг, помещает его в младшее слово вместо этого, где оно не совпадает ни с одним флагом поведения, определённым CryptoAPI, так что вызов генерирует ключ той длины по умолчанию, к которой откатывается провайдер, а не той длины, что вызывающий код думал, будто запросил. Получение действительно 2048-битного ключа RSA означает сначала сдвиг числа в старшее слово
// Key length lives in the upper 16 bits of the CryptGenKey flags;
// the low word carries behavior flags such as CRYPT_EXPORTABLE.
if not CryptGenKey(hProv, AT_KEYEXCHANGE,
(Cardinal(KeyBits) shl 16) or CRYPT_EXPORTABLE, hKey) then
Exit;
Что произойдёт, если забыть CRYPT_EXPORTABLE?
Уберите CRYPT_EXPORTABLE из того же значения флагов, и CryptGenKey всё равно завершится успешно, но пометит сгенерированный закрытый ключ неэкспортируемым на уровне CSP. Всё, что ниже по потоку, тоже продолжает сообщать об успехе: CertCreateSelfSignCertificate возвращает корректный контекст сертификата, а PFXExportCertStoreEx, даже вызванный с EXPORT_PRIVATE_KEYS, всё равно завершается успешно и записывает файл PFX, который открывается, разбирается и выглядит совершенно обычным. Чего он не содержит — это закрытого ключа, потому что CSP отказался выпустить его из контейнера ключей, а PFXExportCertStoreEx никогда не трактует этот отказ как причину провалить весь экспорт
Отказ проявляется только позже и совершенно в другом месте: вызов подписания открывает этот PFX, находит сертификат без прикреплённого закрытого ключа и сообщает в точности ту же ошибку, что вы получили бы от повреждённого или неверного PFX, а не от отсутствующего флага тремя слоями выше по потоку. Любой, кто отлаживает только со стороны подписания, может сжечь целый вечер на неправильном файле, прежде чем поймёт, что реальная ошибка — единственный отсутствующий бит на этапе генерации ключа, в совершенно другом вызове функции, возможно, в совершенно другом скрипте сборки
Почему ProvType должен совпадать между CryptAcquireContextW и сертификатом?
ProvType должен совпадать, потому что CertCreateSelfSignCertificate разрешает закрытый ключ нового сертификата через запись CRYPT_KEY_PROV_INFO, и одно поле в этой записи, ProvType, должно называть точно то же значение типа CSP, что было передано в CryptAcquireContextW при открытии контейнера ключей, PROV_RSA_AES, численно 24, в реализации PDFlibPas. Установите ProvType в ноль или в любую другую константу провайдера, отличную от той, которой реально принадлежит контейнер, и сертификат всё равно может быть создан, но записанная в нём связь с закрытым ключом больше не разрешается в контейнер, что его хранит, что позже проявляется как отказ подписания или экспорта, не имеющий ничего общего с реальным криптографическим содержимым сертификата
// The provider type used to open the key container must match the
// provider type recorded in the certificate's key-provider info.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, build the subject name blob, then:
KeyProvInfo.ProvType := PROV_RSA_AES; // same constant, both call sites
Собираем вместе: от контейнера с GUID до защищённого паролем PFX
Цепочка вызовов внутри PLCreateSelfSignedCertificate следует одной прямой линии: открывает свежий контейнер ключей, названный по только что сгенерированному GUID, чтобы параллельные прогоны CI никогда не сталкивались из-за имён контейнеров, генерирует внутри него пару ключей RSA с двумя описанными выше флагами, кодирует SubjectName в blob имени X.500 через CertStrToNameW и вызывает CertCreateSelfSignCertificate с окном действия, вычисленным из ValidDays и переданным как обычная структура в форме SYSTEMTIME. Получившийся контекст сертификата помещается в хранилище сертификатов в памяти, открытое через CertOpenStore и CERT_STORE_PROV_MEMORY, исключительно чтобы у PFXExportCertStoreEx было хранилище, из которого экспортировать, поскольку этот API работает с дескриптором хранилища, а не с голым контекстом сертификата
// Each call opens a throwaway container named after a fresh GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... generate the key, self-sign the certificate, export the PFX ...
// then delete the container once the PFX holds its own copy of the key:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_DELETEKEYSET);
Сам PFXExportCertStoreEx следует обычному соглашению Win32 из двух проходов: вызвать его один раз с буфером нулевой длины, чтобы узнать, сколько байтов требуется PFX, выделить столько, затем вызвать снова, чтобы заполнить буфер. Как только байты оказываются на диске, PDFlibPas удаляет одноразовый контейнер ключей через CRYPT_DELETEKEYSET, а не оставляет его после себя, потому что PFX уже несёт собственную копию каждого байта материала ключа, что хранил контейнер. Пропустите эту очистку, и каждый вызов PLCreateSelfSignedCertificate оставит осиротевший, названный по GUID контейнер ключей в профиле вызывающего пользователя, — именно тот тип утечки, что агент CI, запускающий эту функцию на каждой сборке, будет накапливать месяцами, прежде чем кто-то заметит
Безопасно ли использовать самоподписанный сертификат для продакшн-подписания?
Нет: самоподписанный сертификат безопасен для прогона пути кода подписания и небезопасен для подписи, которой ожидается доверие от кого-либо вне команды, потому что ничто не связывает его цепочкой обратно к корню, которому уже доверяет ПО принимающей стороны. Естественный следующий шаг для такого PFX — реальный вызов подписания, описанный в статье о построении инструментария соответствия и подписания на Delphi с PDFlibPas, где PFX, построенный так, управляет половиной конвейера, отвечающей за подписание, тогда как другая половина выполняет предпечатную проверку PDF/A и аудиты ByteRange. Подписание, впрочем, лишь половина того, что окружает сертификат, а другая половина — как раз то место, где самоподписанный лист и должен провалиться: статья о подписании и проверке PAdES на Delphi с PDFlibPas описывает проверки цепочки доверия, которые выполняет валидатор соответствия, а валидатору, проходящему цепочку обратно к доверенному корню, нет причин доверять сертификату, который эта функция изобрела пять минут назад из ничего
PLCreateSelfSignedCertificate — одна из функций среди API сертификатов и подписания в PDF-библиотеке PDFlibPas для Delphi и C++Builder, и она существует именно ради описанного здесь пробела: тесту подписания нужна настоящая пара ключей за ним и ничего внешнего для её генерации