Это вторая часть статьи про криптографические сложности в .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);
}
}
}
Использование довольно прямолинейное:
-
В качестве исходных данных передаются:
data
- данные, для которых вычисляется штамп времени.tspDigestOid
- OID алгоритма хэширования. Так как в TSA отправляются не сами удостоверяемые данные, а их хэш, то этим аргументом задаётся алгоритм хэширования и, опционально, его параметры.nonce
- последовательность случайных байтов, служит для защиты от атак повтором сообщения. Может быть пустой, но стандарт настоятельно рекомендует использовать. Я обычно использую 16 случайных байт.tsaUri
- URI центра выдачи штампов времени (TSA).timeout
- таймаут ожидания ответа TSA.
Заполняем структуру
CRYPT_TIMESTAMP_PARA
параметрами запроса TS. Из всех полей этой структуры в подавляющем большинстве случаем нужны только два:Nonce
иfRequestCerts
. В первое сохраняем наш входной параметрnonce
, второе ставим вtrue
для того, чтобы TSA включил в штамп времени свой сертификат, без которого проверить достоверность штампа будет затруднительно.Вызываем функцию
CryptRetrieveTimeStamp
, которая выполняет за нас всю низкоуровневую работу: рассчитывает хэш исходных данных, формирует запрос к TSA, отправляет его по HTTP, читает и разбирает ответ TSA, проверяет валидность полученного токена и сертификата TSA (если задан флагTIMESTAMP_VERIFY_CONTEXT_SIGNATURE
).Извлекаем токен из ответа TSA (поле
pbEncoded
выходной структурыCRYPT_TIMESTAMP_CONTEXT
).Освобождаем неуправляемый блок памяти 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);
}
}
Теперь шаг за шагом подробнее разберём, что здесь происходит.
Вызовом
CryptMsgOpenToDecode
создаём пустое CMS-сообщение для последующего декодирования.Загружаем исходное сообщение, вызывая
CryptMsgUpdate
.Извлекаем само значение электронной подписи для подписанта с индексом
signerIndex
(в общем случае в одном CMS-сообщений может быть много подписантов, штамп времени формируется для каждого отдельно):CryptMsgGetParam(hMsg, CMSG_ENCRYPTED_DIGEST, ...)
. Именно для этого значения будем запрашивать штамп времени в следующем шаге.С помощью ранее рассмотренного метода
RetriveTimestamp
запрашиваем штамп времени.С помощью функции
CryptEncodeObjectEx
кодируем штамп времени в CMS-атрибутid-aa-timeStampToken
c OID=1.2.840.113549.1.9.16.2.14. Указатель на закодированный атрибут помещаем в структуруCMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA
.Вызовом
CryptMsgControl(..., CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, ...)
добавляем закодированный атрибут в качестве неподписываемого для подписанта с индексомsignerIndex
.Освобождаем неуправляемую память, любезно выделенную нам системой при кодировании атрибута на шаге 5, вызывая привычную
LocalFree
.Извлекаем обновлённое CMS-сообщение с помощью вызывов
CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, ...)
в виде байтового массива.Освобождаем неуправляемый объект CMS-сообщения, вызывая
CryptMsgClose
.
Полученным байтовым массивом следует заменить исходную подпись там, где это необходимо.
Заключение
Пользуясь нехитрыми операциями с WinAPI, мы успешно обошли ограничения фреймворка .NET 7 в виде отсутствия поддержки сторонних криптоалгоритмов. Но, очевидно, это будет работать только под Windows. Под Linux, скорее всего, придётся писать свои вызовы к OpenSSL; но это, как говорится, уже совсем другая история.
Полный код, включая необходимые классы P/Invoke доступны на GitHub.
Skykharkov
Все круто. И ладно бы unsafe. Но это чисто моя unsafe-фобия. Но вот WinAPI... Да для NET 7, который как раз то и про кросплатформенность... Уже на linux'е, на серверной стороне работать не будет...
Evengard
Я бы посмотрел на то же самое, сделанное с помощью BouncyCastle...
AntoineLarine Автор
Есть и под них код в загашнике, правда тоже для .NET, не для Java. С BC проблема другая, там вся крипта реализована в самой либе на C#. Соответственно, нет возможности работать с аппаратными носителями, контейнерами КриптоПРО и прочими штуками, обязательными по нашему законодательству.
Да, был опыт переконвертации экспортируемых ключевых контейнеров КриптоПРО в PFX, понимаемый OpenSSL и BC, но это слишком специфические задачи. Первый же неэкспортируемый ключевой контейнер ставит крест на этой либе. Возможно, я ошибаюсь и что-то уже поменялось, но лет пять назад вариантов не было.
Evengard
Тем не менее, работа с российской криптографией в BouncyCastle.NET это всё равно интересно, пишите =)
AntoineLarine Автор
Увы, у меня не было подобных задач под Linux. А под Windows сталкиваюсь с ними не так уж и редко. Поэтому не заморачиваюсь на тему кроссплатформенности.