/ python

Writing a Secure Encrypted Chat in Python

When using a stream ciphers to pass encrypted messages to the other, there are some potential traps that we should look out for. Let's explore them and then move on to a demo chat application.

Key Storage

The first question is where do you store the key. Modern (mobile) platforms provide a secure key storage as part of the operating system. Desktop and server OS still don't.

These are the requirements we need to consider:

  1. It should be easy to change keys periodically.
  2. It should be easy to identify key material (and keep it secret).
  3. Other processes on the machine shouldn't (easily) be able to read the key.

There are 3 main approaches one can use when deciding about key storage:

  1. Use a password entered by the user at boot, and derive the key from that.
  2. Keep password or key in an environment variable.
  3. Keep key in a key file that is stored near the application.

For example lastpass uses (1), Rails uses (3) and perforce client uses (2).

The main problem with user selected passwords are they're easy to guess. 123456 is still the most popular hacked password in the world. That's why when deriving a key from a user selected password it's important to add a random element to that password (called salt). This prevents pre-calculation attacks.

A function that takes a user generated password and a random salt and creates a key is called a key derivation function. In the example below I'll use PBKDF2. Other popular key derivation functions include scrypt, bcrypt and Argon2.

The main problem with a key file is that you need to save that file somewhere and keep it secret. For many applications this can be hard to manage. Rails and other web application frameworks that already enforce a certain directory structure can afford to add such a file.

Random IV

A stream cipher produces a stream of bits (as a function of the key) and XORs it with the message. Using the same key to XOR multiple messages is very dangerous, as an attacker who can see those encrypted messages can XOR them in encrypted form and get the same result as XORing the plain texts.

In order to use a different part of the key stream in every chat we add a random element to the key called an IV or nonce. That IV is randomized at every boot and must be synchronised between both parties in the chat.

Luckily the IV should not be kept a secret and it's ok to send it plaintext to the other party.

cryptography.io makes it easy to use a random IV:

nonce     = os.urandom(16)
algorithm = algorithms.ChaCha20(key, nonce)

HMAC Authentication

Finally each message should be authenticated to ensure it hasn't changed in transit. We'll use HMAC to verify the authenticity of our messages, which requires another key. We'll use the same password as a source but this time feed our key derivation function with a different salt value, which will yield a different key.

Every message will be sent with the authentication tag alongside. The authentication tag is calculated on all previous messages in the chat, so skipping a message would be detected immediately.

Full Demo Code: Encryption

To encrypt messages we can use the following code, which reads data from stdin and prints it encrypted with the authentication tag:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, hmac
import os
import sys

backend = default_backend()

salt = os.urandom(16)
kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=100000,
        backend=backend
        )

mac_salt = os.urandom(16)
mac_kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=mac_salt,
        iterations=100000,
        backend=backend
        )

print("Using Salt: {}".format(bytearray(salt).hex()))
print("Using Mac Salt: {}".format(bytearray(mac_salt).hex()))

key = kdf.derive(os.environ['PASSWORD'].encode('utf-8'))
mac_key = mac_kdf.derive(os.environ['PASSWORD'].encode('utf-8'))

nonce     = os.urandom(16)
print("Using IV: {}".format(bytearray(nonce).hex()))

algorithm = algorithms.ChaCha20(key, nonce)
cipher    = Cipher(algorithm, mode=None, backend=default_backend())
encryptor = cipher.encryptor()
h = hmac.HMAC(mac_key, hashes.SHA256(), backend=default_backend())
for line in sys.stdin:
    ct = encryptor.update(line.encode('utf-8'))
    h.update(ct)
    msg = h.copy().finalize() + ct
    print(bytearray(msg).hex())

Full Demo Code: Decryption

To decrypt the messages we'll need the 3 IVs our encryptor randomized and used. Since they're not secret we can pass then as command line arguments to the decryption program. Here's the code using cryptography.io:

from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import hashes, hmac

import os
import sys

backend = default_backend()
salt = bytes(bytearray.fromhex(sys.argv[1]))

kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=salt,
        iterations=100000,
        backend=backend
        )

mac_salt = bytes(bytearray.fromhex(sys.argv[2]))
mac_kdf = PBKDF2HMAC(
        algorithm=hashes.SHA256(),
        length=32,
        salt=mac_salt,
        iterations=100000,
        backend=backend
        )

key = kdf.derive(os.environ['PASSWORD'].encode('utf-8'))
mac_key = mac_kdf.derive(os.environ['PASSWORD'].encode('utf-8'))

nonce     = bytes(bytearray.fromhex(sys.argv[3]))
algorithm = algorithms.ChaCha20(key, nonce)
cipher    = Cipher(algorithm, mode=None, backend=default_backend())
decryptor = cipher.decryptor()
h = hmac.HMAC(mac_key, hashes.SHA256(), backend=default_backend())

for line in sys.stdin:    
    print(line[0:64])
    sig = bytes(bytearray.fromhex(line[0:64]))
    print(line.strip()[64:])
    msg = bytes(bytearray.fromhex(line.strip()[64:]))

    h.update(msg)
    h.copy().verify(sig)

    print(decryptor.update(msg))

To run the code you'll need to set PASSWORD environment variable to a secret password shared by both encryption and decryption program, and install cryptography.io.