Цель статьи - продемонстрировать возможность работы с российскими криптопровайдерами в 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
if Idx < 0: raise Exception('Штамп времени не получен')
Здесь мы делаем разумное допущение, что длина последовательности, содержащей атрибут времени, его подпись и сертификат подписанта, лежит в пределах от 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-туннель, но на уровне пользователя это никак не отражается, и вышеприведённый код по-прежнему работает.
maxdm
Спасибо за статью. Предложу еще один вариант работы из python: уже несколько лет мы развиваем pycades, недавно выложили на GitHub.