Функция PLCreateSelfSignedCertificate в PDF Library for Delphi строит самоподписанный сертификат 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. PDF Library for Delphi поставляет эту функцию начиная с v3.224.0, доступную не только из модуля Delphi, но и через поверхности DLL и ActiveX, и собственный комментарий документации к ней прямо говорит о том, где она перестаёт быть полезной: любой массовый просмотрщик помечает самоподписанный сертификат как недоверенный, если только кто-то явно его не установит, так что относитесь к тому, что она производит, как к сертификату для прогона пути кода, а не как к подписи, на которую кому-либо вне вашей команды стоит полагаться
var
Success: Boolean;
begin
Success := PLCreateSelfSignedCertificate(
'CN=PDF Library for Delphi 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 означает сначала сдвиг числа в старшее слово
// Длина ключа хранится в старших 16 битах флагов CryptGenKey;
// младшее слово несёт флаги поведения, такие как 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, в реализации PDF Library for Delphi. Установите ProvType в ноль или в любую другую константу провайдера, отличную от той, которой реально принадлежит контейнер, и сертификат всё равно может быть создан, но записанная в нём связь с закрытым ключом больше не разрешается в контейнер, что его хранит, что позже проявляется как отказ подписания или экспорта, не имеющий ничего общего с реальным криптографическим содержимым сертификата
// Тип провайдера, использованный для открытия контейнера ключей, должен совпадать
// с типом провайдера, записанным в key-provider info сертификата.
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... сгенерировать ключ, построить blob имени субъекта, затем:
KeyProvInfo.ProvType := PROV_RSA_AES; // та же константа в обеих точках вызова
Собираем вместе: от контейнера с GUID до защищённого паролем PFX
Цепочка вызовов внутри PLCreateSelfSignedCertificate следует одной прямой линии: открывает свежий контейнер ключей, названный по только что сгенерированному GUID, чтобы параллельные прогоны CI никогда не сталкивались из-за имён контейнеров, генерирует внутри него пару ключей RSA с двумя описанными выше флагами, кодирует SubjectName в blob имени X.500 через CertStrToNameW и вызывает CertCreateSelfSignCertificate с окном действия, вычисленным из ValidDays и переданным как обычная структура в форме SYSTEMTIME. Получившийся контекст сертификата помещается в хранилище сертификатов в памяти, открытое через CertOpenStore и CERT_STORE_PROV_MEMORY, исключительно чтобы у PFXExportCertStoreEx было хранилище, из которого экспортировать, поскольку этот API работает с дескриптором хранилища, а не с голым контекстом сертификата
// Каждый вызов открывает одноразовый контейнер, названный по свежему GUID:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_NEWKEYSET);
// ... сгенерировать ключ, самоподписать сертификат, экспортировать PFX ...
// затем удалить контейнер, как только PFX получит собственную копию ключа:
CryptAcquireContextW(hProv, PWideChar(Container), nil,
PROV_RSA_AES, CRYPT_DELETEKEYSET);
Сам PFXExportCertStoreEx следует обычному соглашению Win32 из двух проходов: вызвать его один раз с буфером нулевой длины, чтобы узнать, сколько байтов требуется PFX, выделить столько, затем вызвать снова, чтобы заполнить буфер. Как только байты оказываются на диске, PDF Library for Delphi удаляет одноразовый контейнер ключей через CRYPT_DELETEKEYSET, а не оставляет его после себя, потому что PFX уже несёт собственную копию каждого байта материала ключа, что хранил контейнер. Пропустите эту очистку, и каждый вызов PLCreateSelfSignedCertificate оставит осиротевший, названный по GUID контейнер ключей в профиле вызывающего пользователя, — именно тот тип утечки, что агент CI, запускающий эту функцию на каждой сборке, будет накапливать месяцами, прежде чем кто-то заметит
Безопасно ли использовать самоподписанный сертификат для продакшн-подписания?
Нет: самоподписанный сертификат безопасен для прогона пути кода подписания и небезопасен для подписи, которой ожидается доверие от кого-либо вне команды, потому что ничто не связывает его цепочкой обратно к корню, которому уже доверяет ПО принимающей стороны. Естественный следующий шаг для такого PFX — реальный вызов подписания, описанный в статье о построении инструментария соответствия и подписания на Delphi с PDF Library for Delphi, где PFX, построенный так, управляет половиной конвейера, отвечающей за подписание, тогда как другая половина выполняет предпечатную проверку PDF/A и аудиты ByteRange. Подписание, впрочем, лишь половина того, что окружает сертификат, а другая половина — как раз то место, где самоподписанный лист и должен провалиться: статья о подписании и проверке PAdES на Delphi с PDF Library for Delphi описывает проверки цепочки доверия, которые выполняет валидатор соответствия, а валидатору, проходящему цепочку обратно к доверенному корню, нет причин доверять сертификату, который эта функция изобрела пять минут назад из ничего
PLCreateSelfSignedCertificate — одна из функций среди API сертификатов и подписания в PDF-библиотеке PDF Library for Delphi для Delphi и C++Builder, и она существует именно ради описанного здесь пробела: тесту подписания нужна настоящая пара ключей за ним и ничего внешнего для её генерации