.NET 7 против российской криптографии

Разрабатывая один проект на свежем .NET 7 столкнулся с необходимостью подписывать данные с использованием отечественных криптоалгоритмов. Ранее, в .NET Framework хорошая поддержка работы с со сторонними криптопровайдерами, реализующими семейство алгоритмов ГОСТ (CryptoPro CSP, ViPNet CSP и пр.), шла "из коробки". По старой памяти набросал код с использованием SignedCms, и voilà:

System.Security.Cryptography.CryptographicException: 'Could not determine signature algorithm for the signer certificate.'

Ясно-понятно, что в новом фреймворке старые работающие технологии помножились на ноль.

Что пошло не так?

Поверхностная отладка исходников .NET быстро вывела на баг, висящий уже пять лет. Очевидно, что в .NET Team использование ГОСТ Р 34.10-2012 даром никому не нужно, поэтому на проблему подключения сторонних криптопровайдеров основательно забили.

Что делать?

Первая мысль была "фреймворк же теперь опенсорс, запили сам". Но вырисовывающий объём работы отпугнул делать "с колёс", да и согласовывать API Proposal - тот ещё квест.

Искать другую библиотеку не хотелось, вариант с Bouncy Castle, которая только полгода назад после двухлетнего ожидания применила несколько PR с поддержкой российской криптографии для CMS, тоже к сожалению отпал, так как с отечественными токенами она работать не желает (или я плохо искал).

К счастью, в дебрях компьютера нашёлся мой старый код, написанный уже в далёком 2015 году для тех же целей, но на голом WinAPI. А так как P/Invoke из .NET пока не выпилили, и поддержки кроссплатформенности не требовалось, то адаптировать старый код под новые реалии оказалось делом техники. Подпись и проверка заработали после пары вечеров кодинга, большая часть которого ушла на написание обменных оболочек для CryptoAPI.

Детали реализации через WinAPI

В WinAPI вся работа CMS-сообщениями сводится к использованию небольшого набора функций с заполнением бесчисленного количества структур. Всё необходимое детально описано в документации Microsoft, но чтобы разобраться с нуля нужно потратить довольно много усилий и обойти несколько подводных камней. Далее я опишу основной путь решения задачи, достаточный для получения работающего решения.

Полный код подписания ЭП и проверки подписи, описанный далее, доступен на GitHub.

Disclaimer. Всё это было написано с помощью unsafe-кода с указателями и новых возможностей C#, наподобие Span<T>. Возможно, это не совсем по классике "C# - типобезопасный язык" и т.п., но было интересно попробовать новые фишки платформы для максимального упрощения написания низкоуровнего кода, а также для достижения высокой производительности, исключающей лишние выделения памяти в управляемой куче и преобразования типов при классическом маршаллинге.

Подпись данных

Если опустить все лишние детали, то последовательность операций для подписи произвольных данных следующая:

  1. Получить закрытый ключ сертификата с помощью CryptAcquireCertificatePrivateKey.

  2. Создать пустое CMS-сообщение с помощью CryptMsgOpenToEncode.

  3. Загрузить в него исходные данные с помощью CryptMsgUpdate.

  4. Извлечь подписанное и закодированное CMS-сообщение в виде массива байтов с помощью CryptMsgGetParam.

Теперь то же самое чуть подробнее и с примерами кода. Начинаем с получения закрытого ключа по заданному сертификату:

// acquire certificate private key
var flags = (silent ? CRYPT_ACQUIRE_SILENT_FLAG : 0U) | CRYPT_ACQUIRE_COMPARE_KEY_FLAG;
CryptAcquireCertificatePrivateKey(certificate.Handle, flags, 0,
	out var hProvider, out var dwKeySpec, out var pfCallerFreeProv).VerifyWinapiTrue();
try
{
...		
}
finally
{
	if (pfCallerFreeProv)
		if (dwKeySpec == CERT_NCRYPT_KEY_SPEC)
			NCryptFreeObject(hProvider);
		else
			CryptReleaseContext(hProvider, 0);
}

