Цель статьи - продемонстрировать возможность работы с российскими криптопровайдерами в 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-туннель, но на уровне пользователя это никак не отражается, и вышеприведённый код  по-прежнему работает.

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


  1. maxdm
    08.08.2024 12:22

    Спасибо за статью. Предложу еще один вариант работы из python: уже несколько лет мы развиваем pycades, недавно выложили на GitHub.