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

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

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

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

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

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

/// <summary>
/// Calculates a timestamp token for a given data
/// </summary>
/// <param name="data">A source binary data for timestamping</param>
/// <param name="tspDigestOid">An OID of a message digest algorithm</param>
/// <param name="nonce">A nonce value, can be empty</param>
/// <param name="tsaUri">An URI of a TSA</param>
/// <param name="timeout">A TSA request timeout</param>
/// <returns>A timestamp token in DER encoding</returns>
public static unsafe byte[] RetriveTimestamp(ReadOnlySpan<byte> data, Oid tspDigestOid, ReadOnlySpan<byte> 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<CRYPT_TIMESTAMP_CONTEXT>(pTsContext.ToPointer(), 1);
			var tst = new ReadOnlySpan<byte>(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-сообщения.

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

/// <summary>
/// Calculates and adds a timestamp token to a CMS message as an unsigned attribute
/// </summary>
/// <param name="cms">A target CMS message</param>
/// <param name="detachedSignature">A flag of the detached signature in the CMS</param>
/// <param name="signerIndex">An index of the CMS signer</param>
/// <param name="tspDigestOid">An OID of a message digest algorithm</param>
/// <param name="nonce">A nonce value, can be empty</param>
/// <param name="tsaUri">An URI of a TSA</param>
/// <param name="timeout">A TSA request timeout</param>
/// <returns>A new CMS message with an injected timestamp token</returns>
public static unsafe byte[] AddTimestampToCms(ReadOnlySpan<byte> cms, bool detachedSignature, uint signerIndex,
	Oid tspDigestOid, ReadOnlySpan<byte> 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<byte>(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.

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


  1. Skykharkov
    02.11.2023 19:21
    +2

    Все круто. И ладно бы unsafe. Но это чисто моя unsafe-фобия. Но вот WinAPI... Да для NET 7, который как раз то и про кросплатформенность... Уже на linux'е, на серверной стороне работать не будет...


    1. Evengard
      02.11.2023 19:21
      +1

      Я бы посмотрел на то же самое, сделанное с помощью BouncyCastle...


      1. AntoineLarine Автор
        02.11.2023 19:21
        +2

        Есть и под них код в загашнике, правда тоже для .NET, не для Java. С BC проблема другая, там вся крипта реализована в самой либе на C#. Соответственно, нет возможности работать с аппаратными носителями, контейнерами КриптоПРО и прочими штуками, обязательными по нашему законодательству.

        Да, был опыт переконвертации экспортируемых ключевых контейнеров КриптоПРО в PFX, понимаемый OpenSSL и BC, но это слишком специфические задачи. Первый же неэкспортируемый ключевой контейнер ставит крест на этой либе. Возможно, я ошибаюсь и что-то уже поменялось, но лет пять назад вариантов не было.


        1. Evengard
          02.11.2023 19:21

          Тем не менее, работа с российской криптографией в BouncyCastle.NET это всё равно интересно, пишите =)


    1. AntoineLarine Автор
      02.11.2023 19:21

      Увы, у меня не было подобных задач под Linux. А под Windows сталкиваюсь с ними не так уж и редко. Поэтому не заморачиваюсь на тему кроссплатформенности.