Предыстория и предпосылки

Пятнадцать лет назад мне по работе пришлось познакомиться с администрированием удостоверяющего центра КриптоПро 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. Шаблон состоит из полей:

На данный момент я решил поддержать только самые распространенные расширения:

KeyUsage — назначение сертификата

Расширение описано на странице стандарта X.509 KeyUsage. Разрешенные значения для этого расширения:

ExtendedKeyUsage — расширенные полномочия сертификата

Расширение описано на странице стандарта X.509 ExtendedKeyUsage. Разрешенными значениями для этого расширения могут быть любые OID-ы. Вот самые распространненные из них:

CertificatePolicies — политики сертификата

Расширение описано на странице стандарта X.509 CertificatePolicies. Разрешенными значениями для этого расширения могут быть любые OID-ы. Вот самые распространенные из них:

IssuerSignTool — средство подписи издателя

Расширение описано в приказе ФСБ 795 пункт 30. Разрешенными значениями для этого расширения могут быть любые четыре строчки, описывающие средства и лицензии. Вот обычный комплект:

SubjectSignTool — средство подписи субъекта

Расширение описано в приказе ФСБ 795 пункт 29. Разрешенное значение для этого расширения — одна строка с описанием средства подписи субъекта. Вот обычный вариант:

IdentificationKind — средство идентификации субъекта

Расширение описано в приказе ФСБ 795 пункт 28.1. Разрешенное значение для этого расширения — одна строка с описанием средства идентификации субъекта. Вот варианты:

PrivateKeyUsagePeriod — срок действия закрытого ключа

Расширение описано в приказе ФСБ 50 пункт 7. Разрешенные значение для этого расширения — две строки с датой временем:

SubjectAlternativeName — альтернативные имена

Расширение описано на странице стандарта X.509 SubjectAlternativeName. Разрешенные значения для этого расширения — строки с доменными именами веб-сайта или строки с IP-адресами веб-сайта. Примеры строк:

Кроме этих расширений, которые передаются вместе с шаблоном, УЦ вставляет свои серверные расширения:

Когда УЦ получает шаблон сертификата, он выполняет следующие действия:

  1. Создает новый пустой контейнер на диске.

В 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)

  1. Устанавливает на контейнер пустой пин-код (по сути отключает защиту по пин-коду).

Необходимо установить пин-код. Если этого не сделать, КриптоПро захочет вывести окошко с запросом пин-кода, что приведет к ошибке при исполнении в серверной среде:
CryptSetProvParam(hContext, PP_KEYEXCHANGE_PIN, "", 0)

  1. Генерирует пару открытого и закрытого ключа в новый контейнер на диске, которые можно экспортировать.

Генерируем пару ключей, которые разрешено экспортировать:
CryptGenKey(hContext, AT_KEYEXCHANGE, CRYPT_EXPORTABLE,out hKey)

  1. Получает открытый ключ и его параметры, для дальнейших ключа и параметров в сертификат.

Получаем структуру CRYPT_PUBLICKEYBLOB, описанную здесь:
CryptExportKey(hKey, IntPtr.Zero, PUBLICKEYBLOB, 0, null, ref len)

  1. Объединяет шаблон сертификата с серверными расширениями, создает тело сертификата и хеширует его.

Все данные, которые отправил пользователь, объединяются с данными сервера и формируется тело сертификата. Тело сертификата — это структура 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)
  1. Подписывает получившийся сертификат подписью УЦ.

     // Создаем объект хеширования на основе провайдера, в котором уже есть закрытый ключ для подписи
     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)
    
  2. Закладывает получившийся сертификат внутрь контейнера

     // Добавление байтов подписанного сертификата в ключевой контейнер
     CryptSetKeyParam(hKey, KP_CERTIFICATE, rawCertificate, 0)
    
  3. Отключает проверку срока действия закрытого ключа

    Тема включения и отключения контроля активности подробно расписана на здесь.

     // Создаем кодировку сообщения
     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)
    
  4. Если пользователь запросил папку с контейнером, то ему сразу выгружается папка с контейнером.

    // УЦ работает на операционной системе 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();
    }
  1. Если пользователь запросил PFX, то приключения продолжаются.

Создание PFX по методу КриптоПро приблизительно повторяет метод, рекомендованный Microsoft:

Хранилище сертификатов в памяти:

    CertOpenStore(CERT_STORE_PROV_MEMORY, 0, IntPtr.Zero, CERT_STORE_CREATE_NEW_FLAG, null)
  1. Из контейнера извлекается только что созданный сертификат.

Извлечение сертификата из контейнера:

    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)
  1. Сертификат с привязанным контейнером закладывается в хранилище сертификатов в памяти.

    // Добавляем сертификат в хранилище
    CertAddCertificateContextToStore(hMemStore, hCert, Constants.CERT_STORE_ADD_NEW, IntPtr.Zero)
    
  2. Всё хранилище сертификатов из памяти выгружается в 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 будет на каждый запрос честно сообщать, что запрашиваемый сертификат не был отозван. Описание структур 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, т.е. кроме самой "голой" подписи надо приложить некоторое количество метаинформации о подписавшем. Приводить здесь весь код создания полноценной подписи считаю нецелесообразным из-за ветвистого кода, размазанного по многим классам.

Обертывание сервиса в Docker-контейнер

Чтобы сервис мог быть запущен в системе контейнеризации, его нужно завернуть в контейнер, а затем запустить в рамках системы контейнеризации. Заворачивание в контейнер начинается с главного шага — выбор базового образа. Я изначально решил, что операционная система у меня будет Linux, далее я посмотрел, какие операционные системы поддерживаются в КриптоПро CSP 5.0. Я решил остановиться на Debian 12, но сервис может работать практически в любом Linux. Я взял Debian, установил в него КриптоПро CSP 5.0 без лицензии и создал базовую операционную систему.

Dockerfile для построения Docker-контейнера

    //Берем пустой 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 по официальным правилам.

Dockerfile для построения Docker-контейнера

    # берем базовую операционную систему 
	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 выданных сертификатов УЦ остановится и попросит новую гамму. После окончания срока действия корневого сертификата УЦ остановится и попросит новый корневой сертификат.