3
\$\begingroup\$

For learning purposes and because it really interests me I am trying to get a better understanding of cryptography by trying to make my own basic secure chat-application. I posted a first version of my attempt here. There were a few flaws in my implementation:

  • My implementation was not E2EE because my server acted as a proxy to generate the derived key.
  • The server had to much knowledge about keys (generating derived key for AES, containing public RSA key,...).
  • I used AES mode ECB (which is less safe).
  • The clients had no verification to trust each other.

In this follow-up question I made a new (hopefully) improved version. I will explain the context again: Alice, Bob and Charlie want a safe way to communicate with each other. So they made a Python script server.py which only function is to echo the incoming messages back to all connected clients, besides also generating a salt unique for each session. They put this server-script on a simple VPS.

They also made a script chat.py. It can be called like this python3 chat.py --user=alice --password=abc123. The idea is Alice, Bob and Charlie agree on a password to use for their chat session. They can only read each other's messages if they all use the same password.

In this improved version all sent messages are also verified using a RSA signature. To achieve this they generated RSA Keypairs:

  • Alice manually generates a RSA-keypair. Saves her private key next to chat.py and shares the public key with Bob and Charlie.
  • Bob manually generates a RSA-keypair. Saves his private key next to chat.py and shares the public key with Alice and Charlie.
  • Charlie manually generates a RSA-keypair. Saves his private key next to chat.py and shares the public key with Alice and Bob.

This results in each of them having e.g a flash drive with the following structure. For Alice:

├── alice.pem #private key
├── chat.py
├── public_keys
    ├── alice.pem
    ├── bob.pem
    └── charlie.pem

This is how the implementation works.

  1. A user initiates a chat executing python3 chat.py --user=alice --password=pass123.
  2. The server generates a unique salt for each session This salt will be used later in Argon2 and PBKDF2_HMAC.
  3. The client retrieves this salt.
  4. The client generates a Argon2-hash with the password pass123 and the retrieved salt.
  5. This hash is because of it's length not usable as AES-key. So we use PBKDF2_HMAC as KDF with the Argon2-hash and the previously retrieved salt.
  6. When a user sends a message we generate a AES-cipher and encrypt the text of the message. Because we use MODE_EAX this message also gets a tag and the cipher gets a nonce.
  7. The message gets also signed using the private key of the user using pkcs1_15. We pass the encrypted version of the message as parameter to get signed and NOT the plain-text version.
  8. Now we send the encrypted message, RSA signature, AES nonce, and AES tag to the server.
  9. The only thing the server does is echoing the request to all connected clients.
  10. When a client receives a message the first thing it does is verifying the RSA-signature. It does this by checking if there is a public_key who can verify the signature.
  11. If that's the case a AES-cipher is generated using the nonce which came with the request.
  12. The message gets decrypted and we check if the tag of the message can be verified.
  13. If that's the case, we print the message to chat.

server.py

import asyncio
import websockets
import binascii
import secrets
import json

# Clients connected to chat server.
clients = set()

# Generate salt unique to this session.
salt = secrets.token_hex(16)

# Function to handle connection (Only one connection and one client in this simplified code).
async def handle_connection(websocket, url):
    
    # Add client.
    clients.add(websocket)

    async for request in websocket:
        print(request)
        jsonRequest = json_decode_request(request)
       
        if "action" in jsonRequest:
            # There are a few special request 'actions' the app
            # of the client automatically does. E.g to retrieve
            # the used salt.
            match jsonRequest["action"]:
                case "retrieve-salt":
                    await websocket.send(salt.encode('utf-8'))
                case other:
                    pass
        else:
            # Receiving messages as example
            # The only thing the server does is echoing the incoming
            # request back to all clients. It has no idea of any keys.
            await asyncio.gather(*[client.send(request) for client in clients])

    await websocket.send(request)

# (ignore) Decode potentially hexified values from
# the request into JSON.
def json_decode_request(request):
    jsonReq = json.loads(request)
    for key, value in jsonReq.items():
        try:
            jsonReq[key] = binascii.unhexlify(value)
        except:
            continue
    return jsonReq

start = websockets.serve(handle_connection, "localhost", 1234)

asyncio.get_event_loop().run_until_complete(start)
asyncio.get_event_loop().run_forever()

chat.py

import os
import asyncio
import websockets
import hashlib
import json
import argparse
import binascii
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.PublicKey import RSA
from Crypto.Signature import pkcs1_15
from Crypto.Hash import SHA256
from argon2 import PasswordHasher

# Run this example with different users as explained in question.
parser = argparse.ArgumentParser()
parser.add_argument('--user', '-user', help = "Choose a user.", type = str)
parser.add_argument('--password', '-password', help = "Choose a password.", type = str)
args=parser.parse_args()

# Load RSA private key.
def import_key_from_file(path):
    with open(path, "r") as keyFile:
        key = keyFile.read()
    return RSA.import_key(key)
# Note!
# In this example all private keys of the users are present in the folder.
# In real each user would only have their own private key.
privateKey = import_key_from_file(args.user + ".pem")

# Client enters a password.
password = args.password.encode('utf-8')

async def connect():
    url = "ws://localhost:1234"
    async with websockets.connect(url) as websocket:
        
        # Receive salt from server
        salt = await retrieve_salt(websocket)

        # Generate secure hash of password using Argom2
        hasher = PasswordHasher(
            hash_len=100,
            salt_len=len(salt)
        )
        hash = hasher.hash(password, salt = salt).encode('utf-8')
        
        # Generate derived key using PBKDF2_HMAC
        derivedKey = hashlib.pbkdf2_hmac('sha256', hash, salt, 60000)

        # --- Sending message ---
        sendingMsg = f"Hello. I am {args.user}.".encode('utf-8')
        await send_message(websocket, derivedKey, sendingMsg)

        # -- Receiving message ---
        await receive_message(websocket, derivedKey)

