Python, CryptoAPI and Russian Crypto Providers

The purpose of this article is to demonstrate the possibility of working with Russian crypto providers in Python under Windows via the CryptoAPI interface. For this, we will use two libraries: pywin32 And PythonForWindows. The first of these is fairly well known and is aimed at adapting WinAPI for use in Python. The second is a relatively new project that allows you to work with WinAPI functions as if they were called from their native C, right down to passing pointers. This approach is more flexible, although unusual in Python programs.

Python 3.12 was used for the examples; in the future, the crypto provider will be understood as CryptoPro CSP.

So, we have a certain file report.zip. Let's set ourselves the following tasks:

  • Encrypt file

  • Sign the file

  • Set timestamp on signature

Encryption

We use the pywin32 package. Load the recipient's certificate from the file:

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]

Encryption parameters:

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

Encryption:

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)

Note: if you pass an empty string as ObjId, the default encryption algorithm will be selected, which is correct. But if you pass an arbitrary string that does not correspond to any algorithms available to the crypto provider, the default algorithm will also be selected, and no error will be detected by the method. CryptEncryptMessage it won't be excited, and that's not right at all.

Signature

We also use pywin32. We get our own certificate from the personal storage:

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('Не найден сертификат организации')

Signature parameters:

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

Signature:

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)

Time stamp

The most interesting thing is that the sequence of steps here is known: we get the signature value from the signed message, pass it to the function CryptRetrieveTimeStampwhich will calculate the hash and send it to the Time-Stamp Service (TSS) at the specified address. We add the response received from the TSS as an unsigned (unauthenticated) attribute to the message. The pywin32 package is not suitable here, since it does not have low-level functions for working with cryptographic messages (Low-level Message Functions). Therefore, we will use the PythonForWindows library, which has the functions we need. CryptMsgGetParam And CryptMsgControl. And here are the functions CryptRetrieveTimeStamp it doesn't have it either. It's a shame, a shame. But okay. Let's take advantage of the fact that the TSS is accessed via an unprotected http protocol and see what the request and response look like when receiving a timestamp. A simple SmartSniff from Nirsoft will do:

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

It is interesting that when accessing the Time-Stamp service of SKB Kontur, there is a redirection to TSS Sertum-Pro:

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

therefore the time stamp will come to us from the address http://pki3.sertum-pro.ru/tsp3/tsp.srf. The very first packet to crl1.ca.cbr.ru is an OCSP (Online Certificate Status Protocol) request for the status of our certificate when it is used to sign a document, at the moment it does not matter to us. So, let's look at the third packet. It is reasonable to assume that messages during the exchange of cryptographic information are transmitted in CER (DER) encoding. Let's look at the contents of the request in any ASN.1 decoder:

It's time to turn to the original sources, namely the Time-Stamp protocol specification RFC3161where the request format is described:

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 }

Obviously, version is INTEGER 1; hashAlgorithm is OBJECT IDENTIFIER 1.2.643.7.1.1.2.2; hashedMessage is OCTET STRING XXX. reqPolicy is the TSA (Time Stamping Authority) policy according to which TimeStampToken should be specified – it is not transmitted, as well as extensions. certReq is our BOOLEAN True – it determines whether the TSS certificate will be present in the response message. Finally, the nonce field is a long integer – it serves to uniquely identify the request and can be arbitrary, for example, represented by eight random bytes:

import random

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

There are packages for Python for working with ASN.1 encodings, but the structure of the request (and the response) is quite simple, and we will not need third-party tools. However, to make a request, you first need to get a hash of the electronic signature (the signature itself is also a hash, only encrypted), for which the timestamp is set.

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) 

The constants dwProvType=80 and Algid=32801 are defined by a specific crypto provider. In the case of CryptoPro, it looks like this:

Using the example of a call CryptGetHashParam We see a familiar way of working with many CryptoAPI functions: first we call the function to get the length of the data (cbData) and allocate memory for it, then we call the same function to get the data (HASH).

So, we have received the signature hash, now we can compose a request to the 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'

We send:

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()

Let's see what came back to us in Response.content:

Response format according to specification:

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

The timeStampToken we are interested in is a SEQUENCE titled OID 1.2.840.113549.1.7.2. Let's find it in the binary string:

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

Here we make a reasonable assumption that the length of the sequence containing the time attribute, its signature and the signer's certificate is between 128 and 65,535 bytes, so its header takes up 4 bytes.

Let's add timeStampToken to the message as an unsignable attribute. We'll need the CMSG_CTRL_ADD_SIGNER_UNAUTH_ATTR_PARA() structure, which isn't declared in the package, so we'll declare it ourselves:

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))

The timestamp has been set. All that remains is to save the updated message:

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)

We received a report.zip.sig file containing a signature and a timestamp:

The Time-Stamp certificate of the Sertum-Pro service is issued to an individual. I do not know who this worthy person is, but let us pay tribute to his noble work.

Note about the Central Bank TSS: it requires a TLS tunnel to access it, but this does not affect the user level in any way, and the above code still works.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *