Python, CryptoAPI и российские криптопровайдеры

от автора

Цель статьи — показать возможность работы с российскими криптопровайдерами в Python под Windows через интерфейс CryptoAPI. Для этого будем использовать две библиотеки: pywin32 и PythonForWindows. Первая из них достаточно известна и нацелена на адаптацию WinAPI к использованию в Python. Вторая — относительно новый проект, позволяющий работать с функциями WinAPI так, как будто они вызываются из родного для них C, вплоть до передачи указателей. Такой подход более гибок, хотя и непривычен в программах на Python.

Для примеров использовался Python 3.12, под криптопровайдером в дальнейшим будем понимать КриптоПро CSP.

Итак, у нас есть некий файл report.zip. Поставим перед собой задачи:

  • Зашифровать файл

  • Подписать файл

  • Установить штамп времени на подпись

Шифрование

Используем пакет pywin32. Загружаем сертификат получателя из файла:

import win32crypt  Store= win32crypt.CertOpenStore(CERT_STORE_PROV_FILENAME,                                  X509_ASN_ENCODING+PKCS_7_ASN_ENCODING,                                  None,                                  0,                                  'сертификат.cer')  CertList = Store.CertEnumCertificatesInStore() Cert = CertList[0]

Параметры шифрования:

EncParam = {'ContentEncryptionAlgorithm':                  {'ObjId': '1.2.643.7.1.1.5.1.1', #GOST-R-34.12-2015-Magma                  'Parameters': b''},             'CryptProv': None #Криптопровайдер по умолчанию            }

Шифрование:

with open('report.zip', 'rb') as MessageFile: Message = MessageFile.read() Message = win32crypt.CryptEncryptMessage(EncParam, (Cert), Message) with open('report.zip.enc', 'wb') as EncFile: EncFile.write(Message)

Примечание: если в качестве ObjId передать пустую строку, то будет выбран алгоритм шифрования по умолчанию, что правильно. А вот если передать произвольную строку, не соответствующую никаким доступным криптопровайдеру алгоритмам, то также будет выбран алгоритм по умолчанию, при этом никакой ошибки методом CryptEncryptMessage возбуждено не будет, и это совсем не правильно.

Подпись

Также используем pywin32. Получаем собственный сертификат из личного хранилища:

OurCert = None Store = win32crypt.CertOpenSystemStore('MY') CertList = Store.CertEnumCertificatesInStore()  for Cert in CertList:     S = bytearray(Cert.SerialNumber)     S.reverse()     if S.hex().upper() == 'XXX' #Серийный номер нашего сертификата         OurCert = Cert         break  if OurCert == None: raise Exception('Не найден сертификат организации')

Параметры подписи:

SignParam = {'SigningCert': OurCert,              'HashAlgorithm': {'ObjId': '', 'Parameters': b''},              'MsgCert': (OurCert,)}

Подпись:

with open('report.zip', 'rb') as MessageFile: Message = MessageFile.read()  Message = win32crypt.CryptSignMessage(SignParam,                                       (Message,),                                       True #Отсоединённая подпись                                      )  with open('report.zip.sig', 'wb') as SignFile: SignFile.write(Message)

Штамп времени

Самое интересное. Последовательность шагов здесь известна: из подписанного сообщения получаем значение подписи, передаём его в функцию CryptRetrieveTimeStamp, которая вычислит хэш и отправит его Time-Stamp сервису (TSS) по заданному адресу. Полученный от TSS ответ добавляем неподписываемым (unauthenticated) атрибутом к сообщению. Пакет pywin32 здесь не подходит, поскольку в нём отсутствуют низкоуровневые функции работы с криптографическими сообщениями (Low-level Message Functions). Поэтому будем использовать библиотеку PythonForWindows, в которой есть необходимые нам функции CryptMsgGetParam и CryptMsgControl. А вот функции CryptRetrieveTimeStamp в ней тоже нет. Обидно, досадно. Но ладно. Воспользуемся тем, что обращение к TSS идёт по незащищённому http протоколу и посмотрим, как выглядит запрос и ответ при получении штампа времени. Простой SmartSniff от Nirsoft подойдёт:

cryptcp.x64 -signf -cert -cadest -dn "Наша компания" -cadestsa http://pki.skbkontur.ru/tsp2012/tsp.srf report.zip

Любопытно, при обращении к Time-Stamp сервису СКБ Контура идёт переадресация на TSS Сертум-Про:

<head><title>Документ перемещен</title></head> <body><h1>Объект перемещен</h1>Документ теперь находится <a HREF="http://pki3.sertum-pro.ru/tsp3/tsp.srf">здесь</a></body>

поэтому штамп времени к нам будет приходить с адреса http://pki3.sertum-pro.ru/tsp3/tsp.srf. Самый первый пакет в адрес crl1.ca.cbr.ru — это OCSP (Online Certificate Status Protocol) запрос статуса нашего сертификата при его использовании для подписи документа, в данный момент для нас он не имеет значения. Итак, смотрим третий пакет. Разумно предположить, что  сообщения при обмене криптографической информацией передаются в кодировке CER (DER). Посмотрим на содержимое запроса в любом ASN.1 декодере:

Пришло время обратиться к первоисточникам, а именно к спецификации Time-Stamp протокола RFC3161, где описан формат запроса:

TimeStampReq ::= SEQUENCE {   version        INTEGER { v1(1) },   messageImprint MessageImprint,   reqPolicy      TSAPolicyId              OPTIONAL,   nonce          INTEGER                  OPTIONAL,   certReq        BOOLEAN                  DEFAULT FALSE,   extensions     [0] IMPLICIT Extensions  OPTIONAL }  MessageImprint ::= SEQUENCE {   hashAlgorithm  AlgorithmIdentifier,   hashedMessage  OCTET STRING }

Очевидно, version — это INTEGER 1; hashAlgorithm — OBJECT IDENTIFIER 1.2.643.7.1.1.2.2; hashedMessage — OCTET STRING XXX. reqPolicy — политика TSA (Time Stamping Authority), в соответствии с которой должен быть указан TimeStampToken — не передаётся, как и extensions — расширения. certReq — наше BOOLEAN True — определяет, будет ли в ответном сообщении присутствовать сертификат TSS. Наконец, поле nonce — длинное целое — служит для уникальной идентификации запроса и может быть произвольным, например, представленным восемью случайными байтами:

import random  def GetNonce():     IntList = []     for i in range(8): IntList.append(random.randint(0, 255))     return bytes(IntList)

Для Python существуют пакеты для работы с ASN.1 кодировками, но структура запроса (да и ответа) вполне проста, и сторонние средства нам не понадобятся. Однако, чтобы составить запрос, нужно сперва получить хэш электронной подписи (сама подпись — тоже хэш, только зашифрованный), для которого и устанавливается метка времени.

from windows.crypto import * from windows import winproxy from windows.generated_def import *  with open('report.zip.sig','rb') as SignFile: Message=SignFile.read()  #Загружаем сообщение. Здесь неявно вызываются функции CryptMsgOpenToDecode и CryptMsgUpdate hMsg = windows.crypto.CryptMessage.from_buffer(Message)  #Содержимое подписи Sign = bytes(hMsg.get_signer_data().EncryptedHash.data)   cbData = DWORD() #Получаем контекст криптопровайдера, обёртка для CryptAcquireContextW with windows.crypto.CryptContext(dwProvType = 80,                                  dwFlags = CRYPT_VERIFYCONTEXT #Не нужно открывать контейнер                                 ) as Prov:      hHash = HCRYPTHASH()     #Создаём объект хэша     winproxy.CryptCreateHash(hProv=Prov,Algid=32801,hKey=None,dwFlags=0,phHash=hHash)      #Хэшируем подпись     winproxy.CryptHashData(hHash, Sign)      #Получаем длину хэша     winproxy.CryptGetHashParam(hHash, HP_HASHVAL, None, cbData)      HASH = create_string_buffer(cbData.value)     #Получаем хэш     winproxy.CryptGetHashParam(hHash, HP_HASHVAL, PBYTE(HASH), cbData) 

Константы dwProvType=80 и Algid=32801 определяются конкретным криптопровайдером. В случае КриптоПро это выглядит так:

На примере вызова CryptGetHashParam видим знакомый способ работы со многими функциями CryptoAPI: сначала вызываем функцию, чтобы получить длину данных (cbData) и выделить под них память, затем вызываем её же, чтобы получить данные (HASH).

Итак, мы получили хэш подписи, теперь можем составить запрос к TSS:

Request = b'\x30\x40\x02\x01\x01\x30\x2E\x30\x0A\x06\x08\x2A\x85\x03\x07\x01\x01\x02\x02\x04\x20' + bytes(HASH) + b'\x02\x08' + GetNonce() + b'\x01\x01\xFF'

Посылаем:

import requests      Header = {'Content-Type': 'application/timestamp-query', 'Connection': 'Keep-Alive', 'Content-Length': '66', 'User-Agent': 'requests'} Session = requests.Session() Response = Session.post('http://pki.skbkontur.ru/tsp2012/tsp.srf', Request, headers = Header) Session.close()

Посмотрим, что вернулось к нам в Response.content:

Формат ответа по спецификации:

TimeStampResp ::= SEQUENCE {   status         PKIStatusInfo,   timeStampToken TimeStampToken OPTIONAL }

Интересующий нас timeStampToken — последовательность (SEQUENCE), озаглавленная OID 1.2.840.113549.1.7.2. Найдём её в двоичной строке:

Idx = Response.content.find(b'\x06\x09\x2A\x86\x48\x86\xF7\x0D\x01\x07\x02') - 4

Здесь мы делаем разумное допущение, что длина последовательности, содержащей атрибут времени, его подпись и сертификат подписанта, лежит в пределах от 128 до 65 535 байт, поэтому её заголовок занимает 4 байта.

Добавим timeStampToken к сообщению как неподписываемый атрибут. Нам понадобится структура CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA(), которая не объявлена в пакете, поэтому объявим её сами:

class CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA(Structure):     _fields_ = [         ("cbSize",DWORD),         ("dwSignerIndex",DWORD),         ("blob",CRYPT_DATA_BLOB),     ]  #Подготовка атрибута Attr = CRYPT_ATTRIBUTE() Attr.pszObjId = '1.2.840.113549.1.9.16.2.14'.encode("ascii") #OID timeStampToken Attr.cValue = 1 Attr.rgValue = PCRYPT_INTEGER_BLOB(CRYPT_INTEGER_BLOB.from_string(Response.content[Idx:]))  #Упаковка его в ASN.1 структуру winproxy.CryptEncodeObjectEx(DEFAULT_ENCODING,PKCS_ATTRIBUTE,byref(Attr),0,None,None,cbData) Buf = create_string_buffer(cbData.value) winproxy.CryptEncodeObjectEx(DEFAULT_ENCODING,PKCS_ATTRIBUTE,byref(Attr),0,None,Buf,cbData)  #Добавление к сообщению Param = CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA() Param.cbSize = sizeof(Param) Param.blob = CRYPT_DATA_BLOB.from_string(Buf) winproxy.CryptMsgControl(hMsg, 0, CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR, byref(Param))

Штамп времени установлен. Осталось сохранить обновлённое сообщение:

winproxy.CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, 0, None, cbData); Buf = create_string_buffer(cbData.value) winproxy.CryptMsgGetParam(hMsg, CMSG_ENCODED_MESSAGE, 0, Buf, cbData)  with open('report.zip.sig', 'wb') as SignFile: SignFile.write(Buf)

Мы получили файл report.zip.sig, содержащий подпись и штамп времени:

Сертификат Time-Stamp сервиса Сертум-Про выдан на физическое лицо. Я не знаю, кто этот достойный человек, но отдадим должное его благородному труду.

Замечание по поводу TSS Центробанка: для обращения к нему требуется tls-туннель, но на уровне пользователя это никак не отражается, и вышеприведённый код  по-прежнему работает.


ссылка на оригинал статьи https://habr.com/ru/articles/834312/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *