Title: Encrypt and decrypt messages in Python
My previous post Use steganography to hide messages in an image in Python showed how you can use a particular encryption method to hide messages in an image. I said I would follow that one up with improved versions. Before I can do that, though, I need to show you how to perform another kind of encryption that uses the Fernet algorithm.
Encryption and decryption in Python is pretty easy with the cryptography library. (Use pip install cryptography.) That library includes several options for encryption and decryption. In this post, I'll talk about one: symmetric encryption with the Fernet algorithm. In symmetric encryption, you use the same key to encrypt and decrypt the message.
Modern encryption works with bytes not text. That makes it easy to encrypt and decrypt anything that can be stored in bytes: text, images, video, etc.
The steps needed to set up the cryptographic algorithms are annoyingly complicated, so I've wrapped them in functions to make things a bit easier. There are two sets of functions: one that works with bytes and one that works with strings.
Before we get started encrypting and decrypting bytes, though, we need a way to set up the Fernet object that will perform the actual encryption.
Getting a Fernet Object
To encrypt or decrypt messages, you need an object that represents the encryption algorithm. This example uses the Fernet algorithm.
TO make the object work correctly, you need to go through some setup steps. You need to use the same steps to encrypt and decrypt, so I wrapped them in the following get_fernet function.
import os
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.kdf.scrypt import Scrypt
def get_fernet(salt_bytes, password_bytes):
'''Get the Fernet object needed to encrypt and decrypt.'''
# Configure a Scrypt key derivation function (KDF).
kdf = Scrypt(
salt=salt_bytes,
length=32, # Fernet requires a 32-byte key
n=2**14, # CPU/Memory cost parameter
r=8, # Block size parameter
p=1 # Parallelization parameter
)
# Derive the key and encode it in URL-safe Base64 (required by Fernet).
derived_key = kdf.derive(password_bytes)
fernet_key = base64.urlsafe_b64encode(derived_key)
# Create and return the Fernet object.
return Fernet(fernet_key)
The function first creates a Scrypt (pronounced ess-script) object to set parameters for a key derivation function (KDF). It uses the KDF (with the parameters) to convert the password into a key. It then passes the key to base64.urlsafe_b64encode, which encodes the bytes generated by kdf.derive into a string that is safe for the Fernet class.
Finally, the function uses the key to create the Fernet object that will perform the encryption/decryption and returns it.
Encrypting Bytes
As I mentioned, encryption algorithms work with bytes. To make encrypting bytes easier, I wrapped the process in the following encrypt function.
def encrypt_bytes(plain_bytes, password_bytes):
'''Use Fernet to encrypt the message (bytes).'''
# Generate a random 16-byte salt to prevent rainbow table attacks.
salt_bytes = os.urandom(16)
# Get the Fernet object.
fernet = get_fernet(salt_bytes, password_bytes)
# Encrypt.
return fernet.encrypt(plain_bytes), salt_bytes
This code first generates a random salt to prevent rainbow table attacks where an attacker makes a table of pre-encrypted values. Instead of trying to guess passwords, the attacker looks up encrypted values to discover the original password. Normally this attack is used on encrypted passwords to try to break a password table.
To prevent this kind of attack, the algorithm adds a salt value to the password. That way a precomputed rainbow table would need to pre-encrypt every value with every possible salt value, which would make the table prohibitively larger.
The function then calls get_fernet to get a Fernet object and uses its encrypt method to encrypt the message. It then returns the encrypted message (which will be a bytes object) and the salt (which you'll need to save so you can decrypt the message later).
Decrypting Messages
The following decrypt_bytes function decrypts messages.
def decrypt_bytes(cipher_bytes, password_bytes, salt_bytes):
'''Decrypt the message.'''
# Get the Fernet object.
fernet = get_fernet(salt_bytes, password_bytes)
# Decrypt.
return fernet.decrypt(cipher_bytes)
The function calls get_fernet to get a Fernet object just as the encrypt function does. It then simply calls the object's decrypt method to decrypt the message. The returned result is another bytes object.
Encrypting Strings
Now that you have functions to encrypt and decrypt bytes, it's easy enough to use them to encrypt and decrypt strings. Simply convert the strings to and from bytes and use the first two functions.
Here's the encrypt_string function.
def encrypt_string(message_text, password_text):
'''Use Fernet to encrypt the message string.'''
# Convert to bytes.
message_bytes = message_text.encode('utf-8')
password_bytes = password_text.encode('utf-8')
# Encrypt.
cipher_bytes, salt_bytes = encrypt_bytes(message_bytes, password_bytes)
return cipher_bytes.decode('UTF-8'), salt_bytes
The function takes two strings inputs: a string and a password. It uses encode to convert the message string into bytes. It passes the bytes to encrypt_bytes, converts the returned encrypted bytes into a string, and returns that plus the salt.
Note that encrypt_string does not convert the salt into a string, it's still in bytes. The random bytes generated as a salt cannot always be converted into a string, so you're stuck with bytes. That may be just as well because you need to save it for later, so you'll probably want to save it in a file and you may as well save it as bytes.
The following code shows the function's counterpart decrypt_string.
def decrypt_string(cipher_text, password_text, salt_bytes):
'''Decrypt the message.'''
# Convert to bytes.
cipher_bytes = cipher_text.encode('UTF-8')
password_bytes = password_text.encode('UTF-8')
# Decrypt.
plain_bytes = decrypt_bytes(cipher_bytes, password_bytes, salt_bytes)
return plain_bytes.decode('UTF-8')
This function uses encode to convert the encrypted message (a string) into bytes. It passes the bytes and salt into the decrypt_bytes function, uses decode to convert the returned bytes into a string, and returns the result.
Using the Functions
Here's how the main program uses the encrypt_string and decrypt_string functions.
message_text = input('Message: ')
password_text = input('Password: ')
# Encrypt.
cipher_text, salt_bytes = encrypt_string(message_text, password_text)
print()
print(f'Salt: {salt_bytes}')
print(f'cipher_text:\n{cipher_text}')
print()
# Decrypt.
password_text = input('Password: ')
recovered_text = decrypt_string(cipher_text, password_text, salt_bytes)
print(f'Recovered text: {recovered_text}')
The program prompts the user for a message and password. (In cryptography terms, the unencoded message is called plaintext.) Because the byte-oriented functions use a UTF-8 encoding, the message can include UTF-8 characters like emojis.
Next, the code calls encrypt_string to encrypt the message. It then displays the the salt (bytes) and the encrypted message (string). (In cryptography terms, the encoded message is called ciphertext.)
The program then gets a new password from the user. Enter the same password to recover the message or enter a different password to see what happens. If the new password differs from the previous one even a tiny bit, the call to fernet.decrypt raises an exception and the whole thing crashes.
The code passes the ciphertext, password, and salt to the decrypt_string function and displays the recovered message.
Conclusion
Cryptographic methods are always a bit complicated but the functions described here wrap up some of the more annoying details and make the process a bit easier.
Download the example to experiment with it and to see additional details.
|