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.
pycryptodomex
can simply be installed from it’s PyPi package:
pip install pycryptodomex
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:
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))
# 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))
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.
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'))
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 ...")
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.
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)
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)
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.
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:
pss.new
passing the private key of the signersign
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)
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:
verify
passing the hash calculated on the received data as well as the signatureThe 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)
A quick checkshows that the signature really gets invalid when one makes changes to the data or uses an invalid key:
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
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
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:
sign
on the payloads hashAES.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)encrypt
on the byte representation of the signed message. This yields the encrypted message objectencrypt
to encrypt the random key (that has to be smaller than the RSA key length)Receiving on Bobs side works the other way round:
decrypt
to perform RSA decryption of the session key. This yields the session key in plaintextverify
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
key_alice = RSA.generate(1024)
key_bob = RSA.generate(1024)
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)
# 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:
Dipl.-Ing. Thomas Spielauer, Wien (webcomplains389t48957@tspi.at)
This webpage is also available via TOR at http://rh6v563nt2dnxd5h2vhhqkudmyvjaevgiv77c62xflas52d5omtkxuid.onion/