С 1.06.2023 году вступили в действие новые требования к сертификатам для подписи кода (aka CodeSigning), которые значительно осложнили жизнь разработчиков ПО. Суть изменений - прощай старый добрый PFX, закрытые ключи теперь должны быть неизвлекаемыми и некопируемыми. Примеры изменений у поставщиков: раз, два, три - в общем-то у всех одно и тоже.
Мы, как российские разработчики, оказались точно также затронуты этими нововведениями. До этого у нас уже был действующий сертификат, который был задеплоен на нужные виртуальные машины, где собираемый софт без проблем подписывался CI/CD-скриптами. Но часы тикали, и до окончательного отыквивания сертификата с ключом в PFX необходимо было решить, как жить дальше.
Что предлагает нам рынок
Для хранения закрытых ключей поставщики предлагают +/- одни и те же три варианта:
USB-токены.
HSM (hardware security module).
Облачные криптопровайдеры.
Каждый из этих вариантов имеет свои плюсы и минусы, которые можно свести к этой таблице:
Особенность | USB | HSM | Cloud |
---|---|---|---|
Цена | Низкая | Высокая | Средняя |
Физическая надёжность носителя | Средняя | Высокая | Высокая |
Сложность настройки | Низкая | Высокая | Низкая |
Безопасность | Средняя | Очень высокая | В теории - высокая, на практике возможны нюансы |
Далее моё личное краткое резюме по всем классам.
HSM
Отдельный специализированный компьютер для хранения ключей и проведения криптографических операций. Очень специфическая штука, редкая, обычно сертифицированная по всем необходимым стандартам и нормам, и крайне дорогая, используется в основном там, где это действительно необходимо: центры сертификации, государственные организации, банки, и прочие, где безопасности уделяется серьёзное внимание. Например, облачные криптопровайдеры по сути продают доступ к массиву своих HSM как сервис.
Облачные криптопровайдеры
Как упомянул выше, облачный криптопровайдер - это сервис, предлагающий дистанционное использование массива провайдерских HSM. Облако обеспечивает: генерацию и хранение ключей, выполнение операций, требующих закрытого ключа (шифрование и генерация ЭП). Естественно, всё это доступно через интернет, гарантируется N девяток к доступности и пр. Хороший вариант, если вы: доверяете облачному провайдеру (во всех смыслах - см. типичную ситуацию с облаками после февраля 2022), а также готовы (и имеете право!) хранить ключи шифрования на стороне. Цены у всех разные, но довольно кусачие, ибо HSM и остальные инфраструктура, обязательные сертификации и аудиты, квалифицированный персонал для обслуживания всего этого стоят дорого.
USB-токен
Самый доступный и дешёвый вариант. Хорошо известные устройства в России: Рутокен, JaCarta и им подобные. Надёжность носителя не ахти, от частого включения/отключения могут ломаться разъёмы, сами устройство иногда дохнут - за такую-то цену и не удивительно. Зато легко настроить и использовать.
Как показывает практика, большинство небольших компаний, выбирают этот вариант как наиболее доступный. Поэтому дальше речь пойдёт именно про него и только в контексте вопросов организации подписи кода.
Проблемы
И так, вы наконец-то купили USB-токен и выпустили сертификат. С какими проблемами возможно придётся столкнуться? В общем-то, все они проистекают из требований о неизвлекаемости и некопируемости закрытого ключа.
Ключ один, а использовать нужно на нескольких ПК
В отличие от PFX, который можно раскидать по нужным компьютерам, USB-токен можно физически подключить только на одной машине одновременно. Вариантов решения не так уж и много - устройства USB-over-IP. Как ставший уже классикой AnywhereUSB, так и его отечественный аналоги, например такой. Слышал, что есть и чисто софтверные решения под для организации подобного доступа к USB по IP. Сам, правда, не сталкивался, так что если кому-то интересно, то гугл в помощь. Но, в любом исполнении, подобное решение избавляет только от необходимости физически переставлять токен из компьютера в компьютер. Одновременная работа токена на нескольких клиентских устройствах всё также не поддерживается.
Ключ в физическом USB-разъёме, а использовать его надо в виртуальной машине
Типичная проблема USB-устройств, далеко не каждый гипервизор умеет их пробрасывать в гостевые ВМ. Например, VMWare позволяет, а Hyper-V - нет. Для тех, кто не позволяет, остаётся только ранее рассмотренный вариант с USB-over-IP. Но и здесь получается, что токен работает только одной виртуальной машине одновременно.
Необходимость ввода PIN-кода
Для использования закрытого ключа необходимо вводить PIN-код. В режиме ручной подписи - это допустимо, но любые средства автоматизированной подписи, в т. ч. CI/CD-скрипты, не предполагают отображение каких-либо диалоговых окон. Даже если такое окно и отобразится, то никто его не увидит на сервере, который находится неизвестно где, и на кнопку ОК не нажмёт. В итоге, операция подписи или отвалится по таймауту, или просто зависнет - зависит от реализации драйверов.
Ситуация усугубляется тем, что утилита signtool
, используемая для подписи исполняемых файлов и установочных пакетов под Windows, не позволяет передать PIN-код как параметр. Да и разработчики драйверов носителей ключей не стремятся добавлять (или по крайней мере, широко афишировать) опции сохранения PIN-кодов и/или отключения UI для их ввода. Ведь предполагается, что USB-токены, являющиеся личными устройством, как раз должны обеспечивать безопасность пользователя. Что совершенно понятно в случаях персональных ключей электронных подписей. Ведь ни пользователю, ни производителю устройств не нужно, чтобы какой-нибудь примитивный зловред подписал за вас пару платёжных поручений, переводящих все нажитые накопления какому-нибудь Хулио из Колумбии.
Но, учитывая сложившиеся практики повсеместного использование подписи кода в автоматизированных системах сборки, производители устройств выкручиваются как могут. Например, для Рутокенов существует специальный тред на их форуме техподдержки.
Про сохранение PIN-кодов
Да, хранить PIN-коды для автоматизированной обработки - совсем не по фэншую, и все возможные руководства не советуют так делать. Но мы тут насущные проблемы решаем, а не боремся за абстрактную безопасность.
И что со всем этим делать?
Рассказываю, как я решил для себя эту задачу. После некоторых проб и экспериментов оформилось решение в виде системы их двух компонентов:
Веб-сервер на ASP.NET Core с генерацией электронной подписи и ввода PIN-кода посредством вызова функций WinAPI CNG (Cryptography API: Next Generation).
Клиентская часть в виде Powershell-скрипта, запускающего
signtool
со специальными ключами и обращающегося к нашему серверу за операцией генерации подписи.
Hidden text
Почему именно CNG, а не старый добрый CryptoAPI, будет понятно далее.
Теперь всё тоже самое, но по порядку и с подробностями.
Суть решения
Недолгий гуглинг показал, что signtool
умеет разделять подпись файла на четыре этапа.
С помощью вызова с параметром -dg формируем только заготовку электронной подписи в формате CMS, при этом сам хэш, который необходимо подписать, сохраняется в отдельном файле в кодировке Base64. Например, следующий вызов для подписи нашего пробного приложения
c:\temp\myapp.exe
сертификатомc:\temp\cert.cer
:
signtool sign -f c:\temp\cert.cer -fd sha256 -dg c:\temp -v c:\temp\myapp.exe
сгенерирует два новых файла в рабочем каталоге (в нашем примере c:\temp
). Первый - myapp.exe.p7u
- содержит заготовку CMS-подписи, нам он пока не интересен. Второй - myapp.exe.sig
- как раз содержит нужное нам значение рассчитанного хэша, закодированное в Base64.
2. Теперь необходимо как-то подписать хэш из файла myapp.exe.dig
. Этим будет заниматься наш веб-сервис, который предполагается установить на компьютер, к которому подключён USB-токен (физически или через USB-over-IP, не принципиально). На вход сервису будет передаваться значение хэша, алгоритм хэширования (SHA256
в примере выше) и отпечаток сертификата, результатом будет рассчитанная электронная подпись. Получив подпись, её необходимо сохранить в том же рабочем каталоге в файле myapp.exe.dig.signed
, тоже в кодировке Base64.
3. Всё готово для сборки подписи и вставке её в исполняемый файл. Вызываем:
signtool sign -di c:\temp -v c:\temp\myapp.exe
Параметр -di предписывает утилите signtool
взять сырое значение электронной подписи из файла myapp.exe.dig.signed
, завершить формирование CMS-подписи, и вставить её в исполняемый файл.
4. При такой схеме нельзя выполнить подпись и простановку штампа времени за один вызов signtool
, поэтому заставляем её проставить штамп времени на подпись отдельным вызовом:
signtool timestamp -tr http://timestamp.globalsign.com/tsa/r6advanced1 -td sha256 -v c:\temp\myapp.exe
Voilà, файл подписан, что и требовалось.
Реализация серверной части
Для обслуживания запросов от разных наших клиентов (CI/CD-скрипты) я написал простенький веб-сервис на ASP.NET Core, который развёрнут в виде Windows-службы на сервере, в который вставлен USB-токен.
Веб-часть до ужаса примитивна. Есть один minimal API endpoint, который и обрабатывает наши POST-запросы на генерацию подписи, и есть блок считывания настроек используемых сертификатов.
Начнём с настроек
Список доступных сертификатов и PIN-кодов к ним будем хранить в стандартном файле настроек приложения appsettings.json
в виде JSON. У меня он выглядит так:
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*",
"Signer": {
"Certificates": [
{
"Thumbprint": "fc276a503b9b2f24d644424fff823bdf6bf6f603",
"KeyName": "2a63f603f43743848ea243130bf1702e",
"IsMachineKey": false,
"Pin": "12345678"
}
]
}
}
Первые два раздела стандартные для ASP.NET, нас интересует последний, Signer. В нём есть только один параметр с массивом сертификатов, ключами которых мы планируем подписывать что-либо. Каждый сертификат задаётся четырьмя параметрами:
Thumbprint - текстовое представление SHA1-отпечатка сертификата, используется для идентификации. Именно это значение будет передаваться клиентом как один из параметров нашего сервиса. Можно подсмотреть в свойствах сертификата любым способом.
KeyName - имя ключевого контейнера в криптопровайдере Microsoft Smart Card Key Storage Provider.
Для ключа на Рутокен это имя можно подсмотреть в панели управления Рутокен
Для других устройств смотрите соответствующую документацию разработчика, или извлекайте кодом с помощью перечисления ключевых контейнеров с функцией
NCryptEnumKeys
(полеpszName
структурыNCryptKeyName
).IsMachineKey - признак что ключ принадлежит компьютеру, а не текущему пользователю.
Pin - наш сохранённый PIN-код для USB-токена.
В самом приложении эти настройки инкапсулированы в пару классов:
public sealed class Certificate
{
public required string Thumbprint { get; set; }
public required string KeyName { get; set; }
public bool IsMachineKey { get; set; }
public required string Pin { get; set; }
}
public sealed class SignerOptions
{
public List<Certificate> Certificates { get; set; } = [];
}
При инициализации приложения классы с настройками регистрируются в DI-контейнере стандартным вызовом:
builder.Services.Configure<SignerOptions>(builder.Configuration.GetSection("Signer"));
Обработчик запросов
Для обработки входящих запросов на подпись используем Minimal API:
app.MapPost("/sign/base64hash",
async ([FromQuery] string base64hash, [FromQuery] string certificateThumbrint, [FromQuery] string hashAlgId, IOptionsMonitor<SignerOptions> options, CancellationToken ct) =>
await SignBase64HashAsync(base64hash, certificateThumbrint, hashAlgId, options.CurrentValue, ct));
Сервис реализован в виде метода POST по адресу /sign/base64hash
с тремя параметрами, которые передаются через параметры запроса:
base64hash - кодированное в Base64 значение хэша, которого нужно подписать.
certificateThumbrint - текстовое представление отпечатка сертификата, которым необходимо подписать хэш.
hashAlgId - название алгоритм хэширования, который должен совпадать с константами из CNG.
SignBase64HashAsync
- наш метод, который будет выполнять всю работу, рассмотрим его подробнее:
private static async Task<IResult> SignBase64HashAsync(string base64hash, string certificateThumbrint, string hashAlgId, SignerOptions options, CancellationToken ct)
{
var certificate = options.Certificates.FirstOrDefault(c => c.Thumbprint.Equals(certificateThumbrint, StringComparison.OrdinalIgnoreCase));
if (certificate is null)
return Results.Problem($"The certificate with the thumbprint '{certificateThumbrint}' is not allowed.", null, StatusCodes.Status403Forbidden);
var hash = Convert.FromBase64String(base64hash);
var semaphore = _keySemaphores.GetOrAdd(certificate, c => new(() => new(1))).Value;
await semaphore.WaitAsync(ct);
try
{
var signature = SignCore(hash, hashAlgId, certificate);
return Results.Bytes(Encoding.ASCII.GetBytes(Convert.ToBase64String(signature, Base64FormattingOptions.None)), "application/octet-stream");
}
finally
{
semaphore.Release();
}
}
На вход он получает параметры запроса и указатель на объект SignerOptions
с настройками приложения. Сначала функция выполняет поиск сертификата с заданным отпечатком, если он не найден, то на клиента возвращается ошибка в формате Problem Details.
Если сертификат найден в настройках, то выполняется поиск и при необходимости создание семафора, обеспечивающего доступ к ключу только одному потоку приложения одновременно. Сам словарь семафоров объявлен так:
private static ConcurrentDictionary<Certificate, Lazy<SemaphoreSlim>> _keySemaphores = new();
Далее происходит вход в семафор и выполняется метод SignCore
, который и выполняет непосредственно подписание. Полученный байтовый массив - это и есть значение подписи, которое кодируется в Base64 и отправляется клиенту.
Подпись
Сначала я хотел использовать для подписи CryptoAPI (ранее немного освещал его использование именно для .NET: раз и два). Но с ним я зашёл в тупик. Оказалось, что функция CryptSignHash принимает на вход ссылку HCRYPTHASH
, то есть ссылку на объект, выполняющий хэширование. У нас же хэш уже вычислен, и нужно только подписать его. Но способа передать только значение хэша без выполнения самого хэширования я не нашёл. Да ещё, и весь CryptoAPI объявлен deprecated, и сама MS настоятельно рекомендует перейти на CNG, который как ни странно, появился ещё в составе Windows Vista, но не снискал особой популярности, оставаясь в тени своего предшественника.
После небольшого исследования стало понятно, что NCryptSignHash
- функция подписи в CNG - отделена от хэширования и принимает на вход произвольный байтовый массив, что нам и требуется.
Итак, сам код. Для P/Invoke я использовал свою библиотеку функций (nuget, github), вы можете использовать любую другую или написать свои переходники к функциям WinAPI.
private static unsafe byte[] SignCore(ReadOnlySpan<byte> inData, ReadOnlySpan<char> hashAlg, Certificate certificate)
{
nint hProvider;
fixed (char* providerName = MS_SMART_CARD_KEY_STORAGE_PROVIDER)
NCryptOpenStorageProvider(&hProvider, providerName, 0).VerifyWinapiErrorCode();
try
{
nint hKey;
fixed (char* pKeyName = certificate.KeyName)
NCryptOpenKey(hProvider, &hKey, pKeyName, 0U, NCRYPT_SILENT_FLAG | (certificate.IsMachineKey ? NCRYPT_MACHINE_KEY_FLAG : 0U)).VerifyWinapiErrorCode();
try
{
fixed (char* pPin = certificate.Pin, pParam = NCRYPT_PIN_PROPERTY)
NCryptSetProperty(hKey, pParam, pPin, (uint)(certificate.Pin.Length + 1) * 2, 0U).VerifyWinapiErrorCodeInList(ERROR_SUCCESS);
var inDataLen = (uint)inData.Length;
uint outDataLen;
fixed (char* pHashAlg = BCRYPT_SHA256_ALGORITHM)
fixed (byte* pInData = inData)
{
var padding = new BCRYPT_PKCS1_PADDING_INFO() { pszAlgId = pHashAlg };
NCryptSignHash(hKey, &padding, pInData, inDataLen, null, 0, &outDataLen, NCRYPT_PAD_PKCS1_FLAG).VerifyWinapiErrorCode();
var outData = new byte[(int)outDataLen];
fixed (byte* pOutData = outData)
NCryptSignHash(hKey, &padding, pInData, inDataLen, pOutData, outDataLen, &outDataLen, NCRYPT_PAD_PKCS1_FLAG).VerifyWinapiErrorCode();
return outData;
}
}
finally
{
NCryptFreeObject(hKey);
}
}
finally
{
NCryptFreeObject(hProvider);
}
}
Код прост и прямолинеен:
Вызовом
NCryptOpenStorageProvider
получаем ссылку на встроенный провайдер смарт-карт (константаMS_SMART_CARD_KEY_STORAGE_PROVIDER
).С помощью
NCryptOpenKey
получаем ссылку на наш ключевой контейнер по имени. Важно, что контейнер открывается с флагомNCRYPT_SILENT_FLAG
, что блокирует отображение криптопровайдером любых диалоговых окон наподобие "Вставьте ключ", "Введите PIN-код". Нам как раз и нужно такое поведение.Передаём PIN-код ключа с помощью
NCryptSetProperty
и константыNCRYPT_PIN_PROPERTY
.Наконец, генерируем саму подпись парой вызовов
NCryptSignHash
. Первый вызов вернёт размер буфера под подпись, второй - заставит USB-токен рассчитать её значение.Очищаем все выделенные неуправляемые ресурсы: ссылки на ключ и на провайдер - вызовами
NCryptFreeObject
.
На этом с сервером почти всё. Осталось две задачи:
Обеспечить некоторое разграничение прав. Делаете любым подходящим вам способом, я делал с помощью API Key по этой статье на Хабре.
Настроить хостинг в Windows-службе согласно стандартной доке. Кстати, службе не обязательно давать права
LocalSystem
, у меня хватило иLocalService
.
Реализация клиентской части
Для использования только что созданного веб-сервиса и избавления от повторяющихся вызовов signtool
я написал простенький Powershell-скрипт Sign-ExecutableFile.ps1
, выполняющий всю грязную работу:
[CmdletBinding()]
param(
[Parameter(Mandatory=$true, ValueFromPipeline = $true, HelpMessage="A target file name")]
[ValidateNotNullOrEmpty()]
[string] $TargetFileName,
[Parameter(Mandatory=$true, HelpMessage="A full path to the SignTool utility")]
[ValidateNotNullOrEmpty()]
[string] $SignToolPath,
[Parameter(Mandatory=$true, HelpMessage="A certificate file name")]
[ValidateNotNullOrEmpty()]
[string] $CertFileName,
[Parameter(Mandatory=$true, HelpMessage="An URI of signing service")]
[ValidateNotNullOrEmpty()]
[string] $SigningServiceUri,
[Parameter(Mandatory=$true, HelpMessage="An API key for signing service")]
[ValidateNotNullOrEmpty()]
[string] $SigningServiceApiKey,
[Parameter(Mandatory=$false, HelpMessage="A digest algorithm name for signing")]
[ValidateSet("SHA256", "SHA384", "SHA512")]
[string] $DigestAlgorithm = "SHA256",
[Parameter(Mandatory=$false, HelpMessage="A timestamp authority URI")]
[ValidateNotNullOrEmpty()]
[string] $TsaUri = $null,
[Parameter(Mandatory=$false, HelpMessage="A digest algorithm name for timestamping")]
[ValidateSet("SHA256", "SHA384", "SHA512")]
[string] $TsaDigestAlgorithm = "SHA256",
[Parameter(Mandatory=$false, HelpMessage="A working directory for placing intermediate results")]
[string] $WorkingDirectory
)
begin {
$ErrorActionPreference = "Stop"
if (![System.String]::IsNullOrEmpty($WorkingDirectory))
{
$TargetWorkingDirectory = $WorkingDirectory
}
else
{
Write-Warning "The working folder is not specified, temporary files will be placed next to the executable files."
}
$certBytes = [System.IO.File]::ReadAllText($CertFileName)
$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($CertFileName)
}
process {
Write-Host
Write-Host ("Signing the file '" + $TargetFileName + "'") -ForegroundColor White
if ([System.String]::IsNullOrEmpty($WorkingDirectory))
{
$TargetWorkingDirectory = [System.IO.Path]::GetDirectoryName($TargetFileName)
}
else
{
$TargetWorkingDirectory = $WorkingDirectory
}
$tempFilePrefix = [System.IO.Path]::Combine($TargetWorkingDirectory, [System.IO.Path]::GetFileName($TargetFileName))
$digestFileName = $tempFilePrefix + ".dig"
$signatureFileName = $digestFileName + ".signed"
$cmsFileName = $tempFilePrefix + ".p7u"
try
{
Write-Host ("Generating a digest for the file '" + $TargetFileName + "'") -ForegroundColor Cyan
& $SignToolPath sign -f "$CertFileName" -fd $DigestAlgorithm -ph -dg "$TargetWorkingDirectory" -q "$TargetFileName"
Write-Host ("Calculating a signature for the digest from the file '" + $digestFileName + "'" ) -ForegroundColor Cyan
$serviceUri = [System.Uri]::new($SigningServiceUri)
$serviceUri = [System.Uri]::new($serviceUri, ("sign/base64hash?base64hash=" + [System.Uri]::EscapeDataString([System.IO.File]::ReadAllText($digestFileName)) + "&hashAlgId=" + $DigestAlgorithm + "&certificateThumbrint=" + [System.Uri]::EscapeDataString($cert.Thumbprint)))
Invoke-RestMethod -Uri $serviceUri -Method Post -Headers @{ 'X-API-Key' = $SigningServiceApiKey } -OutFile $signatureFileName
Write-Host ("Injecting a signature into the file '" + $TargetFileName+ "'" ) -ForegroundColor Cyan
& $SignToolPath sign -di "$TargetWorkingDirectory" -q "$TargetFileName"
if (-not [System.String]::IsNullOrEmpty($TsaUri))
{
Write-Host ("Injecting a timestamp into the file '" + $TargetFileName+ "'" ) -ForegroundColor Cyan
& $SignToolPath timestamp -tr "$TsaUri" -td $TsaDigestAlgorithm -q "$TargetFileName"
}
}
finally
{
Remove-Item -Path $digestFileName -Force -ErrorAction SilentlyContinue
Remove-Item -Path $signatureFileName -Force -ErrorAction SilentlyContinue
Remove-Item -Path $cmsFileName -Force -ErrorAction SilentlyContinue
}
}
end {
}
Скрипт принимает все необходимые значения через параметры, назначение которых вполне очевидно из исходника. Для каждого элемента из входной коллекции файлов скрипт выполняет нужную череду вызовов signtool
и обращений к нашему сервису подписи. В конце производится зачистка всех временных файлов, созданных утилитой signtool
.
Пример вызова для подписи всех EXE-файлов в каталоге c:\temp
, включая подкаталоги:
gci -Path "c:\temp\*.exe" -Recurse | .\Sign-ExecutableFile.ps1 -CertFileName c:\temp -SignToolPath "c:\Program Files (x86)\Windows Kits\10\bin\10.0.22621.0\x64\signtool.exe" -SigningServiceUri "http://localhost:8145" -SigningServiceApiKey "myApiKey" -TsaUri "http://timestamp.globalsign.com/tsa/r6advanced1"
Заключение
В сухом остатке, задача по подписи кода из CI/CD с ключами на USB-токенах была успешно решена, "веб-сервис крутится, сигнатурки мутятся". Буду рад, если предложенное решение облегчит кому-то рутинные операции.