Using pycryptodomex for encryption and signing (PKCS1 OAEP and PSS) in Python

22 Mar 2023 - tsp
Last update 23 Mar 2023
Reading time 18 mins

This is a short summary on how to use pycryptodomex for some simple encryption and decryption procedures using RSA with the OAEP schemes from PKCS#1 as well as signing and verification routines using the PSS scheme. Please keep in mind the usual rule: Don’t try to role your own cryptosystem when you want to rely on it to be secure or confidential. This is pretty hard to get right even for experts - I for myself built two different implementations of those algorithms (and other cryptography stuff like OpenPGP) from scratch to learn how they work - which is fine to see if one really understands stuff and if it’s interoperable with working correct implementations - but there are many pitfalls where even a small non obvious mistake can completely compromise the security of your cryptosystem (one padding oracle, one random number generator with problematic distribution, one minor mistake to not filter out weak primes, wrong choice of constants that might not be obvious from the mathematical point of view, etc.). But the following routines should summarize what’s required to play around a little bit or solve some specific problems like signing data passed between ones own applications. It’s nice when one wants to play around with public key cryptography in some toy settings without having to implement the PKCS#1 primitives oneself (which is pretty fun - but a little bit more challenging). Still when wanting something reliable I’d refrain from building own cryptosystems (this includes certificate and message structure, etc.). One cannot stress enough: It’s too easy to make minor mistakes that break the whole cryptosystem even when using strong and proven to be correct primitives as well as entropy everywhere. Experts have taken much trial and error to construct todays most robust cryptosystems (including PKCS#1 and OpenPGP) to be as secure and safe as they are as of today. Weaknesses are usually not obvious, they are not even easy to spot for mathematicians and even not for cryptography experts.

The pycryptodome package is a (now incompatible) substitute for the legacy pycrypto package that provides many different cryptographic primitives. To avoid collisions there exist pycyptodomex which exposes the same API - but under the Cryptodome package instead of just Crypto. This allows pycryptodomex to coexist with pycrypto in the same environment - for this reason this blog article uses pycryptodomex. One can use the same cod with pycryptodome by simple changing imports from Cryptodome to Crypto.

Note that this blog article only includes most basic PSS routines for signing and OAEP methods for encryption (it uses AES for symmetric encryption in this case) - pycryptodome supports way more features than that. Refer to the good documentation. This article just provides some reference for some of the most basic situations. All examples / recipes utilize JSON messages since I’ve used them in some application that way.

Installation

pycryptodomex can simply be installed from it’s PyPi package:

pip install pycryptodomex

Key handling (RSA)

First let’s take a look on how to handle RSA keys. RSA keys of today usually have lengths of 512 or 1024 bits (short term usage) or 4096 bits (long term usage). Keep in mind that one usually does not use the same key for signing and encrypting - and also rotates keys on a periodic basis or after some amount of encrypted or signed messages. Because of this most cryptosystems employ hierarchical keys where the root key that is associated with the identity is only used to sign other keys that are rotated periodically.

To use keys one has to:

Generating keys

The first step is to generate keys. This can be done using RSA.generate. The only required argument is the number of bits. For long living keys 4096 is the most used one, the FIPS standard only defined 1024, 2048 and 3072 bits though. For short lived keys lengths down to 512 bits are possible. In addition one can pass a random number generator to be used instead of the default Crypto.Random.get_random_bytes() backend as well as the public exponent e that defaults to 65537.

from Cryptodome.PublicKey import RSA

str_passphrase = "demo"

# Generating a short key

key_short = RSA.generate(1024)

# CPU times: user 61.2 ms, sys: 0 ns, total: 61.2 ms
# Wall time: 64.5 ms

To write a key into a file one can simply call export_key and set the format that one wants to use. This can be either PEM or DER. Note that DER might not be able to be used under all circumstances though. In addition since one is exporting a private key one should encrypt it under most circumstances - this requires one to set the passphrase argument. Keep in mind Python provides no way to keep passphrases secure during processing so they might be swapped into the swapfile, bleeded via cache sidechannels or other uncontrolled ways that can be prevented when doing clean implementations in some native language that uses custom memory allocation methods.

# And writing to a file (PKCS#1, passphrase protection)

# Use PEM text encoding
with open('my_short_key_1.pem', 'wb') as fOut:
    fOut.write(key_short.export_key(format = 'PEM', passphrase = str_passphrase))

Same example with a 8192 bits key

# Generating a very long key (usually one should use around 2048 or 4096 bits)

key_long = RSA.generate(8192)

# CPU times: user 1min 13s, sys: 16.7 ms, total: 1min 13s
# Wall time: 1min 13s
# And writing to a file

# Use PEM text encoding
with open('my_long_key_1.pem', 'wb') as fOut:
    fOut.write(key_long.export_key(format = 'PEM', passphrase = str_passphrase))

Loading (private) keys

The counterpart to storing a key is of course reading it from a source. This can be done from any byte array - like a read file - using RSA.import_key. When not specifying a passphrase the key is expected to be unprotected and a ValueError will be raised in case of invalid data formats or an encrypted key. Unfortunately there is no extra Exception that allows one to check if it’s an format error or a missing passphrase:

First run import with missing passphrase

try:
    with open('my_short_key_1.pem', 'r') as fIn:
        temp_key = RSA.import_key(fIn.read())
except ValueError as e:
    print("Failed to load key, missing passphrase (as expected)")

This outputs Failed to load key, missing passphrase (as expected)

When passing a passphrase the key import works as expected:

try:
    with open('my_short_key_1.pem', 'r') as fIn:
        temp_key = RSA.import_key(fIn.read(), passphrase = str_passphrase)
except ValueError as e:
    print("Failed to load key (unexpected)")

The resulting object is - as the exported one - a private RSA key.

Exporting public keys

To pass a key to another entity (and to be able to perform signatures, etc.) one has to store the public key spearately. This can be used by accessing the public key part using the public_key() method of the private key. The resulting object is a Public RSA key:

key_short_pub = key_short.publickey()

The key can be exported the exact same way as the private key - also again to PEM or DER:

with open("my_short_key_pub.pem", "wb") as fout:
    fout.write(key_short.publickey().export_key(format = 'PEM'))
with open("my_long_key_pub.pem", "wb") as fout:
    fout.write(key_long.publickey().export_key(format = 'PEM'))

Loading public keys

The counterpart to exporting is again loading. And as one would expect this is done using RSA.import_key. The import function distinguished the key types by itself:

with open('my_short_key_pub.pem', 'r') as fIn:
    temp_key = RSA.import_key(fIn.read())

Keys can be directly compared using the == operator:

if temp_key == key_short.publickey():
    print("Keys are equal after loading ...")
if temp_key == key_long.publickey():
    print("There is an error ...")