Функция CryptAcquireCertificatePrivateKey возвращает нам ссылку на криптопровайдер, привязанный к сертификату, тип ключа и флаг необходимости удаления свежесозданного криптопровайдера после окончания использования. Оборачиваем всё в try/finally, так как выделяемые системой ресурсы неуправляемые, сборщиком мусора не контролируются, и всё до последнего байта придётся освобождать вручную.

Методы-хелперы VerifyWinapiTrue() и VerifyWinapiNonzero() используются для анализа возвращаемого значения фунций WinAPI и генерации исключения Win32Exception в случае неуспешного выполнения, с кодом ошибки, получаемого вызовом GetLastError.

Единственный нюанс в фрагменте выше - это использование флага CRYPT_ACQUIRE_SILENT_FLAG. Он нужен в случаях, когда вы не хотите отображать никаких диалоговых окон по типу "вставьте носитель" или "введите PIN-код для ключевого контейнера", например, при подписи из службы. Этот флаг запрещает отображать любые окна в процессе использования закрытого ключа, но тогда вам придётся вручную указать PIN-код, чтобы криптопровайдер мог корректно выполнить свою работу. Для этого используем такой код:

if (pin.Length > 0)
{
	// set PIN-code for the private key
	var asciiPinLength = Encoding.ASCII.GetByteCount(pin);
	var asciiPin = stackalloc byte[asciiPinLength + 1];
	Encoding.ASCII.GetBytes(pin, new Span<byte>(asciiPin, asciiPinLength));
	if (dwKeySpec == AT_KEYEXCHANGE)
		CryptSetProvParam(hProvider, PP_KEYEXCHANGE_PIN, (nint)asciiPin, 0).VerifyWinapiTrue();
	else if (dwKeySpec == AT_SIGNATURE)
		CryptSetProvParam(hProvider, PP_SIGNATURE_PIN, (nint)asciiPin, 0).VerifyWinapiTrue();
}

Основная тонкость здесь в том, что PIN-код должен обязательно быть в однобайтной кодировке ASCII (интересно, что будет, если поставить на носитель русскоязычный PIN?).

Получив, закрытый ключ, заполняем несколько структур с информацией о подписанте и сертификате подписи, после чего создаём CMS-сообщение.

// acquiring certificate context
var certContext = new ReadOnlySpan<CERT_CONTEXT>(certificate.Handle.ToPointer(), 1);
var signerCertBlob = new CRYPT_INTEGER_BLOB
{
	cbData = certContext[0].cbCertEncoded,
	pbData = certContext[0].pbCertEncoded
};

// prepare CMSG_SIGNER_ENCODE_INFO structure
var signerInfo = new CMSG_SIGNER_ENCODE_INFO();
signerInfo.cbSize = (uint)Marshal.SizeOf(signerInfo);
signerInfo.pCertInfo = certContext[0].pCertInfo;
signerInfo.hKey = hProvider;
signerInfo.dwKeySpec = dwKeySpec;
signerInfo.HashAlgorithm.pszObjId = (nint)digestOidRaw;

// prepare CMSG_SIGNED_ENCODE_INFO structure
var signedInfo = new CMSG_SIGNED_ENCODE_INFO();
signedInfo.cbSize = (uint)Marshal.SizeOf(signedInfo);
signedInfo.cSigners = 1;
signedInfo.rgSigners = (nint)(&signerInfo);
signedInfo.cCertEncoded = 1;
signedInfo.rgCertEncoded = (nint)(&signerCertBlob);

// create CMS
var hMsg = CryptMsgOpenToEncode(MsgEncodingTypes.X509_ASN_ENCODING | MsgEncodingTypes.PKCS_7_ASN_ENCODING,
	detachedSignature ? CMSG_DETACHED_FLAG : 0U, MsgType.CMSG_SIGNED, (nint)(&signedInfo), null, 0).VerifyWinapiNonzero();
try
{
	...
}
finally
{
	CryptMsgClose(hMsg);
}

Здесь изюминка в получении контекста сертификата. Согласно документации, X509Certificate.Handle уже является указателем на неуправляемую структуру CERT_CONTEXT. Поэтому для доступа к контексту достаточно привести указатель к типу ReadOnlySpan<CERT_CONTEXT> и можно смело её использовать без всякого маршаллинга.

