This post is part three of a multi-part series. If you haven’t already, please read parts one and two.
It’s been a while…
Sorry for the extreme (four month!) delay on this series. I got busy with other things and it became hard to return to it, being unsure if anyone would still be interested. Anyways, I felt it should be completed so here we go!
The Code [Cont.]
Unless you have super-human memory, here’s a brief refresher of what I covered in Part II:
-
Initializing the Server
a. Creating the socket
b. Generating p and g for the DHKE
c. Listening for incoming clients -
Initializing a client (as the server)
a. Creating a private/public key pair
b. Sending p, g, and the public key in a standardized format
c. Receiving the client’s public key
d. Calculating the shared key from p, the private key, and the client’s public key
The last line of code shown started a new thread to listen for incoming messages from the newly initialized client:
# Listen for incoming messages from client
threading.Thread(target=self.listen, args=(client, )).start()
This allows the server to initialize new clients while still listening to this one. The server’s listen
method does the following:
- Receive 1024 bytes of data from the client
- Decrypt the data with the client’s shared key
- Broadcast the decrypted message to the rest of the clients on the server
- Repeat!
def listen(self, client):
"""
Receive and handle data from a client.
:param client: client to receive data from
"""
while True:
try:
# Wait for data from client
data = client.connection.recv(1024)
# Disconnect client if no data received
if not data:
self.disconnect(client)
break
print("{} [Raw]: {}".format(client.address[0], data))
# Parse data as cipher-text message
msg = Message(key=client.key, ciphertext=data)
print("{} [Decrypted]: {}".format(client.address[0], msg.plaintext))
if msg.plaintext == "!exit":
client.send("Acknowledged")
self.disconnect(client)
continue
self.broadcast(msg.plaintext, client)
# Disconnect client if unable to read from connection
except OSError:
self.disconnect(client)
break
The Message
class takes care of the encryption and decryption of messages using the shared AES key we generated with the client. You can find it in cipher.py.
When the server broadcasts the message, it has to re-encrypt the plaintext with each recipient’s respective key. Here is the server’s method for doing so:
def broadcast(self, content, from_client, show_address=True):
if show_address:
msg = from_client.address[0] + ": " + content
else:
msg = content
[client.send(msg) for client in self.clients if client is not from_client]
It looks through its list of clients and sends the message to everyone who is not the sender.
And here is the send
method inside of the client:
def send(self, content):
"""
Encrypt and send a message to the client
:param content: plaintext content to be encrypted
"""
msg = Message(key=self.key, plaintext=content)
self.connection.sendall(msg.pack())
It encrypts the message with the client’s key and packages it (by concatenating the initialization vector and the ciphertext) to be sent over the socket.
And that’s it for the server!
3. The Client and the Command-Line Interface
I’m going to skip most of the interface-related code since it doesn’t relate to computer security and it isn’t very interesting anyways. I’ll go over its initialization though, since it may be a useful technique in other projects.
Here’s what happens when you run client.py
:
- A new
CLI
object is created
a. The screen/chat window is initialized (I’m using curses) - A new
Client
object is created (which connects to the chat server) and theCLI
object is passed in so the client can write to the screen. - The
CLI
object is given theClient
so that it can send the input it collects from the user. - The client listener (awaiting messages from the server) is started on a separate thread.
- The command-line interface (awaiting message input from the user) is started on the main thread.
. . .
interface = CLI()
try:
c = Client(interface, args.host, port=args.port)
except ConnectionRefusedError:
interface.clean_exit()
print("Connection Refused")
sys.exit()
except OSError:
interface.clean_exit()
print("Connection Failed")
sys.exit()
# Add the client object to the interface
interface.init_client(c)
# Start the client
client_thread = threading.Thread(target=c.start)
client_thread.start()
# Start the main input loop
try:
interface.main()
except KeyboardInterrupt:
interface.clean_exit()
Giving the client and the interface references to each-other allows them to communicate across separate threads—a useful approach that can be applied in various projects.
Let’s see what happens when we initialize the Client
:
def __init__(self, interface, server_address, port=DEFAULT_PORT):
"""
Initialize a new client.
:param server_address: IP address of the server
:param port: Server port to connect to
"""
self.cli = interface
self.connection = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.cli.add_msg("Connecting to {}...".format(server_address))
try:
self.connection.connect((server_address, port))
except KeyboardInterrupt:
self.cli.clean_exit()
sys.exit()
self.cli.add_msg("Connected!")
self.key = None
You’ll see that a new socket is created and used to connect to the provided server, again using IPv4 and TCP. The client also has the ability to call add_msg
on the interface to print to the screen, as previously mentioned.
When the client is started, via these two lines from earlier:
client_thread = threading.Thread(target=c.start)
client_thread.start()
the client’s start
method is called on another thread. It does the following:
- Perform a DHKE with the server to generate a shared key.
- Listen for incoming messages
- Decrypt incoming messages and display them on the screen.
Let’s see how it accomplishes part 1
:
def start(self):
"""
Start the client: perform key exchange and start listening
for incoming messages.
"""
try:
self.key = self.dh()
except ConnectionError:
self.cli.add_msg("Unable to Connect")
return
. . .
def dh(self):
"""
Perform Diffie-Hellman Key Exchange with the server.
p: prime modulus declared by the server
g: generator declared by the server
server_key: the server's public key
private_key: the client's private key
public_key: the client's public key
:return shared_key: the 256-bit key both the client and
server now share
"""
self.cli.add_msg("Establishing Encryption Key...")
dh_message = self.connection.recv(DH_MSG_SIZE)
# Unpack p, g, and server_key from the server's dh message
p, g, server_key = DH.unpack(dh_message)
# Generate a randomized private key
private_key = DH.gen_private_key()
# Send the server a public key which used the previously
# Generated private key and both g and p
public_key = DH.gen_public_key(g, private_key, p)
self.connection.sendall(DH.package(public_key, LEN_PK))
# Calculate shared key
shared_key = DH.get_shared_key(server_key, private_key, p)
# print("Shared Key: {}".format(shared_key))
self.cli.add_msg("Encryption Key: {}".format(binascii.hexlify(shared_key).decode("utf-8")))
return shared_key
As you’ll notice, the client’s dh
method is quite similar to that of the server—the only difference being it receives p
and g
instead of generating them, and is the first to get the other’s public key. It then creates a private/public key pair sends the public key. Finally, it calculates the shared key with the server’s public key, its own private key, and p
.
To wrap things up, let’s view the rest of the start
method, which covers 2
and 3
.
. . .
while True:
try:
# Wait for data from server
data = self.connection.recv(1024)
# Disconnect from server if no data received
if not data:
self.connection.close()
self.cli.uninit_client()
break
# Parse data as cipher-text message
msg = Message(key=self.key, ciphertext=data)
if not self.cli:
break
# Add message to the command-line interface
self.cli.add_msg(msg.plaintext)
# Disconnect client if unable to read from connection
except OSError:
self.connection.close()
self.cli.uninit_client()
break
Again, this looks very similar to the server code. It attempts to receive 1024 bytes from the server, and once it does, it decrypts the data using the shared key. Lastly, it displays the plaintext message to the screen through the command-line interface.
When the user submits a message, client.send()
is called by the interface, which encrypts it and sends it to the server:
def send(self, content):
"""
Send a message to the server.
:param content: string to encrypt and send
"""
if not self.key:
self.cli.add_msg("Error: Key Not Established")
return
msg = Message(key=self.key, plaintext=content)
self.connection.sendall(msg.pack())
The End.
That completes the encrypted chat series, props to those who stuck around for this long! If you have any questions, I’d be happy to answer them. Just leave a comment or send me a direct message.
P.S.
I’ve also been working on a version 2 of this program that works more like a modern chat program, with user accounts, message storage, chat groups, and cool security features (that were recommended by users of 0x00sec!). It’s still in its early stages, but I’ve already put the code on GitHub if anyone is interested: https://github.com/spec-sec/SecureChat2.