.NET 7 против российской криптографии. Часть 2. Штампы времени

7702f549e3830e5af3d80488e40da506

Это вторая часть статьи про криптографические сложности в .NET 7. Предыдущая доступна здесь.

Практически неотъемлемой частью формирования электронной подписи стало формирование штампов времени (TS) на подпись. С их помощью обеспечивается доверенное подтверждение времени подписания документа. Со штампами времени в .NET 7 та же беда, что и с CMS-сообщениями — отсутствие нативной поддержки российских алгоритмов хэширования и электронной подписи на уровне фреймворка. Но, благо, старый добрый WinAPI и здесь поможет решить задачу.

Саму теорию TS описывать здесь не буду, за деталями отсылаю в RFC3161, российские выкрутасы на эту тему неплохо освещены в этой статье.

Получение штампа времени

В WinAPI вся работа штампа по получению штампа времени выполняется одной функцией CryptRetrieveTimeStamp.

Как всегда, начинаем с кода:

/// 
/// Calculates a timestamp token for a given data
/// 
/// A source binary data for timestamping
/// An OID of a message digest algorithm
/// A nonce value, can be empty
/// An URI of a TSA
/// A TSA request timeout
/// A timestamp token in DER encoding
public static unsafe byte[] RetriveTimestamp(ReadOnlySpan data, Oid tspDigestOid, ReadOnlySpan nonce, string tsaUri, TimeSpan timeout)
{
	var tspReq = new CRYPT_TIMESTAMP_PARA();
	tspReq.fRequestCerts = true;
	fixed (byte* pData = data, pNonce = nonce)
	{
		if (nonce.Length > 0)
		{
			tspReq.Nonce.cbData = (uint)nonce.Length;
			tspReq.Nonce.pbData = (nint)pNonce;
		}

		nint pTsContext;
		CryptRetrieveTimeStamp(tsaUri, TIMESTAMP_NO_AUTH_RETRIEVAL | TIMESTAMP_VERIFY_CONTEXT_SIGNATURE, 
			timeout.Milliseconds, tspDigestOid.Value, (nint)(&tspReq), (nint)pData, (uint)data.Length,
			(nint)(&pTsContext), 0, 0).VerifyWinapiTrue();
		try
		{
			var tsContext = new ReadOnlySpan(pTsContext.ToPointer(), 1);
			var tst = new ReadOnlySpan(tsContext[0].pbEncoded.ToPointer(), (int)tsContext[0].cbEncoded);
			return tst.ToArray();
		}
		finally
		{
			if (pTsContext != 0)
				CryptMemFree(pTsContext);
		}
	}
}

Использование довольно прямолинейное:

  1. В качестве исходных данных передаются:

    1. data — данные, для которых вычисляется штамп времени.

    2. tspDigestOid — OID алгоритма хэширования. Так как в TSA отправляются не сами удостоверяемые данные, а их хэш, то этим аргументом задаётся алгоритм хэширования и, опционально, его параметры.

    3. nonce — последовательность случайных байтов, служит для защиты от атак повтором сообщения. Может быть пустой, но стандарт настоятельно рекомендует использовать. Я обычно использую 16 случайных байт.

    4. tsaUri — URI центра выдачи штампов времени (TSA).

    5. timeout — таймаут ожидания ответа TSA.

  2. Заполняем структуру CRYPT_TIMESTAMP_PARA параметрами запроса TS. Из всех полей этой структуры в подавляющем большинстве случаем нужны только два: Nonce и fRequestCerts. В первое сохраняем наш входной параметр nonce, второе ставим в true для того, чтобы TSA включил в штамп времени свой сертификат, без которого проверить достоверность штампа будет затруднительно.

  3. Вызываем функцию CryptRetrieveTimeStamp, которая выполняет за нас всю низкоуровневую работу: рассчитывает хэш исходных данных, формирует запрос к TSA, отправляет его по HTTP, читает и разбирает ответ TSA, проверяет валидность полученного токена и сертификата TSA (если задан флаг TIMESTAMP_VERIFY_CONTEXT_SIGNATURE).

  4. Извлекаем токен из ответа TSA (поле pbEncoded выходной структуры CRYPT_TIMESTAMP_CONTEXT).

  5. Освобождаем неуправляемый блок памяти c ответом TSA с помощью функции CryptMemFree.

Токен, полученный в п. 4, можно сохранить в файл, прикладывая его где нужно к подписанному сообщению. По такой простейшей технологии работает, например, система ЭТРАН ОАО «РЖД», где штамп времени хранится отдельно от электронной подписи в формате CMS.

Встраивание штампа времени в подписанное CMS-сообщение

Более интересный вариант — это когда полученный штамп времени нужно сохранить внутри исходного CMS-сообщения в качестве одного из неподписываемых атрибутов. Такой вариант, например, используется в формате подписи CAdES-T и его производных. WinAPI поможет решить и эту задачу, правда кода будет уже больше, так как необходимо повозиться с обновлением CMS-сообщения.

И вновь начинаем с готового кода, который, как известно, один из лучших способов документации:

