Using pycryptodomex for encryption and signing (PKCS1 OAEP and PSS) in Python
22 Mar 2023 - tsp
Last update 23 Mar 2023
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:
- Generate them
- Export them into some serialized format. Usually one uses PEM (ASCII based
representation that contains keys and certificates in ASCII armor) or some
binary ASN.1 based encoding such das DER (distinguished encoding rules which
offer a unique well defined binary representation). This data can be stored
in files or transmitted over the network - or embedded in other datastructures
- Loading keys from some external storage or memory format
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:
- Creating the payload - this will be represented as byte array for encryption
- Loading public key of the receiver
- Creating a cipher object with the destination public key
- Encrypt byte array using the cipher object
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:
- Loading the private key of the receiver
- Creating a cipher object with the private key of the receiver
- Decrypting the byte array (also yields a byte array)
- Optionally processing the byte array
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
Signature can be applied to any payload. The process is pretty simple:
- Generate your payload - this has to be the same payload encoding that you transmit later on (bytewise) - and encode as byte buffer
- Load the private key that you want to use for signing
- Hash the content you want to sign (in this sample itās a SHA256 hash). The validity of the signature will be based on the equivalence of the hashes.
- Create the PSS signature object using
pss.new passing the private key of the signer
- Signing using
sign and passing the hash (as byte array). This signed hash will then be the signature.
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:
- ⦠generates a hash of the received data using the same hashing algorithm
- ⦠loads the public key of the entity that claims to have signed the data
- ⦠again creates a PSS signature system using the public key
- and calls
verify passing the hash calculated on the received data as well as the signature
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:
- Create the message and itās byte representation. This should already include some randomness in many cases (though not being required in this case there are stream cipher modes where this is required to prevent oracle based attacks - and usually it doesnāt hurt anyways).
- Sign the message
- Create the hash of the message
- Create the PSS signature system using Alices private key
- Call
sign on the payloads hash
- One now has the message and the signature. The signature will be base64 encoded in this example. Most cryptosystems would keep it binary. This has been choosen that way to represent every stage inside each envelope as JSON object. Note that this might not be required - one can simply cocatenate the byte representations for example.
- Create a byte repsentation of the signed message.
- Symmetrically encrypt the signed message envelope:
- Create a random key for AES
- Create a cipher object using
AES.new passing in the random key as well as one of the block operation modes (usually using CBC for traditional modes, the OpenPGP modified mode or some mode like EAX that would also support message integrity protection thatās not used in this example)
- Call
encrypt on the byte representation of the signed message. This yields the encrypted message object
- When using a mode that requires a nonce store the nonce in the envelope object
- Use PKCS#1 OAEP to encrypt the random key with Bobās (i.e. the receivers) public key:
- Load Bobs public key
- Create the OAEP object with the public key
- Call
encrypt to encrypt the random key (that has to be smaller than the RSA key length)
- Store the encrypted envelope (ciphertext) together with the encrypted random key and the nonce in some other envelope object that can be serialized and transmitted to Bob
Receiving on Bobs side works the other way round:
- Recover the nonce from the envelope object
- Decode the random session key:
- Create the OAEP cipher object using Bobās private key
- Call
decrypt to perform RSA decryption of the session key. This yields the session key in plaintext
- Use the nonce and the recovered session key to perform stream cipher (AES) decryption of the encrypted envelope. This yields the object containing the signature and the signed payload
- In our case JSON deserialize the payload to yield a Python dictionary
- Verify the signature
- Calculate our own hash of the serialized (signed) payload
- Create the PSS signature object using Alices public key
- Verify the signature using
verify
- In case the verification succeeds we accept the message, else we have to drop it as invalid or forged.
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: