Пятнадцать лет назад мне по работе пришлось познакомиться с администрированием удостоверяющего центра КриптоПро 1.5. Это был очень захватывающий и одновременно очень печальный опыт — я начал понимать, как устроена работа удостоверяющего центра, но взаимодействие с большим количеством пользователей вызывала резко отрицательные эмоции из-за очень медленного интерфейса и множества кликов для достижения цели. Мне как программисту очень захотелось написать удобнее и производительнее. Но судьба не дала мне шанса тогда это реализовать, и я отложил свои желания на потом.
Три года назад мне по производственной необходимости понадобился тестовый ГОСТовый удостоверяющий центр. Я пошел к нашим сотрудникам безопасности и попросил выдать мне мой собственный тестовый УЦ, и они не смогли мне этого дать. Оказалось, что УЦ надо все равно покупать (даже если он тестовый) и при этом цена зависит от количества выданных уникальных сертификатов. И удобства в этом УЦ не добавилось за прошедшие 12 лет. Я обрадовался и сел писать собственный УЦ, благо мне разрешили потратить на это рабочее время. Через два дня я понял, что у меня еще очень много пробелов в знаниях по российской криптографии, и мне пришлось срочно переключаться с идеи запрограммировать УЦ на идею "сделать хоть как-нибудь". Я прикрутил КриптоПро CSP к OpenSSL под Windows и создал систему скриптов, которые позволяли в полуручном режиме выпускать тестовые сертификаты. Получилось намного неудобнее, чем КриптоПро УЦ, зато бесплатно. Эта система проработала около 15 месяцев, и тогда я осознал главную проблему всего подхода — при выпуске корневого сертификата через КриптоПро CSP, даже если установить срок его действия 10 лет, на его закрытый ключ незаметно ставится ограничение в 15 месяцев. И корневой сертиикат УЦ надо менять. А для моего наколенного УЦ это было смерти подобно, все надо было переписывать и переделывать. И я забросил свою поделку и заставил наших тестировщиков мучаться с тестовыми сертификатами официального тестового УЦ КриптоПро, которые заканчиваются каждые 3 месяца. Процедура работы выглядела очень печально. Каждые три месяца тестировщики выполняли следующие ручные действия с каждым тестовым сертификатом:
Сразу возникает вопрос - зачем придумали столько много неудобных шагов???
Имя контейнера должно быть уникальным как в операционной системе УЦ, так и в операционной системе пользователя, поэтому если не задать вручную имя контейнеру, то система присвоит ему уникальный номер, т.е. GUID. Искать потом контейнер по номеру GUID среди десятков других совершенно неинформативно и трудно, поэтому тестировщики перед каждым созданием запроса на сертификат называли сертификат осмысленно.
При создании пары открытого и закрытого ключей необходимо с помощью криптографически строгой случайной последовательности посчитать кусочек эллиптической кривой. Обычный пользовательский компьютер не в состоянии генерировать криптографически строгие случайные последовательности. Поэтому КирптоПро придумали биологический датчик случайных чисел, который для генерирования последовательности использует в том числе хаотичные движения мышкой и случайные нажатия на клавиши компьютера.
Работать через браузер, который поддерживает ГОСТ, необходимо для того, чтобы сервер УЦ мог шифровать передаваемые данные по ГОСТ, т.к. запрос на сертификат и итоговый сертификат вполне можно считать персональными данными, а персональные данные можно передавать только при шифровании канала по ГОСТ.
В связи с тем, что контейнер с ключами создается на стороне пользователя, а сертификат выпускает УЦ, то пользователь желает после получения сертификата установить его в свой контейнер рядом со своими ключами. Для работы на этом же самом компьютере установка сертификата в контейнер не является обязательной, а вот при экспорте контейнера и передаче его на другие компьютеры удобнее передавать контейнер со всем необходимым внутри, нежели передавать контейнер отдельно, а сертификат отдельно.
Это страшный кошмар, который не давал мне спать по ночам. В голове рождалось желание все-таки сделать свой УЦ, но без всех этих ужасных шагов для получения тестовых сертификатов. Я копил эти годы информацию и созревал на подвиг :) И тут подвернулся шанс воплотить мои мечты — я взял отпуск на две недели, но прям за день до отпуска заболел, и все мои планы на отпуск рухнули. Я обрадовался и понял — это знак свыше, пора писать свой УЦ!
За последние годы я отошел от программирования и подался в начальники, поэтому я четко осознавал: чтобы добиться осмысленного результата за короткое время — нужен четкий и простой план. Поэтому я вспомнил все проблемы наших тестировщиков и решил как можно сильнее упростить им жизнь. План я решил составить из основных действий тестировщика:
Также я решил, если останется время, добавить регалии, которые должны быть у каждого уважающего себя УЦ:
Основные ноу-хау, которые я узнал за последнее время:
Современные стандарты, которые мне пришлось изучить, чтобы реализовать всё задуманное:
Спойлер: интересная история формата PFX от компании КриптоПро.
Оказывается, когда разрабатывался международный стандарт PKCS12, компания Microsoft взяла и, слегка подредактировав PKCS12, выпустила свой стандарт PFX, который несовместим с PKCS12. Затем компания КриптоПро взяла стандарт PFX от Microsoft и, слегка его подредактировав, сделала свой вариант PFX, который несовместим со стандартом PFX от Microsoft.
Формат PFX подразумевает безопасную передачу закрытого ключа, поэтому закрытый ключ всегда шифруется внутри PFX.
Правила работы официальных удостоверяющих центров гласят, что УЦ должен принять запрос на сертификат, взять оттуда некоторые поля (которые посчитает достойными для включения в итоговый сертификат), дополнить своими полями, подписать и выдать результат пользователю. Для этого внутри каждого УЦ есть модуль политик обработки сертификатов, где в каждой политике определяется — что можно принимать, а что нельзя. Так как я делаю тестовый удостоверяющий центр и при этом всего за две недели, я решил, что мой удостоверяющий центр не будет никак ограничивать ту информацию, которую он принимает.
Сертификат в основном состоит из следующих полей:
Сертификат по ГОСТ 34.10/34.11 2012 может быть обычной стойкости — 256 бит и усиленной стойкости — 512 бит. По умолчанию предлагается 256 бит, но пользователь может выбрать 512 бит.
ГОСТ-контейнеры можно передавать либо папкой с файлами, либо в виде PFX, поэтому пользователю предлагается выбрать вариант получения результата.
На данный момент самым популярным форматом для REST API стал JSON, поэтому шаблон сертификата я решил сделать в формате JSON.
Шаблон состоит из полей:
На данный момент я решил поддержать только самые распространенные расширения:
Расширение описано на странице стандарта X.509 KeyUsage. Разрешенные значения для этого расширения:
Расширение описано на странице стандарта X.509 ExtendedKeyUsage. Разрешенными значениями для этого расширения могут быть любые OID-ы. Вот самые распространненные из них:
Расширение описано на странице стандарта X.509 CertificatePolicies. Разрешенными значениями для этого расширения могут быть любые OID-ы. Вот самые распространенные из них:
Расширение описано в приказе ФСБ 795 пункт 30. Разрешенными значениями для этого расширения могут быть любые четыре строчки, описывающие средства и лицензии. Вот обычный комплект:
Расширение описано в приказе ФСБ 795 пункт 29. Разрешенное значение для этого расширения — одна строка с описанием средства подписи субъекта. Вот обычный вариант:
Расширение описано в приказе ФСБ 795 пункт 28.1. Разрешенное значение для этого расширения — одна строка с описанием средства идентификации субъекта. Вот варианты:
Расширение описано в приказе ФСБ 50 пункт 7. Разрешенные значение для этого расширения — две строки с датой временем:
Расширение описано на странице стандарта X.509 SubjectAlternativeName. Разрешенные значения для этого расширения — строки с доменными именами веб-сайта или строки с IP-адресами веб-сайта. Примеры строк:
Кроме этих расширений, которые передаются вместе с шаблоном, УЦ вставляет свои серверные расширения:
Когда УЦ получает шаблон сертификата, он выполняет следующие действия:
В containerName указываем заранее считыватель HDIMAGE, чтобы при создании контейнера КриптоПро не захотел спросить, в какой считыватель писать контейнер. На рабочей машине это просто всплывающее окошко, а вот при исполнении на сервере это приведет к ошибке — в серверном Linux нет окошек.string containerNamе = @"\\.\HDIMAGE\" + Guid.NewGuid().ToString();
Алгоритм выбирается в зависимости от запроса пользователя: PROVIDER_GOST_3410_2012_256BIT или PROVIDER_GOST_3410_2012_512BIT.
Эта команда создает пустой контейнер, в котором пока нет ключей:CryptAcquireContextA(out hContext, containerName, null, ProviderAlgorithm, CRYPT_NEWKEYSET)
Необходимо установить пин-код. Если этого не сделать, КриптоПро захочет вывести окошко с запросом пин-кода, что приведет к ошибке при исполнении в серверной среде:CryptSetProvParam(hContext, PP_KEYEXCHANGE_PIN, "", 0)
Генерируем пару ключей, которые разрешено экспортировать:CryptGenKey(hContext, AT_KEYEXCHANGE, CRYPT_EXPORTABLE,out hKey)
Получаем структуру CRYPT_PUBLICKEYBLOB, описанную здесь:CryptExportKey(hKey, IntPtr.Zero, PUBLICKEYBLOB, 0, null, ref len)
Все данные, которые отправил пользователь, объединяются с данными сервера и формируется тело сертификата. Тело сертификата — это структура TBSCertificate, которая подготавливается для подписи методом кодирования в нотации ASN.1 DER. После кодирования, нужно получить хеш от получившихся байтов следующими командами:
// Создание пустого объекта функции хеширования.
if (!Hashing.CryptCreateHash(hContext, (uint)HashType, IntPtr.Zero, 0, out hHash))
// Наполнение объекта функции хеширования данными.
Hashing.CryptHashData(hHash, buffer, (uint)read_length, 0)
// Получение итогового хеша
CryptGetHashParam(hHash, HP_HASHVAL, hash, ref cbHash, 0)
Подписывает получившийся сертификат подписью УЦ.
// Создаем объект хеширования на основе провайдера, в котором уже есть закрытый ключ для подписи
CryptCreateHash(prov, (uint)hashType, IntPtr.Zero, 0, out HashHandle hHash)
// Устанавливаем значение ранее посчитанного хеша
CryptSetHashParam(hHash, HP_HASHVAL, hashValue, 0)
// Подписываем хеш
CryptSignHashA(hHash, AT_KEYEXCHANGE, IntPtr.Zero, 0, retVal, ref size)
Закладывает получившийся сертификат внутрь контейнера
// Добавление байтов подписанного сертификата в ключевой контейнер
CryptSetKeyParam(hKey, KP_CERTIFICATE, rawCertificate, 0)
Отключает проверку срока действия закрытого ключа
Тема включения и отключения контроля активности подробно расписана на здесь.
// Создаем кодировку сообщения
message = new byte[] { 0x03, 0x02, 0x06, 0x00 }
// Заворачиваем сообщение в структуру CERT_EXTENSION
extension = new CERT_EXTENSION()
{
pszObjId = "1.2.643.2.2.37.3.11",
// Идентификатор контроля валидности
fCritical = false,
Value = new CRYPT_INTEGER_BLOB
{
cbData = 4,
pbData = message
}
}
// Добавление расширения в ключевой контейнер
CryptSetProvParam(hContext, PP_CONTAINER_EXTENSION, extension, 0)
Если пользователь запросил папку с контейнером, то ему сразу выгружается папка с контейнером.
// УЦ работает на операционной системе Linux. КриптоПро установили стандартный путь, по которому в Linux сохраняются контейнеры в считывателе HDIMAGE.
hdimage_path = "/var/opt/cprocsp/keys/" + Environment.UserName + "/";
// Упаковка контейнера в zip
path = hdimage_path + containerName;
using (MemoryStream ms = new MemoryStream())
{
using (ZipArchive archive = new ZipArchive(ms, ZipArchiveMode.Create, true))
{
foreach (var f in Directory.GetFiles(path)) archive.CreateEntryFromFile(f, containerName + "/" + Path.GetFileName(f));
}
return ms.ToArray();
}
Создание PFX по методу КриптоПро приблизительно повторяет метод, рекомендованный Microsoft:
Экспортировать хранилище в PFX.
Создается хранилище сертификатов в оперативной памяти.
Хранилище сертификатов в памяти:
CertOpenStore(CERT_STORE_PROV_MEMORY, 0, IntPtr.Zero, CERT_STORE_CREATE_NEW_FLAG, null)
Извлечение сертификата из контейнера:
CertAndStore.CryptGetKeyParam(hKey, KP_CERTIFICATE, data, ref len, 0)
К сертификату привязывается закрытый ключ в контейнере.
// Собираем информацию о провайдере
info = new CRYPT_KEY_PROV_INFO
{
pwszContainerName = GetUniqueName(),
cProvParam = 0,
dwFlags = 0,
dwKeySpec = AT_KEYEXCHANGE,
dwProvType = GetProviderType(),
pwszProvName = GetProviderName(),
rgProvParam = IntPtr.Zero
};
// Установка свойства сертификата — привязка в закрытому ключу
CertSetCertificateContextProperty(hCert, CERT_KEY_PROV_INFO_PROP_ID, 0, info)
Сертификат с привязанным контейнером закладывается в хранилище сертификатов в памяти.
// Добавляем сертификат в хранилище
CertAddCertificateContextToStore(hMemStore, hCert, Constants.CERT_STORE_ADD_NEW, IntPtr.Zero)
Всё хранилище сертификатов из памяти выгружается в PFX-файл и отдается пользователю
// Создание блоба для получения байтов PFX
blob = new CRYPT_INTEGER_BLOB
{
cbData = bufferSize,
pbData = buffData
}
// Создание PFX методом шифрования контейнера с ключами и сертификатом с указанием пароля для шифрования
PFXExportCertStoreEx(hMemStore, blob, password, IntPtr.Zero, EXPORT_PRIVATE_KEYS | REPORT_NO_PRIVATE_KEY | REPORT_NOT_ABLE_TO_EXPORT_PRIVATE_KEY)
Приведу пару самых распространенных шаблонов, которые вы можете подстроить под себя.
Шаблон пользователя в формате json, который можно отправить на вход сервису.
{
"notBefore": "2024-04-20T17:24:15.272Z",
"notAfter": "2044-04-20T17:24:15.272Z",
"subject": [
{ "id": "E", "name": "mail@mail.ru" },
{ "id": "CN", "name": "Алексей Алексеев" },
{ "id": "O", "name": "АО «Рога и копыта»" },
{ "id": "L", "name": "Москва" },
{ "id": "S", "name": "77 Москва" },
{ "id": "STREET", "name": "пр.Походный д7" },
{ "id": "SN", "name": "Алексеев" },
{ "id": "G", "name": "Алексей" },
{ "id": "ИНН", "name": "7733221100" },
{ "id": "ОГРН", "name": "1122743344555" },
{ "id": "СНИЛС", "name": "05656676767" },
{ "id": "C", "name": "RU" }
],
"extensions": [
{
"critical": false,
"name": "ExtendedKeyUsage",
"values": [
"1.3.6.1.5.5.7.3.2",
"1.3.6.1.5.5.7.3.3",
"1.3.6.1.5.5.7.3.4",
"1.3.6.1.4.1.311.20.2.2"
]
},
{
"critical": true,
"name": "KeyUsage",
"values": [
"digitalSignature",
"nonRepudiation",
"keyEncipherment",
"dataEncipherment"
]
},
{
"critical": false,
"name": "IssuerSignTool",
"values": [
"Средство электронной подписи: СКЗИ \"КриптоПро CSP\" версия 4.0 R4 (исполнение 2-Base)",
"Заключение на средство УЦ: Сертификат соответствия № СФ/128-4272 от 13.07.2022",
"Средство УЦ: ПАК «Удостоверяющий центр «КриптоПро УЦ» версии 2.0» (вариант исполнения 9)",
"Заключение на средство ЭП: Сертификат соответствия № СФ/124-3971 от 15.01.2021"
]
},
{
"critical": false,
"name": "SubjectSignTool",
"values": [
"Средство электронной подписи: СКЗИ \"КриптоПро CSP\""
]
},
{
"critical": false,
"name": "IdentificationKind",
"values": [
"RemoteSystem"
]
}
]
}
Шаблон веб-сервера в формате json, который можно отправить на вход сервису.
{
"notBefore": "2024-04-20T17:24:15.272Z",
"notAfter": "2044-04-20T17:24:15.272Z",
"subject": [
{ "id": "CN", "name": "learn.microsoft.com" },
{ "id": "O", "name": "Microsoft Corporation" },
{ "id": "L", "name": "Redmond" },
{ "id": "S", "name": "WA" },
{ "id": "C", "name": "US" }
],
"extensions": [
{
"critical": false,
"name": "SubjectAlternativeName",
"values": [
"www.learn.microsoft.com",
"learn.microsoft.com"
]
},
{
"critical": false,
"name": "ExtendedKeyUsage",
"values": [
"1.3.6.1.5.5.7.3.2",
"1.3.6.1.5.5.7.3.1"
]
},
{
"critical": true,
"name": "KeyUsage",
"values": [
"digitalSignature"
]
},
{
"critical": false,
"name": "IssuerSignTool",
"values": [
"Средство электронной подписи: СКЗИ \"КриптоПро CSP\" версия 4.0 R4 (исполнение 2-Base)",
"Заключение на средство УЦ: Сертификат соответствия № СФ/128-4272 от 13.07.2022",
"Средство УЦ: ПАК «Удостоверяющий центр «КриптоПро УЦ» версии 2.0» (вариант исполнения 9)",
"Заключение на средство ЭП: Сертификат соответствия № СФ/124-3971 от 15.01.2021"
]
},
{
"critical": false,
"name": "SubjectSignTool",
"values": [
"Средство электронной подписи: СКЗИ \"КриптоПро CSP\""
]
},
{
"critical": false,
"name": "IdentificationKind",
"values": [
"RemoteSystem"
]
}
]
}
У всех выдаваемых сертификатов по правилам должен быть уникальный серийный номер. Я нарушил правило формирования серийного номера — сделал его случайным. Но уровень случайности в нем даже выше, чем у GUID, поэтому очень маловероятно повторение серийных номеров.
Это самая простая функция моего УЦ. Корневой сертификат зашит в контейнер с УЦ в виде статического файла, который выдается методом обычного скачивания файла с веб-сервера. Когда он закончится, контейнер нужно будет пересобрать с новым сертификатом.
В связи с тем, что систему отзыва сертификатов мне было делать совершенно некогда, я решил, что буду выдавать список отзыва с одним из моих тестовых сертификатов на лету. Формат списка отзыва описан в RFC 5280. Также как и сертификат, основные поля — это тело списка отзыва, алгоритм подписи и сама подпись.
Основные поля списка отзыва — дата публикации, дата следующей публикации, список серийных номеров отозванных сертификатов и даты их отзыва.
ThisUpdate = new UTCTime(DateTime.Now),
NextUpdate = new UTCTime(DateTime.Now.AddDays(1)),
RevokedCertificates = new SequenceOf<RevokedCertificate>
{
new RevokedCertificate
{
UserCertificate = new CertificateSerialNumber([0xc2, 0x96, 0xc7, 0xe1, xbb, 0x49, 0xb0, 0xf0, 0x7d, 0x31, 0xbb, 0xf6, 0x97, 0x97, 0xc5, 0x35, 0xa9, x5a, 0x56, 0xa7]),
RevocationDate = new UTCTime(new DateTimeOffset(2024,04,01, 01, 01, 01, imeSpan.FromHours(3)))
}
}
После создания тела, его надо закодировать в ASN.1 DER и захешировать.
// Создание пустого объекта функции хеширования.
if (!Hashing.CryptCreateHash(hContext, (uint)HashType, IntPtr.Zero, 0, out hHash))
// Наполнение объекта функции хеширования данными.
Hashing.CryptHashData(hHash, buffer, (uint)read_length, 0)
// Получение итогового хеша.
CryptGetHashParam(hHash, HP_HASHVAL, hash, ref cbHash, 0)
// Создаем объект хеширования на основе провайдера, в котором уже есть закрытый ключ для подписи
CryptCreateHash(prov, (uint)hashType, IntPtr.Zero, 0, out HashHandle hHash)
// Устанавливаем значение ранее посчитанного хеша
CryptSetHashParam(hHash, HP_HASHVAL, hashValue, 0)
// Подписываем хеш
CryptSignHashA(hHash, AT_KEYEXCHANGE, IntPtr.Zero, 0, retVal, ref size)
В связи с тем, что в этом списке всегда один сертификат, то все остальные выданные сертификаты будут считаться действующими и их нельзя будет отозвать на моем УЦ. Также я нарушил правило формирования серийного номера списка отозванных сертификатов. Он должен быть уникальным среди всех опубликованных списков, а я сделал его случайным. Но уровень случайности в нем даже выше, чем у GUID, поэтому очень маловероятно, что серийные номера повторятся.
В связи с тем, что систему отзыва сертификатов мне было делать совершенно некогда, я решил что мой сервис OCSP будет на каждый запрос честно сообщать, что запрашиваемый сертификат не был отозван. Описание структур OCSP запроса и ответа находятся в RFC 6960 и RFC 8954. Сервис сделан аналогично сервису выдачи списков отзыва.
Сервер получает информацию о проверяемом сертификате. В запросе к серверу OCSP передаются следующие данные:
ReqCert = new CertID
{
HashAlgorithm = new AlgorithmIdentifier
{
Algorithm = new ObjectIdentifier("1.3.14.3.2.26"), // sha1NoSign
Parameters = Null.Instance
},
IssuerNameHash = new OctetString(issuerNameHash),
IssuerKeyHash = new OctetString(issuerKeyHash),
SerialNumber = new CertificateSerialNumber(serialNumber)
}
Добавляет к информации о сертификате статус неотозванности.
Responses = new SequenceOf<SingleResponse>(request.TbsRequest.RequestList.Select(r=> new SingleResponse
{
CertID = r.ReqCert,
CertStatus = new Null(),
ThisUpdate = DateTime.Now,
NextUpdate = DateTime.Now.AddDays(1))
}).ToList()),
После создания тела, его надо закодировать в ASN.1 DER и захешировать.
// Создание пустого объекта функции хеширования.
if (!Hashing.CryptCreateHash(hContext, (uint)HashType, IntPtr.Zero, 0, out hHash))
// Наполнение объекта функции хеширования данными.
Hashing.CryptHashData(hHash, buffer, (uint)read_length, 0)
// Получение итогового хеша.
CryptGetHashParam(hHash, HP_HASHVAL, hash, ref cbHash, 0)
Подписывает хеш тела ответа подписью УЦ и отдает пользователю.
// Cоздаем объект хеширования на основе провайдера, в котором уже есть закрытый ключ для подписи
CryptCreateHash(prov, (uint)hashType, IntPtr.Zero, 0, out HashHandle hHash)
// Устанавливаем значение ранее посчитанного хеша
CryptSetHashParam(hHash, HP_HASHVAL, hashValue, 0)
// Подписываем хеш
CryptSignHashA(hHash, AT_KEYEXCHANGE, IntPtr.Zero, 0, retVal, ref size)
Сервис штампов времени предназначен для подтверждения существования конкретного документа в конкретный момент времени. Принцип работы этого сервиса — пользователь присылает хеш документа, а в ответ УЦ берет этот хеш, прикладывает к нему время и все подписывает своей подписью, чтобы подтвердить истинность. Если вы хотите, чтобы мой УЦ выдавал правильное время, его надо настроить на уровне операционной системы Docker-контейнера. Описание структур TSP-запроса и ответа находятся в RFC 3161.
Сервер получает информацию о документе.
В запросе на штамп времени передаются следующие данные: алгоритм хеширования, хеш документа.
messageImprint = new MessageImprint()
{
HashAlgorithm = new Cryptography.X509.AlgorithmIdentifier(new ObjectIdentifier(hashAlg),Null.Instance, true),
HashedMessage = new OctetString(new byte[] { 0x9e, 0x3a, 0x6c, 0xc9, 0xfa, 0x65, 0x5a,0x1e, 0x55, 0xd8, 0x4e, 0x77, 0x95, 0xda, 0x2e, 0x53, 0x3b, 0x84, 0xe0, 0x44, 0xe4, 0xe0 0x62, 0xc2, 0x09, 0x79, 0x5d, 0xf2, 0x6a, 0xf3, 0x74, 0x73 })
};
Добавляет к информации о документе текущее время и хеширует получившееся тело ответа.
info = new TSTInfo
{
version = 1,
policy = request.reqPolicy ?? new ObjectIdentifier("1.2.643.3.4.1.1.5"),
messageImprint = request.messageImprint,
serialNumber = new Integer(serialNumber),
genTime = DateTimeOffset.UtcNow,
nonce = request.nonce
};
После создания тела, его надо закодировать в ASN.1 DER и захешировать.
// Создание пустого объекта функции хеширования
if (!Hashing.CryptCreateHash(hContext, (uint)HashType, IntPtr.Zero, 0, out hHash))
// Наполнение объекта функции хеширования данными
Hashing.CryptHashData(hHash, buffer, (uint)read_length, 0)
// Получение итогового хеша
CryptGetHashParam(hHash, HP_HASHVAL, hash, ref cbHash, 0)
Подписывает хеш тела ответа подписью УЦ и отдает пользователю.
Во всех предыдущих случаях УЦ ставил "голую" подпись под телом ответа. Под "голой" подписью подразумевается байты, которые получаются после применения формулы подписи ассиметричным ключом. В "голой" подписи нет никакой мета-информации: кто подписывал или когда, или каким алгоритмом. Со штампом времени стандарт обязует ставить полноценную подпись по стандарту CADES-BES, т.е. кроме самой "голой" подписи надо приложить некоторое количество метаинформации о подписавшем. Приводить здесь весь код создания полноценной подписи считаю нецелесообразным из-за ветвистого кода, размазанного по многим классам.
Чтобы сервис мог быть запущен в системе контейнеризации, его нужно завернуть в контейнер, а затем запустить в рамках системы контейнеризации. Заворачивание в контейнер начинается с главного шага — выбор базового образа. Я изначально решил, что операционная система у меня будет Linux, далее я посмотрел, какие операционные системы поддерживаются в КриптоПро CSP 5.0. Я решил остановиться на Debian 12, но сервис может работать практически в любом Linux. Я взял Debian, установил в него КриптоПро CSP 5.0 без лицензии и создал базовую операционную систему.
//Берем пустой Debian 12 из докер-хаба
FROM debian:stable-slim AS needs-squashing
//Копируем дистрибутив криптоПро 5.0 R2
COPY ./linux-amd64_deb /cpro/
//Устаналиваем КриптоПро, устанавливаем необходимые библиотеки для запуска .NET приложений
RUN chmod +x /cpro/install.sh \
&& cd /cpro/ \
&& ls -lahR \
&& ./install.sh \
&& rm -rf /cpro \
&& apt-get update \
&& apt-get install -y libc6 libgcc-s1 libicu72 libssl3 libstdc++6 zlib1g
//склеиваем все слои, чтобы в итоге был один слой
FROM scratch
COPY --from=needs-squashing / /
Для построения сервиса из исходных кодов я взял стандартный образ на базе Debian от Microsoft, в нем стоит .NET SDK для сборки проекта. Потом создал Dockerfile по официальным правилам.
# берем базовую операционную систему
FROM debian-cryptopro:5.0.R2.01 as base
# берем контейнер для построения из исходных кодов
FROM mcr.microsoft.com/dotnet/sdk:8.0 as build
# настраиваем окружение для построения проекта
ENV DOTNET_CLI_TELEMETRY_OPTOUT=1
ENV DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=1
WORKDIR "/src/"
# копируем исходные коды
COPY . .
# строим сервис
RUN dotnet publish Example/FreeGostCA/FreeGostCA.csproj --configuration Release --output /src/bin/publish/ -r linux-x64 --self-contained true /p:DefineConstants=LINUX /p:Version=1.0.0.1
# начинаем создание итогового контейнера
FROM base as final
EXPOSE 5000
WORKDIR /app
# копируем файлы построенного сервиса в конечный контейнер из контейнера-построителя
COPY --from=build /src/bin/publish/ /app/
# В конечном докер-контейнере сервис будет выполняться из-под учетной записи nobody
# Поэтому закрытый ключ УЦ надо положить в личную папку пользователя nobody
RUN mkdir -p /var/opt/cprocsp/keys/nobody/a50dc676.000
COPY ./Example/FreeGostCA/wwwroot/resources/a50dc676.000 /var/opt/cprocsp/keys/nobody/a50dc676.000
RUN chown -R nobody:nogroup /var/opt/cprocsp/keys/nobody
RUN chmod -R 700 /var/opt/cprocsp/keys/nobody
# копируем гамму на 100 000 ключей, сгенерированную заранее внутрь специальной папки dsrf
COPY ./Example/FreeGostCA/wwwroot/resources/db1 /var/opt/cprocsp/dsrf/db1
COPY ./Example/FreeGostCA/wwwroot/resources/db2 /var/opt/cprocsp/dsrf/db2
RUN chmod -R 777 /var/opt/cprocsp/dsrf/db1
RUN chmod -R 777 /var/opt/cprocsp/dsrf/db2
# Сообщаем КриптоПро CSP, чтобы он использовал гаммы с приоритетом 3 (т.е. перед использованием Биологического ДСЧ)
RUN /opt/cprocsp/sbin/amd64/cpconfig -hardware rndm -add cpsd -name 'cpsd rng' -level 3
# Указываем КриптоПро CSP где лежат гаммы
RUN /opt/cprocsp/sbin/amd64/cpconfig -hardware rndm -configure cpsd -add string /db1/kis_1 /var/opt/cprocsp/dsrf/db1/kis_1
RUN /opt/cprocsp/sbin/amd64/cpconfig -hardware rndm -configure cpsd -add string /db2/kis_1 /var/opt/cprocsp/dsrf/db2/kis_1
# Переключаемся в бесправного пользователя nobody чтобы слегка защитить наш контейнер
USER nobody
# При запуске контейнера в него устанавливается лицензия КриптоПро CSP 5.0 R2 и если установка прошла успешно, запускаем наш сервис
ENTRYPOINT /opt/cprocsp/sbin/amd64/cpconfig -license -setlocal $CPROCSP_LICENSE && /app/FreeGostCA
Команда для построения Docker-контейнера в рамках Docker Desktop:
docker build -f Example\FreeGostCA\Dockerfile --progress=plain --no-cache -t aksenov3000/freegostca:1.0.1.06 .\
Команда для отправки получившегося контейнера в Docker Hub.
docker push aksenov3000/freegostca:1.0.1.06
После успешной отправки контейнера в Docker Hub, нужно запустить его в рамках системы контейнеризации. Это может docker, docker-compose, docker-swarm, kubernetes или что-то еще в этом роде. Я решил внедрить в Kubernetes, но вы можете переписать мой манифест на нужную вам систему контейнеризации.
Для успешного запуска контейнера в Kubernetes, нужно сделать четыре манифеста — namespace, deploy, service, ingress. В рамках deploy нужно произвести настройки контейнера, передав ему необходимые стартовые параметры.
//namespace
apiVersion: v1
kind: Namespace
metadata:
name: freegostca
labels:
name: freegostca
Отдельный namespace рекомендуется для удобства и капельки безопасности.
//deploy
apiVersion: apps/v1
kind: Deployment
metadata:
name: freegostca
namespace: freegostca
labels:
app: freegostca
spec:
replicas: 1
selector:
matchLabels:
app: freegostca
template:
metadata:
labels:
app: freegostca
spec:
containers:
- name: freegostca
image: aksenov3000/freegostca:1.0.0.13
env:
- name: ASPNETCORE_URLS
value: "http://0.0.0.0:5000"
- name: DOTNET_SYSTEM_GLOBALIZATION_INVARIANT
value: "1"
- name: CPROCSP_LICENSE
value: "50500-ХХХХХ-ХХХХХ-ХХХХХ-ХХХХХ"
- name: ApplicationConfig__SigningContainerName
value: "\\\\.\\HDIMAGE\\a50dc676-efb5-4761-a043-3f5be820da5f"
- name: ApplicationConfig__SigningContainerNamePin
value: ""
- name: ApplicationConfig__SigningContainerNameAlgorithm
value: "GOST_R3411_2012_512"
- name: ApplicationConfig__OCSPAddress
value: "http://freegostca.aksenov.pro/ca/ocspservice"
- name: ApplicationConfig__CertificateAddress
value: "http://freegostca.aksenov.pro/ca/root1.crt"
- name: ApplicationConfig__CRLAddress
value: "http://freegostca.aksenov.pro/ca/root1.crl"
- name: Logging__LogLevel__Default
value: Information
- name: Logging__LogLevel__Microsoft
value: Information
- name: ApplicationInsights__EnableRequestTrackingTelemetryModule
value: "false"
- name: ApplicationInsights__EnableEventCounterCollectionModule
value: "false"
- name: ApplicationInsights__EnableDependencyTrackingTelemetryModule
value: "false"
- name: ApplicationInsights__EnablePerformanceCounterCollectionModule
value: "false"
- name: ApplicationInsights__EnableDiagnosticsTelemetryModule
value: "false"
- name: AllowedHosts
value: "*"
ports:
- containerPort: 5000
name: http-web-svc
Deploy нужен для описания процесса запуска и работы контейнеров в кластере Kubernetes.
Описание входных параметров:
//service
apiVersion: v1
kind: Service
metadata:
name: freegostca-service
namespace: freegostca
spec:
selector:
app: freegostca
ports:
- protocol: TCP
port: 80
targetPort: http-web-svc
Service нужен для внутренней балансировки трафика внутри Kubernetes, и очень пригождается, если вы решите запустить несколько экзепляров одного и того же контейнера. Также service делает подмену портов.
//ingress
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
annotations:
nginx.ingress.kubernetes.io/client-body-buffer-size: "30m"
nginx.ingress.kubernetes.io/proxy-body-size: "30m"
name: ingress-freegostca
namespace: freegostca
spec:
ingressClassName: nginx
rules:
- host: "freegostca.aksenov.pro"
http:
paths:
- pathType: Prefix
path: "/"
backend:
service:
name: freegostca-service
port:
number: 80
Ingress нужен для настройки внутреннего реверсивного прокси Kubernetes. Здесь нужно указать внешнее доменное имя УЦ.
Демонстрационный контур я развернул на своем доменном имени. В связи с тем, что образ УЦ лежит в публичном репозитории Docker Hub, вы можете взять себе этот образ и развернуть его в своей тестовой среде.
Я всю разработку веду под Windows в своей любимой Visual Studio. Моя платформа .NET позволяет компилировать запускаемые файлы под много операционных систем, в том числе под Linux. Я привык к тому, что все что сделано под Windows будет также работать под Linux. С КриптоПро оказалось совсем не так.
КриптоПро очень по разному работает со строками на уровне неуправляемого кода. В некоторых структурах используются строки с однобайтовой кодировкой (ANSI). В других структурах используются строки с двухбайтовой кодировкой (UNICODE). В редких местах используются строки с четырехбайтовой кодировкой (UTF32). В документации местами упоминается ANSI, местами UNICODE, но вот про UTF32 я не нашел нигде упоминаний. И все было бы хорошо, если в разных структурах были бы разные кодировки.
Самое тяжёлое состояло в том, что одни и теже методы (например CertSetCertificateContextProperty) под Windows работают в кодировке Unicode а под Linux - UTF32. Более того, под Linux при возникновении ошибки, код ошибки не выдается. Это привело к тому, что запустив проект на Windows - я обрадовался что он заработал, а когда залил проект в Linux - я увидел что многие вызовы функций сломались, и вместо вменяемого кода ошибки я всегда получаю 0x80070000 (что на языке CryptoApi значит ERROR_SUCCESS - The operation completed successfully.) Я потратил много часов чтобы разобраться с каждой сломавшейся функцией в отдельности. Также я осознал что UTF32 не поддерживается стандартным маршаллером .NET и мне пришлось писать свой собственный маршаллер для получения строк заканчивающихся нулем в кодировке UTF32 из неуправляемого кода. Этот маршаллер в зависимости от операционной системы работает с разной кодировкой строк.
P.S. Сертифицированная версия КриптоПро оказывается содержит множество багов, на некоторые из которых мне также пришлось наступить.
Хотелось за две недели написать свой УЦ — получилось.
Хотелось сделать удобный УЦ для тестировщиков — частично получилось (нельзя задать имя контейнера заранее).
Хотелось сделать бесплатный УЦ — частично получилось (нужно купить лицензию КриптоПро CSP за 5 тысяч рублей).
Хотелось сделать "рюшечки" OCSP, CRL, TSP — частично получилось (системы работают, но в холостую, нельзя отозвать сертификат).
В общем, я доволен!
Чтобы использовать софт этого УЦ, этот софт нужно сертифицировать — у меня нет сертификации.
Нужно вести учет всех выданных сертификатов — я не запоминаю выданные сертификаты.
Нужно уметь отзывать сертификаты — я не умею пока отзывать сертификаты.
Нужно следить за уникальностью серийных номеров (как сертификатов, так и CRL, и OCSP) — я не слежу за их уникальностью.
По закону нельзя выдавать сертификаты сроком более чем на 15 месяцев (есть исключения для серьезных носителей) — я выдаю сертификаты без ограничения во времени.
Нельзя, чтобы УЦ видел закрытый ключ выдаваемого сертификата — мой УЦ сам их генерирует и поэтому все знает.
УЦ должен удостоверять соотвествие открытого ключа и поля subject — я ничего нигде не удостоверяю, просто выдаю не глядя.
После 100 000 выданных сертификатов УЦ остановится и попросит новую гамму.
После окончания срока действия корневого сертификата УЦ остановится и попросит новый корневой сертификат.