Fernet system for symmetric encryption

Martin McBride, 2020-02-23
Tags fernet encryption symmetric encryption
Categories cryptography
In Python libraries

This article is part of a series on the Python cryptography library.

Refer to the glossary of cryptography terms for definitions of any terms used in this chapter.

Overview of Fernet

Fernet is a system for symmetric encryption/decryption, using current best practices. It also authenticates the message, which measm that the recipient can tell if the message has been altered in any way from what was originally sent.

Fernet overcomes many of the problems obvious mistakes a naive developer might make when designing such a system by:

  • Providing a secure mechanism for generating keys (a key is similar to a password).
  • Selection a secure encryption algorithm (AES using CBS mode and PKCS7 padding)
  • Randomly allocating a secure "salt" value IV) to make the encryption more secure.
  • Timestamping the encrypted message.
  • Signing the message (using HMAC and SHA256) to detect any attempts to change it.

We will look at the hows and whys of these features later in this article.

Using Fernet

Fernet is included in the cryptography library.

To encrypt and decrypt data, we will need a secret key that must be shared between anyone who needs to encrypt or decrypt data. It must be kept secret from anyone else, because anyone who knows the key can read and create encrypted messages. This means we will need a secure mechanism to share the key. The same key can used multiple times.

Creating a key

We can create a key like this:

from cryptography.fernet import Fernet
import base64

key = Fernet.generate_key()

The key is a random value, and will be completely different each time you call the generate_key function.

Encrypting a message

To encrypt a message, we must first create a Fernet object using the key created previously. We than call the encrypt function, passing the data we wish to encrypt is the form of a bytes array:

cipher = Fernet(key)

message = "Message to be encrypted".encode('utf-8')
token = cipher.encrypt(message)

Notice that we use the encode('uft-8') method to convert our message string into a bytes array. If we want to encrypt an image or other data, we must load it into memory as a byte array.

The encrypted message is stored in token in the format described below.

Decrypting a message

To decrypt a message, we must again create a Fernet object using the same key that was used to encrypt the data. We than call the decrypt function, passing the data we wish to decrypt is the form of a bytes array. The function returns the decrypted original message.

cipher = Fernet(key)

decoded = cipher.decrypt(token)

decrypt will raise an exception if it cannot decode token for any reason. For example:

  • The token is malformed, most likely because it has an invalid length, see later.
  • The HMAC signature doesn't match. This could because the key is incorrect or because the token has been modified after creation.
  • The token has expired.

Fernet tokens contain a timestamp which allows testing for an expired message. To do this you must add the a ttl (time to live) parameter to the decrypt function that specifies a maximum age (in seconds) of the token before it will be rejected. For example:

decoded = cipher.decrypt(token, 24*60*60)

This will reject any messages that are more than 1 day old. If you do not set a ttl value (or set it to None), the age of the token will not be checked at all.

Fernet key and token format

A fernet key as returned by the generate_key actually contains two 16-byte keys:

  • A signing key used to sign the HMAC.
  • A private key used by the encryption.

These two values are concatenated to form a 32 byte value:

This 32 byte key is then encoded using Base64 encoding. This encodes the binary quantity as string of ASCII characters. The variant of Base64 used is URL and filename safe, meaning that it doesn't contain any characters that aren't permitted in a URL or a valid filename in any major operating system.

If we print the key, the result is a 44 byte string representing the 32 byte bunary value.

print(key)   # b'K7GVACyA63l--mNkBjQ5tbkDxO6yCJkmr9D-uV5T-wU='

A Fernet token contains the following fields:

The fields are:

  • Version, 1 byte - the only valid value currently is 128.
  • Timestamp 8 bytes - a 64 bit, unsigned, big-endian integer that indicates when the ciphertext was created. Time is represented as the number of seconds since the start of Jan 1, 1970, UTC.
  • IV 32 bytes - the 128 bit Initialization Vector used in AES encryption and decryption.
  • Ciphertext - the encrypted version of the plaintext message. This is encrypted using AES, in CBC mode, using the encryption key section of the Fernet key. The ciphertext is padded to be a multiple of 128 bits, which is the AES block size, using the PKCS7 padding algorithm. This menas that the ciphertest will always be a multiple of 16 bytes in length, but the padding will be automatically removed when the data is decrypted.
  • HMAC - a 256-bit HMAC of the concatenated Version, Timestamp, IV, and Ciphertext fields. The HMAC is signed using the signing key section o fteh Fernet key.

The entire token (including the HMAC) is encoded using Base64. The HMAC is calculated using the binary data of the Version, Timestamp, IV, and Ciphertext fields, before Base64 encoding is applied.

If you found this article useful, you might be interested in the book Functional Programming in Python or other books by the same author.