async def send_message(websocket, derivedKey, text):
    # AES.new() must be called for each message. Because:
    # A cipher object is stateful: once you have decrypted a message
    # you cannot decrypt (or encrypt) another message with the same
    #  object.
    aesCipher = AES.new(derivedKey, AES.MODE_EAX)
    
    # Encrypt message.
    sendingMsgEncrypted, sendingTag = aesCipher.encrypt_and_digest(pad(text, AES.block_size, style='pkcs7'))

    # Sign encrypted message using private key (RSA).
    sendingMsgSignature = sign_message(sendingMsgEncrypted)

    # Send.
    await websocket.send(message_to_json(sendingMsgEncrypted, sendingMsgSignature, aesCipher.nonce, sendingTag))

async def receive_message(websocket, derivedKey):
    while True:
        incomingMsg = json_decode_request(await websocket.recv())
        
        # Verify message using public RSA key.
        verification = verify_message(incomingMsg["message"], incomingMsg["signature"])

        if not verification:
            print("This message could not be verified!")
            return
    
        # AES.new() must be called for each message. Because:
        # A cipher object is stateful: once you have decrypted a message
        # you cannot decrypt (or encrypt) another message with the same
        #  object.
        aesCipher = AES.new(derivedKey, AES.MODE_EAX, nonce = incomingMsg["aes-nonce"])
        
        # Decrypt.
        decryptedMessage = unpad(aesCipher.decrypt(incomingMsg["message"]), AES.block_size, style='pkcs7').decode('utf-8')
        
        # Verify AES tag.
        try:
            aesCipher.verify(incomingMsg["aes-tag"])
            # Print to chat.
            print(decryptedMessage)
        except:
            print("This message could not be verified.")
            return

# Retrieve salt from server.
async def retrieve_salt(websocket):
    await websocket.send(json.dumps({"action": "retrieve-salt"}))
    return await websocket.recv()

# Sign a message using RSA private key.
def sign_message(message):
    messageHash = SHA256.new(message)
    return pkcs1_15.new(privateKey).sign(messageHash)

# Verify message signature using RSA public key.
def verify_message(message, signature):
    messageHash = SHA256.new(message)
    
    # A incoming message can come from Alice, Bob or Charlie.
    # We have all these persons public keys and loop over them
    # to check if a message is signed by one of them.
    for filename in os.listdir("public_keys"):
        path = "public_keys/" + filename
        publicKey = import_key_from_file(path)
        
        try:
            pkcs1_15.new(publicKey).verify(messageHash, signature)
        except:
            continue 
        return True
    
    return False

# (ignore) Helper function required because:
# TypeError: Object of type bytes is not JSON serializable.
def message_to_json(message, signature, aes_nonce, aes_tag):
    return json.dumps({
        "message": binascii.hexlify(message).decode('utf-8'),
        "signature": binascii.hexlify(signature).decode('utf-8'),
        "aes-nonce": binascii.hexlify(aes_nonce).decode('utf-8'),
        "aes-tag": binascii.hexlify(aes_tag).decode('utf-8')
    })

# (ignore)
def json_decode_request(request):
    jsonReq = json.loads(request)
    return {
        "message": binascii.unhexlify(jsonReq["message"]),
        "signature": binascii.unhexlify(jsonReq["signature"]),
        "aes-nonce": binascii.unhexlify(jsonReq["aes-nonce"]),
        "aes-tag": binascii.unhexlify(jsonReq["aes-tag"])
    }

if __name__ == "__main__":
    asyncio.get_event_loop().run_until_complete(connect())

Notes:

  • So it is not intended to be a chat-app for a large user-base. Only for a very limited amount of people who agreed to use this app.
  • In real, all connections will go over SSL.

Questions:

  • Is this implementation better then my previous attempt?
  • Is this a relatively good implementation?
  • Is this considered E2EE? Because only the clients know the AES-key?
  • Other comments?
\$\endgroup\$

1 Answer 1

3
\$\begingroup\$

The protocol is already much better than the previous version where the server knew about all the keys. However, there are some weird things happening:

  • both a private key and a password is used; this is not necessarily bad, but in general you need only the key pair
  • the symmetric key is derived from the password using two PBKDF's (Argon & PBKDF2)
  • the key derivation seems to be performed for each message
  • although the salt is indicated to be "unique per connection" this isn't actually implemented, the salt seems to be the same
  • it isn't clear how the salt would be handled if multiple clients are sending messages to the server
  • the password seems to be unique per connection rather than per user
  • there doesn't seem to be any protection against replay attacks
  • the signature seems to be calculated for each message while signature generation is relatively expensive
  • the messages are now protected using the authentication part of EAX mode and a signature, which is too much
  • we'll have to assume that the public keys are distributed securely
  • worse, we have to assume that the password (secrets) are distributed securely
  • state handling is enough for a single connection between two clients, but the protocol probably needs extension to be able to handle more message better
  • although sending all messages to all people helps against guessing who is talking to who (as long as the TLS connection is created first) it would be relatively expensive
  • the protocol cannot be extended as there is no version number or similar within the messages
  • EAX mode doesn't require padding

There are also some good things:

  • the use of TLS to the server makes it very hard to get any information directly from the connections
  • the use of JSON makes it possible to more clearly separate different elements in the request & responses
  • the use of reasonably secure algorithms such as AES in EAX mode and RSA in PKCS#1 mode
  • the use of the secrets module to create the salt
  • the use of a random nonce with EAX mode, included with the message
  • little wrong with the code itself in my opinion
\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Not the answer you're looking for? Browse other questions tagged or ask your own question.