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

Самопідписані тестові сертифікати в Delphi з CryptoAPI

Функція 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 усе одно вдається, але позначає згенерований приватний ключ невиносним (non-exportable) на рівні 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, побудований так, керує половиною підписування конвеєра, що також виконує preflight PDF/A та аудити ByteRange. Підписування, однак, лише половина того, що сидить навколо сертифіката, а інша половина — саме там, де самопідписаний лист і мав би провалитися: стаття підписування та валідація PAdES в Delphi з PDFlibPas розглядає перевірки ланцюжка довіри, які виконує валідатор відповідності, а валідатор, що обходить ланцюжок назад до довіреного кореня, не має підстав довіряти сертифікату, який ця функція вигадала п'ять хвилин тому з нічого

PLCreateSelfSignedCertificate — одна функція серед API сертифікатів та підписування в бібліотеці PDF PDFlibPas для Delphi та C++Builder, і вона існує саме для прогалини, описаної тут: тест підписування, якому потрібна справжня пара ключів за ним і нічого зовнішнього для її генерації