RSA OEAP encryption (PKCS#1)

In PKCS#1 there are two sets of primitives for encryption: One is called PKCS#1 v1.5 padding and the other OAEP (Optimal Asymmetric Encryption Padding). Both are considered secure but the way OAEP works (xor’ing the payload with random bytes instead of just padding with randomness as v1.5 padding does) it prevents some types of padding oracles - so when one has the choice one should use OAEP anyways. Also distribution of entropy works better for OAEP than for v1.5 padding.

Encrypting data

First let’s take a look on how to encrypt data. Keep in mind that one usually does not encrypt data directly as is done in the following example. This should never be done in the wild!. One always generated a random key for some symmetric cipher that gets encrypted using the assymetric one. Then one uses this random session key to encrypt data using a symmetric stream cipher such as AES. For sake of shortness the following examples violate that principle anyways:

The steps required to encrypt data using RSA are:

from Cryptodome.Cipher import PKCS1_OAEP
from Cryptodome.PublicKey import RSA

import json

payload = "Testdata"

with open('my_short_key_pub.pem', 'r') as inFile:
    key_pub = RSA.importKey(inFile.read())

# Create a cipher

cipher = PKCS1_OAEP.new(key_pub)

encrypted_payload = cipher.encrypt(payload.encode('utf-8'))

print(encrypted_payload)

Decrypting data

The counterpart requires the private key to decode data. Again the steps are similar:

with open('my_short_key_1.pem', 'r') as inFile:
    key_priv = RSA.importKey(inFile.read(), passphrase = "demo")

cipher = PKCS1_OAEP.new(key_priv)
cleartext = cipher.decrypt(encrypted_payload)

print(cleartext)

Trying with the wrong key

What happens when one passes the wrong key? A simple ValueError is raised:

key_priv = RSA.generate(1024)

cipher = PKCS1_OAEP.new(key_priv)

try:
    cleartext = cipher.decrypt(encrypted_payload)
except ValueError as e:
    print(f"Decryption failed due to invalid key: {e}")

This outputs Decryption failed due to invalid key: Incorrect decryption.

RSA PSS signatures (PKCS#1)

To sign data the PKCS#1 system again supports two methods: The old v1.5 signature scheme that works pretty similar to the v1.5 encryption scheme - this works since signature is basically the same as encryption - one just swaps private and public keys and encrypts the hash of the message (this encrypted hash is then the signature). The idea is that noone can use the published public key to generate a signature on purpose that decrypts to the has of a forged message since the operations are not invertible. The other scheme is the probabilistic signature scheme with appendix that appends a masked nounce to the message and prevents many different possible attacks on the v1.5 scheme (though this is also still considered safe - one should use PSS in case on has the choice). One of the upsides of PSS is that the hash itself is never used directly during the signing (encryption) process but is always combined with the nounce which again prevents some kind of oracles to occure.

from Cryptodome.Signature import pss
from Cryptodome.Hash import SHA256
from Cryptodome.PublicKey import RSA
from Cryptodome import Random

import json

Perform signature

Signature can be applied to any payload. The process is pretty simple:

payload = json.dumps({
    'test1' : 'Some test data',
    'test2' : 123,
    'test3' : "Other test data"
})

with open('my_short_key_1.pem', 'r') as infile:
    signkeypriv = RSA.import_key(infile.read(), passphrase = "demo")

msgHash = SHA256.new(payload.encode('utf-8'))

signSystem = pss.new(signkeypriv)

signature = signSystem.sign(msgHash)

Verify signature

The counterpart is of course verification of a signature. In contrast to encryption one does not recover the original signed hash in this process. One:

The verify method raises a ValueError or TypeError exception when failing to verify the signature.

refHash = SHA256.new(payload.encode('utf-8'))

with open('my_short_key_pub.pem', 'r') as infile:
    signkeypub = RSA.import_key(infile.read())

verifySystem = pss.new(signkeypub)

try:
    verifySystem.verify(refHash, signature)
    print("Signature is valid")
except (ValueError, TypeError) as e:
    print("Failed to verify signature")
    print(e)

Example with damaged signature, wrong key and forged data

A quick checkshows that the signature really gets invalid when one makes changes to the data or uses an invalid key:

Forged data
payloadForged = json.dumps({
    'test1' : 'Some test data',
    'test2' : 124,
    'test3' : "Other test data"
})

refHashForged = SHA256.new(json.dumps(payloadForged).encode('utf-8'))

try:
    verifySystem.verify(refHashForged, signature)
    print("Signature is valid")
except (ValueError, TypeError) as e:
    print("Failed to verify signature")
    print(e)

This will yield Failed to verify signature

Invalid key
signkeypubwrong = RSA.generate(1024).public_key()

verifySystem = pss.new(signkeypubwrong)

try:
    verifySystem.verify(refHash, signature)
    print("Signature is valid")
except (ValueError, TypeError) as e:
    print("Failed to verify signature")
    print(e)

This will yield Failed to verify signature

Simple sign (PSS) and encrypt (OAEP) sample with 1024 bit RSA keys

This is a simple full example of how to transmit a signed and encrypted message between two parties - as traditionally done they are called Alice and Bob in this case and want to hide from an jealous eavesdropper called Eve. Alice and Bob have keysets (private and public keys) where they only know their own private keys - and the public keys of all entities. The distribution of public keys is out of scope and usually uses some kind of certificate authority, sideband verification, challenge response mechanism or web of trust approach. The basic idea is - as usual for RSA - to pack an signed message (hash or the message and message) into an encrypted envelope. The process is pretty straight forward:

Receiving on Bobs side works the other way round:

Imports

from Cryptodome.PublicKey import RSA

from Cryptodome.Signature import pss
from Cryptodome.Hash import SHA256
from Cryptodome import Random

from Cryptodome.Cipher import PKCS1_OAEP

from Cryptodome.Cipher import AES

import json
import base64

Creating keys

key_alice = RSA.generate(1024)
key_bob = RSA.generate(1024)

Alice sending a secret signed message to bob

payload = json.dumps({
    'randomstuff' : base64.b64encode(Random.get_random_bytes(16)).decode('utf-8'),
    'message' : 'This is a secret message from Alice to Bob'
})

# First hash sign and hash
payload_hash = SHA256.new(payload.encode('utf-8'))
signSystem = pss.new(key_alice)
alice_signature = signSystem.sign(payload_hash)

envelope_data = json.dumps({
    'payload' : payload,
    'signature' : base64.b64encode(alice_signature).decode('utf-8'),
})

# Create a random encryption key ... and encrypt our data using AES (CBC mode)
randkey = Random.get_random_bytes(32)
cipher = AES.new(randkey, AES.MODE_EAX)
ciphertext = cipher.encrypt(envelope_data.encode('utf-8'))
nonce = cipher.nonce

# Encrypt the random key with BOBs target public key
cipher = PKCS1_OAEP.new(key_bob.public_key())
encrypted_randkey = cipher.encrypt(randkey)

encrypted_packet = {
    'ciphertext' : base64.b64encode(ciphertext).decode('utf-8'),
    'nonce' : base64.b64encode(nonce).decode('utf-8'),

    'key' : base64.b64encode(encrypted_randkey).decode('utf-8')
}

print(encrypted_packet)

Bob receiving Alice message and verifying …

# Decrypt our random key and base64 decode nounce

nonce = base64.b64decode(encrypted_packet['nonce'])

# Decrypt the random key
cipher = PKCS1_OAEP.new(key_bob)
randkey = cipher.decrypt(base64.b64decode(encrypted_packet['key']))

# Use the decrypted key to decrypt the AES message ...
cipher = AES.new(randkey, AES.MODE_EAX, nonce)
enveloped_data = cipher.decrypt(base64.b64decode(encrypted_packet['ciphertext']))
enveloped_data = json.loads(enveloped_data)

referenceHash = SHA256.new(enveloped_data['payload'].encode('utf-8'))
verifySystem = pss.new(key_alice.public_key())
try:
    verifySystem.verify(referenceHash, base64.b64decode(enveloped_data['signature']))
    print("Signature verification succeeded, message from Alice:")
    print(json.loads(enveloped_data['payload']))
except:
    print("Signature verification failed ...")

This article is tagged:


Data protection policy

Dipl.-Ing. Thomas Spielauer, Wien (webcomplains389t48957@tspi.at)

This webpage is also available via TOR at http://rh6v563nt2dnxd5h2vhhqkudmyvjaevgiv77c62xflas52d5omtkxuid.onion/

Valid HTML 4.01 Strict Powered by FreeBSD IPv6 support