Это вторая часть статьи про криптографические сложности в .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-timeStampTokenc 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.