Кроме того, следует обратить внимание на флаг CMSG_DETACHED_FLAG функции CryptMsgOpenToEncode. Его необходимо установить, если вы хотите получить отделённую подпись, т.е. CMS-сообщение, содержащее только значение хэш-функции от исходных данных, и не включающее сами данные.

После использования сообщения, как и ранее с криптопровайдером, необходимо явно освободить его с помощью вызова CryptMsgClose().

Получив ссылку на CMS-сообщение, можно приступать непосредственно к хешированию данных и вычислению электронной подписи. Всё это делается одним вызовом:

// add, hash, and sign the data
fixed (byte* pData = data)
	CryptMsgUpdate(hMsg, (nint)pData, (uint)data.Length, true).VerifyWinapiTrue();

Теперь остаётся только извлечь закодированную подпись из CMS-сообщения:

// extract signed CMS
var cmsLength = 0;
CryptMsgGetParam(hMsg, CMSG_CONTENT_PARAM, 0, 0, ref cmsLength).VerifyWinapiTrue();
var cms = new byte[cmsLength];
fixed (byte* pSignature = cms)
	CryptMsgGetParam(hMsg, CMSG_CONTENT_PARAM, 0, (nint)pSignature, ref cmsLength).VerifyWinapiTrue();

Здесь всё по канону, включая два вызова CryptoAPI. Первый - для получения размера буфера под данные, второй - для собственно извлечения закодированной электронной подписи. Полученный буфер cms можно сохранить как есть в файл с расширением .sig, и все утилиты наподобие Крипто-АРМ будут воспринимать его как "обычную" электронную подпись.

Проверка электронной подписи

Проверка подписи с помощью WinAPI чуть сложнее, чем её формирование. В основном, из-за сложности и некоторой непоследовательности (например, зачем было хранить различные идентификаторы сертификата подписи, не проще ли было сразу требовать вложение полного сертификата?) самого формата PKCS #7, который допускает возможность хранения нескольких подписей в одной структуре, использование контрасигнатур, расширений наподобие CAdES.

Ниже я приведу код, который проверяет все основные подписи (включая проверку валидности сертификатов), содержащиеся в CMS-сообщении, но не проверяет контрсигнатуры, дополнительные штампы времени и прочие вспомогательные вещи. И вновь начинаем с общего алгоритма:

  1. Создать пустое CMS-сообщение с помощью CryptMsgOpenToDecode.

  2. Загрузить закодированное сообщение с помощью CryptMsgUpdate.

  3. Для отделённой подписи загрузить исходные данные также с помощью вызова CryptMsgUpdate.

  4. С помощью CertOpenStore извлечь все сертификаты, вложенные в подпись. Это могут быть как конечные сертификаты подписантов, так и промежуточные, дополнительно вложенные в сообщение.

  5. С помощью CryptMsgGetParam(..., CMSG_SIGNER_COUNT_PARAM, ...) определить количество подписей в сообщений.

  6. Для каждого подписанта извлечь идентификатор его сертификата с помощью CryptMsgGetParam(..., CMSG_SIGNER_CERT_ID_PARAM, ...).

  7. В хранилище, полученном на шаге 5, найти нужный сертификат по идентификатору с помощью вызова CertFindCertificateInStore(..., CERT_FIND_CERT_ID, ...).

  8. Собственно проверить электронную подпись вызовом CryptMsgControl(..., CMSG_CTRL_VERIFY_SIGNATURE_EX, ...).

  9. При необходимости проверить валидность самого сертификата путём построения цепочки сертификатов с помощью CertGetCertificateChain().

А теперь всё то же самое, но с примерами кода. Создание CMS-сообщения и загрузка в него данных тривиальны:

var hMsg = CryptMsgOpenToDecode(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, detachedSignature ? CMSG_DETACHED_FLAG : 0U, 0, 0, 0, 0)
	.VerifyWinapiNonzero();