/// 
/// Calculates and adds a timestamp token to a CMS message as an unsigned attribute
/// 
/// A target CMS message
/// A flag of the detached signature in the CMS
/// An index of the CMS signer
/// An OID of a message digest algorithm
/// A nonce value, can be empty
/// An URI of a TSA
/// A TSA request timeout
/// A new CMS message with an injected timestamp token
public static unsafe byte[] AddTimestampToCms(ReadOnlySpan cms, bool detachedSignature, uint signerIndex,
	Oid tspDigestOid, ReadOnlySpan nonce, string tsaUri, TimeSpan timeout)
{
	var hMsg = CryptMsgOpenToDecode(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, detachedSignature ? CMSG_DETACHED_FLAG : 0U, 0, 0, 0, 0)
		.VerifyWinapiNonzero();
	try
	{
		// load the CMS signed message
		fixed (byte* pCms = cms)
			CryptMsgUpdate(hMsg, (nint)pCms, (uint)cms.Length, true).VerifyWinapiTrue();

		// extract the signature from the CMS message for the specified signerIndex
		var signatureLength = 0;
		CryptMsgGetParam(hMsg, CMSG_ENCRYPTED_DIGEST, signerIndex, 0, (nint)(&signatureLength)).VerifyWinapiTrue();
		var signature = stackalloc byte[signatureLength];
		CryptMsgGetParam(hMsg, CMSG_ENCRYPTED_DIGEST, signerIndex, (nint)signature, (nint)(&signatureLength)).VerifyWinapiTrue();

		// receive timestamp on the extracted signature
		var tst = RetriveTimestamp(new ReadOnlySpan(signature, signatureLength), tspDigestOid, nonce, tsaUri, timeout);

		// add a new unsigned attribute
		fixed (byte* pzdObjId = "1.2.840.113549.1.9.16.2.14"u8, pTst = tst)
		{
			var tstBlob = new CRYPT_INTEGER_BLOB();
			tstBlob.cbData = (uint)tst.Length;
			tstBlob.pbData = (nint)pTst;

			var tstAttr = new CRYPT_ATTRIBUTE();
			tstAttr.pszObjId = (nint)pzdObjId;
			tstAttr.cValue = 1;
			tstAttr.rgValue = (nint)(&tstBlob);

			// encode a timestamp attribute to DER
			var attr = (nint)0;
			var attrLen = 0U;
			CryptEncodeObjectEx(X509_ASN_ENCODING | PKCS_7_ASN_ENCODING, PKCS_ATTRIBUTE, (nint)(&tstAttr), CRYPT_ENCODE_ALLOC_FLAG,
				0, (nint)(&attr), (nint)(&attrLen)).VerifyWinapiTrue();
			try
			{
				// inject the encoded unsigned attribute to the SignerInfo
				var cmsAttr = new CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA();
				cmsAttr.dwSignerIndex = signerIndex;
				cmsAttr.blob.cbData = attrLen;
				cmsAttr.blob.pbData = attr;
				CryptMsgControl(hMsg, 0, CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, (nint)(&cmsAttr)).VerifyWinapiTrue();
			}
			finally
			{
				LocalFree(attr).VerifyWinapiZero();
			}
		}

		// extract the updated CMS message
		uint updatedCmsLength = 0;
		CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, 0, 0, (nint)(&updatedCmsLength)).VerifyWinapiTrue();
		var updatedCms = new byte[updatedCmsLength];
		fixed (byte* pUpdatedCms = updatedCms)
			CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, 0, (nint)pUpdatedCms, (nint)(&updatedCmsLength)).VerifyWinapiTrue();
		return updatedCms;
	}
	finally
	{
		CryptMsgClose(hMsg);
	}
}

Теперь шаг за шагом подробнее разберём, что здесь происходит.

  1. Вызовом CryptMsgOpenToDecode создаём пустое CMS-сообщение для последующего декодирования.

  2. Загружаем исходное сообщение, вызывая CryptMsgUpdate.

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

  4. С помощью ранее рассмотренного метода RetriveTimestamp запрашиваем штамп времени.

  5. С помощью функции CryptEncodeObjectEx кодируем штамп времени в CMS-атрибут id-aa-timeStampToken c OID=1.2.840.113549.1.9.16.2.14. Указатель на закодированный атрибут помещаем в структуру CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA.

  6. Вызовом CryptMsgControl(..., CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, ...) добавляем закодированный атрибут в качестве неподписываемого для подписанта с индексом signerIndex.

  7. Освобождаем неуправляемую память, любезно выделенную нам системой при кодировании атрибута на шаге 5, вызывая привычную LocalFree.

  8. Извлекаем обновлённое CMS-сообщение с помощью вызывов CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, ...) в виде байтового массива.

  9. Освобождаем неуправляемый объект CMS-сообщения, вызывая CryptMsgClose.

Полученным байтовым массивом следует заменить исходную подпись там, где это необходимо.

Заключение

Пользуясь нехитрыми операциями с WinAPI, мы успешно обошли ограничения фреймворка .NET 7 в виде отсутствия поддержки сторонних криптоалгоритмов. Но, очевидно, это будет работать только под Windows. Под Linux, скорее всего, придётся писать свои вызовы к OpenSSL;, но это, как говорится, уже совсем другая история.

Полный код, включая необходимые классы P/Invoke доступны на GitHub.

© Habrahabr.ru