Разрабатывая один проект на свежем .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.

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

Комментарии (13)