try
{
	// load signed CMS
	fixed (byte* pCms = cms)
		CryptMsgUpdate(hMsg, (nint)pCms, (uint)cms.Length, true).VerifyWinapiTrue();
	if (detachedSignature)
	{
		if (data.Length > 0)
			// load source data
			fixed (byte* pData = data)
				CryptMsgUpdate(hMsg, (nint)pData, (uint)data.Length, true).VerifyWinapiTrue();
		else
			throw new ArgumentException("The data must be specified for verifying a detached signature.", nameof(data));
	}
	...
}
finally
{
	CryptMsgClose(hMsg);
}

В конце в блоке finally не забываем освободить неуправляемые ресурсы.

Получение хранилища сертификатов из CMS-сообщения тоже не представляет сложностей:

// extract all included certificates from the CMS as cert store
var hCertStore = CertOpenStore(1, 0, 0, 0, hMsg).VerifyWinapiNonzero();
try
{
	...
}
finally
{
	CertCloseStore(hCertStore, CERT_CLOSE_STORE_FORCE_FLAG);
}

В конце закрываем хранилище с флагом CERT_CLOSE_STORE_FORCE_FLAG, чтобы не заморачиваться с явным освобождением контекстов сертификатов, получаемых далее при поиске.

Получение количества подписантов тоже не представляет сложностей:

// determine signer count
var signerCount = 0U;
var signerCountSize = Marshal.SizeOf(signerCount);
CryptMsgGetParam(hMsg, CMSG_SIGNER_COUNT_PARAM, 0, (nint)(&signerCount), ref signerCountSize).VerifyWinapiTrue();
if (signerCount == 0)
	throw new Win32Exception(CRYPT_E_NO_SIGNER);

Теперь для каждого подписанта пытаемся найти его сертификат:

// extract CERT_ID
nint pCertContext = 0;
var certIdLength = 0;
CryptMsgGetParam(hMsg, CMSG_SIGNER_CERT_ID_PARAM, signerIndex, 0, ref certIdLength).VerifyWinapiTrue();
var certIdRaw = ArrayPool<byte>.Shared.Rent(certIdLength);
try
{
	fixed (byte* pCertId = certIdRaw)
	{
		CryptMsgGetParam(hMsg, CMSG_SIGNER_CERT_ID_PARAM, 0, (nint)pCertId, ref certIdLength).VerifyWinapiTrue();
		pCertContext = CertFindCertificateInStore(hCertStore, X509_ASN_ENCODING | PKCS_7_ASN_ENCODING,
			0, CERT_FIND_CERT_ID, (nint)pCertId, 0);
		if (pCertContext == 0)
		{
			var error = Marshal.GetLastWin32Error();
			if (error == CRYPT_E_NOT_FOUND)
				throw new Win32Exception(CRYPT_E_SIGNER_NOT_FOUND);
			else
				throw new Win32Exception(error);
		}
	}
	...
}
finally
{
	ArrayPool<byte>.Shared.Return(certIdRaw, true);
}

Если контекст сертификата найден, то переходим к собственно проверке значения подписи:

// validate signature
var vsp = new CMSG_CTRL_VERIFY_SIGNATURE_EX_PARA();
vsp.cbSize = (uint)Marshal.SizeOf(vsp);
vsp.dwSignerIndex = signerIndex;
vsp.dwSignerType = CMSG_VERIFY_SIGNER_CERT;
vsp.pvSigner = pCertContext;
CryptMsgControl(hMsg, 0, CMSG_CTRL_VERIFY_SIGNATURE_EX, (nint)(&vsp)).VerifyWinapiTrue();

И, в конце, если нужно проверить валидность самого сертификата, то используем примерно такой код:

// verify certificates
nint pChainContext = 0;
var chainParams = new CERT_CHAIN_PARA();
chainParams.cbSize = (uint)Marshal.SizeOf(chainParams);
try
{
	CertGetCertificateChain(HCCE_CURRENT_USER, pCertContext, 0, hCertStore, (nint)(&chainParams), chainFlags,
		0, (nint)(&pChainContext)).VerifyWinapiTrue();
}
finally
{
	if (pChainContext != 0)
		CertFreeCertificateChain(pChainContext);
}

Как всегда, в конце не забываем освободить выделенные неуправляемые структуры.

Заключение

Как я и упомянул ранее, сложного ничего нет, но приходится не забывать о всяких мелочах. Также напоминаю, что полный код, включаю необходимые классы P/Invoke доступны здесь.

P.S.

Спасибо всем, кто дочитал до конца, это моя первая статья на Хабре.

@AntonLarinLive
11.05.2023 13:22 UTC
Первоисточник

Комментарии

@sstv
11.05.2023 08:48 UTC
+4

Спасибо за статью, еще добавлю: от криптопро потихоньку готовится библиотека https://github.com/CryptoPro/libcore, я его пробовал, работает вполне, но пока не все возможности имеются (см. readme)

@AntonLarinLive
11.05.2023 08:51 UTC
+2

Посмотрел, интересно. CryptoPro, как всегда, в своём репертуаре: выложили проект на GitHub, но исходники закрыли. Возможно, кому-то и в таком виде пригодится.

11.05.2023 08:57 UTC
+6

Они .NET Core 3.1 форкнули полностью, поэтому можно использовать патченный dotnet (заменить крипто сборки по факту) и все будет работать. Мы крутим в докере патченный дотнет + официальный КриптоПро CSP, устраивает. Вот тут можно глянуть их примеры.

Автор молодец, хардово. Но .NET уже давно не про Windows, к счастью, для всех, но, к сожалению, для данной статьи.

11.05.2023 17:31 UTC
-11

Не про Windows? Шутка про MAUI и линух?

12.05.2023 06:35 UTC
+5

Ну расскажите, как моей конторе легально купить windows pro или лучше подписку на msdn?

11.05.2023 10:54 UTC
+2

Fork .NET Core 3.1 у нас полностью открыт (https://github.com/CryptoPro/corefx). За всё время существования проекта -- ни одного merge request от сторонних разработчиков.

11.05.2023 12:03 UTC
+14

PR нет, потому что принцип "неуловимого Джо". Но наличие исходников - это не только возможность получать PRs от сообщества, но и возможность отладки, и анализа кода, а также понимания тонкостей работы библиотеки.

@Einherjar
11.05.2023 11:54 UTC
+2

Учитывая всякое импортозамещение, кроссплатформенный вариант был бы актуален.

@OwDafuq
11.05.2023 12:28 UTC
+12

А мы решили оставить на net framework grpc сервис, который только принимает в себя сырой XML и отдает обратно подписанный, который и отправляется дальше, доставило в разы меньше боли :)

@buldo
11.05.2023 16:55 UTC
+1

Я бы не сказал, что раньше было лучше. CryptoPro NET, емнип, патчил сборки фреймворка в gac. И если через Windows update прилетало обновление фреймворка, то неожиданно всё отваливалось

@AntonLarinLive
11.05.2023 19:03 UTC
+5

Использовать такой кастомный .NET с пропатченными сборками, по мне, можно только совсем от безнадёги. В обычном .NetFX почти вся криптография замыкалась на нативный CryptoAPI, с котором проблем я не припомню. Всё что мне лично было нужно, работало "из коробки".

12.05.2023 03:47 UTC
+3

Там проблемы были (и до сих пор есть) при проверке подписи XML. Потому что реализации алгоритмов выбираются не из реестра, а захардкожены в одном из методов оператором select.


Именно поэтому им понадобилось патчить сборки в GAC, и делать форк на который дали ссылку выше.


А, ну вы как раз один из подобных багов и нашли, и даже про него написали...

@JavaFox
12.05.2023 10:54 UTC
0

интересно как с этим в Java

@LordKAS
23.05.2023 05:34 UTC
+1

3 года назад портировал из библиотека bouncy castle java в ту же либу в net изменения для подписи по госту: все что нужно там было. В java версии не проверял, net версия заработала как ожидалось в linux в докере. Только сертификаты и ключ нужно из контейнера доставать вручную: поддержки контейнера криптопро в версии для net не было.