Initial Commit

This commit is contained in:
2025-07-05 14:24:22 +01:00
commit b31d19e336
19 changed files with 3858 additions and 0 deletions

7
Client/ClientTempMail.py Normal file
View File

@ -0,0 +1,7 @@
# This is a class to help the client side with holding the mail structure that mimics the one used by the server
class ClientTempMail:
def __init__(self):
self.from_address = None
self.to_address_list = []
self.subject = None
self.body = None

546
Client/ClientThread.py Normal file
View File

@ -0,0 +1,546 @@
import queue
import socket
import selectors
import time
from datetime import datetime
from threading import Thread
from time import sleep
import ClientTempMail
import Crypto
# Included the unix timestamp function from the DBConn file so we dont have to use the entire class just for this
def get_unix_timestamp(seconds_offset=0) -> int:
"""
Function to get the current unix timestamp to sim the NOW() sql method, seconds offset will pull a
past of future timestamp
"""
return int(time.time()) + seconds_offset
class ClientThread(Thread):
"""Class for dealing with the threaded actions of the client app"""
def __init__(self, current_socket: socket, address: tuple):
Thread.__init__(self)
self.__address = address
self.__socket: socket.socket = current_socket
self.__selector = selectors.DefaultSelector()
self.__buffer_in = queue.Queue()
self.__buffer_out = queue.Queue()
# flag for the current network status, -1 for awaiting request, 0 for awaiting response, 1 for ready
self.current_status = -1
self.state = "INIT"
self.__init_timeout = get_unix_timestamp()
self.last_response_content = []
# Login and User variables
self.__session_ref = None
self.login_username = None
self.__user_full_name = None
self.__user_email = None
self.__user_role = None
self.initialised = False
# Mail class to hold the mail data structure that mimics the server version
self.client_mail_temp: ClientTempMail.ClientTempMail or None = None
self.__queued_messages = []
# Handshake and Encryption Variables
self.__pending_handshake = False
self.__hand_shook = False
self.__crypto_manager_receive: Crypto.Receiver or None = None
self.__crypto_manager_transmit: Crypto.Transmitter or None = None
# Active and Heartbeat Vars
self.active = True
self.__heartbeat_timer = None
self.__heartbeat_tick = -120
self.__selector.register(self.__socket, selectors.EVENT_READ | selectors.EVENT_WRITE, data=None)
def is_connected(self) -> bool:
"""Method for checking if the connection is active, and sending the noop command for the heartbeat"""
if not self.__socket:
return False
# If the connection is initialised we can send commands
if self.initialised:
try:
# Send heartbeat NOOP command, this is a background command as to not ruin the flow of the program
# since actual SMTP servers work in a specific order
self.queue_command("NOOP")
except Exception:
return False
return True
def get_users_role(self) -> str:
"""Method for getting the current users server role (USER or ADMIN)"""
return self.__user_role
def run(self):
"""Main threads looped run method that listens and writes our network data"""
pending_handshake_counter = 0
try:
while self.active:
waiting_commands = self.__selector.select(timeout=1)
for key, mask in waiting_commands:
try:
if mask & selectors.EVENT_READ:
self.__read()
elif mask & selectors.EVENT_WRITE and not self.__buffer_out.empty():
self.__write()
# Process any queued messages when an actual message is not being processed
elif mask & selectors.EVENT_WRITE and len(self.__queued_messages) > 0:
self.__write_queue()
except Exception as ex:
print(f"Exception hit: {repr(ex)}")
self.__close()
# Init time out makes sure that a "valid" connection it makes will time out if the server
# does not respond the way we want it to
if self.state == "INIT":
if self.__init_timeout < get_unix_timestamp(-40):
print("No INIT processed, the connection is most likely at fault.")
self.active = False
# Pending handshake so we can send the AUTH response then set the handshook flag after it has been sent
if self.__pending_handshake:
if pending_handshake_counter == 1:
self.__pending_handshake = False
self.__hand_shook = True
pending_handshake_counter = 0
else:
pending_handshake_counter += 1
# If logged in we can do our Heartbeat NOOP request to faux keep our connection alive
if self.__session_ref:
if self.__heartbeat_timer < get_unix_timestamp(self.__heartbeat_tick):
if not self.is_connected():
print("Server did not respond")
self.__close()
if not self.__selector.get_map():
break
finally:
self.__selector.close()
def __read(self):
"""Private method for reading the network data coming into the client"""
try:
data_in = self.__socket.recv(1024)
except BlockingIOError:
pass
else:
if data_in:
if self.__hand_shook:
# We decode the bytes of the message we receive, this should be a hex string
data_in_decoded_string = data_in.decode()
# If needed we split the response into chunks so that we can decrypt the multiple blocks that
# were sent
chunk_size = self.__crypto_manager_receive.get_expected_block_size()
data_in_chunks = [self.__crypto_manager_receive.decrypt_string(
data_in_decoded_string[i:i + chunk_size]
) for i in range(0, len(data_in_decoded_string), chunk_size)]
# Join all of the decrypted chunks back together
full_data_in = "".join(data_in_chunks)
self.__buffer_in.put(full_data_in)
else:
# Data is treated normally when we have not shaken hands
self.__buffer_in.put(data_in.decode())
else:
raise RuntimeError("Connection Closed")
self.process_response()
def __write_queue(self):
"""Private method for writing a queue of data with a simulated 'latency' to stop the messages being joined"""
# Some SMTP response require the server to send multiple responses per request
while len(self.__queued_messages) > 0:
# Sleep to simulate some kind of latency to stop the queue to hopefully stop
# the response from being added to the previous.
sleep(1)
try:
self.process_silent_command(self.__queued_messages.pop(0))
response = self.__buffer_out.get_nowait()
except Exception:
response = None
if response:
# This would sometimes interrupt the users input because a queued write would happen during it
# print(f"Sending Queued response to Client: {self.__address}")
try:
self.__do_write(response)
except BlockingIOError as ex:
print(f"IO Error: {repr(ex)}")
pass
def __write(self):
"""Private method for doing a single write to the socket"""
try:
response = self.__buffer_out.get_nowait()
except Exception:
response = None
if response:
print(f"Sending data: {repr(response)} to {self.__address}")
try:
self.__do_write(response)
except BlockingIOError as ex:
print(f"Could not send data: {repr(ex)}")
pass
def __do_write(self, response: bytes) -> None:
"""Shared Private method to do the actual write to the socket"""
# Check if we have shook hands
if self.__hand_shook:
# Get the max chunk size for the key we generated in case we need to split the request down
chunk_size = self.__crypto_manager_transmit.get_usable_byte_length()
response_chunks = [self.__crypto_manager_transmit.encrypt_string(response[i:i + chunk_size])
for i in range(0, len(response), chunk_size)]
full_response = b"".join(response_chunks)
self.__socket.send(full_response)
else:
self.__socket.send(response)
# Set the heartbeat timer since we just sent a command and wont need to send a noop again
self.__heartbeat_timer = get_unix_timestamp()
def process_command(self, command: str) -> None:
"""Method to Process the command and add it into the buffer, we also set the client to read mode"""
self.current_status = 0
self.__buffer_out.put(command.encode())
def process_silent_command(self, command: str) -> None:
"""Method to silently add a command to the buffer, this does not cause the client to enter read mode"""
self.__buffer_out.put(command.encode())
def queue_command(self, command: str) -> None:
"""Method to add a command to the queued message array for queued writing"""
self.__queued_messages.append(command)
def process_response(self):
"""Method to process the response from the server and to set the client to the required state"""
current_data = self.__buffer_in.get()
# Split the response string by the first space, this should give us a response code and response data
response_parts = current_data.split(" ", 1)
if len(response_parts) >= 2:
response_code = int(response_parts[0])
# If the response is not from the heartbeat print it
if response_code != 295:
print(f"Response: {repr(current_data)}")
if len(response_parts) > 1:
response_data = response_parts[1]
else:
response_data = None
# Check the current state and respond accordingly
if self.state == "INIT":
if response_code == 220:
self.state = "HELO"
# Initialised, now send the HELO command
self.process_command("HELO " + self.__address[0])
self.current_status = 0
self.initialised = True
else:
print("No response from the server...")
self.__close()
elif self.state == "HELO":
if response_code == 250:
# HELO responded, send the AUTH command
self.state = "AUTH"
self.current_status = 0
elif self.state == "AUTH":
if not self.__hand_shook:
if response_code == 570:
# set up the RSA encryption variables for encrypting the connection
self.__crypto_manager_transmit = Crypto.Transmitter(response_data)
self.__crypto_manager_receive = Crypto.Receiver()
self.__crypto_manager_receive.generate_keys()
# Send our public key back to the server
self.process_command("AUTH " + self.__crypto_manager_receive.get_public_key_pair())
# Set the client to a pending handshake
self.__pending_handshake = True
else:
if response_code == 250:
# Auth key was sent successfully, start the confirmation handshake
self.process_command("HSHK " + response_data)
self.state = "HSHK"
elif self.state == "HSHK":
if response_code == 250:
# The validation handshake was current, start the login state and set the write
self.state = "LOGI"
self.current_status = -1
elif self.state == "LOGI":
if response_code == 250:
# Login was successful, wait for the secondary role response so we can tell
# if the user is and ADMIN or USER
self.current_status = 0
elif response_code == 265:
self.__session_ref = response_data
self.current_status = 0
# Send the VRFY command to the get logged in users name and email address
self.state = "VRFY"
self.process_command("VRFY " + self.login_username)
elif response_code == 421:
pass
else:
# Bad login, try again
self.last_response_content.clear()
self.last_response_content.append(response_data)
self.current_status = -1
elif self.state == "VRFY":
if response_code == 261:
self.__user_role = response_data
elif response_code == 250:
# Process the email address that gets sent back with the VRFY response
if '@' in response_data:
if '<' in response_data:
start_bracket_index = response_data.find('<')
end_bracket_index = response_data.rfind('>')
self.__user_full_name = response_data[0:start_bracket_index-1].strip()
self.__user_email = response_data[start_bracket_index+1:end_bracket_index].strip()
else:
#
self.__user_full_name = "N/A"
self.__user_email = response_data.strip()
# Send the user to the Home menu (HMEN) as they are not logged in
self.state = "HMEN"
self.current_status = -1
else:
# VRFY failed and we send the user back to the login
self.reset_session()
self.state = "LOGI"
self.current_status = -1
elif response_code == 500:
# VRFY failed and we send the user back to the login
self.reset_session()
self.state = "LOGI"
self.current_status = -1
elif self.state == "MAIL":
# Start an email input, we start by asking for the receipt addresses (RCPT)
if response_code == 250:
self.state = "RCPT"
self.current_status = -1
else:
self.state = "HMEN"
self.current_status = -1
elif self.state == "RCPT":
self.last_response_content.clear()
if response_code == 250:
# The address was local and added
self.last_response_content.append("Address Added.")
elif response_code == 251:
# The address was valid but not local and added
self.last_response_content.append("Address Added, but not local.")
elif response_code == 503:
# The server said we are out of sequence, reset the transaction and start over
self.last_response_content.append("There was an issue with the order of commands sent, RSET sent.")
self.state = "RSET"
self.process_command("RSET")
else:
# The address was rejected, we remove it from the clients end
last_address = self.client_mail_temp.to_address_list.pop(-1)
self.last_response_content.append(f"Address {last_address} not added for reason: " + response_data)
if self.state != "RSET":
self.state = "RCPT"
self.current_status = -1
elif self.state == "SUBJ":
# The receipts were fine and we move onto the subject, subject does not have an official "state"
# we process it fully from the client side and send it along with all of the starting DATA requests
if response_code == 354:
string_date = datetime.now()
date_formatted = string_date.strftime("%d %b %y %H:%M:%S")
# send the email headers that get used when "forwarding" to another server, for completeness
# DATA starts with 4 lines, Date, From, Subject and To as a comma seperated list
self.queue_command("Date: " + date_formatted)
self.queue_command("From: " + self.__user_full_name + " <" + self.__user_email + ">")
self.queue_command("Subject: " + self.client_mail_temp.subject)
self.queue_command("To: " + ", ".join(self.client_mail_temp.to_address_list))
# Set the state to DATA and start accepting lines from the client until the >SEND command is used
self.state = "DATA"
self.current_status = -1
else:
# The server said we are out of sequence, reset the transaction and start over
self.last_response_content.append("There was an issue with the order of commands sent, RSET sent.")
self.state = "RSET"
self.process_command("RSET")
elif self.state == "DATA":
if response_code == 250:
# The data was sent and the server responded with an OK response, return to the home menu
self.last_response_content.clear()
self.last_response_content.append("Email sent.")
self.client_mail_temp = None
self.state = "HMEN"
self.current_status = -1
elif self.state == "RSET":
if response_code == 250:
# The server responded OK to our request to reset the current transaction
self.client_mail_temp = None
self.state = "HMEN"
self.current_status = -1
elif self.state == "VIEW":
if response_code == 211:
# Server responded to the VIEW command
self.current_status = 0
self.last_response_content.clear()
elif response_code == 214:
# Process the VIEW response until we get the end of DATA string
if response_data == "\n.\n":
self.current_status = -1
else:
self.last_response_content.append(response_data)
elif response_code == 504:
# There was an error getting a mail, usually if they try to enter
# a number that doesnt belong to them
self.last_response_content.clear()
self.last_response_content.append("There was an issue loading this mail")
self.current_status = -1
else:
# There was an unspecified error so we send the user back to the home menu
self.last_response_content.clear()
self.state = "HMEN"
self.current_status = -1
elif self.state == "ADMN":
if response_code == 250:
# Server has left ADMIN mode
if response_data == "OK QUIT":
self.last_response_content.clear()
self.state = "HMEN"
self.current_status = -1
# Server has processed the command given
elif response_data == "OK DONE":
self.last_response_content.clear()
self.last_response_content.append("Action Completed.")
self.current_status = -1
else:
self.last_response_content.clear()
self.current_status = -1
# Server sent a multipart ADMIN response
elif response_code == 211:
self.current_status = 0
self.last_response_content.clear()
elif response_code == 214:
if response_data == "\n.\n":
self.current_status = -1
else:
self.last_response_content.append(response_data)
# Server sent an ADMIN error response
elif response_code == 500:
self.last_response_content.clear()
self.last_response_content.append("Action Failed.")
self.current_status = -1
# Permission was denied by the server
else:
self.last_response_content.clear()
self.last_response_content.append("Permission Denied.")
self.state = "HMEN"
self.current_status = -1
elif self.state == "LOUT":
# Server logged the client out
if response_code == 250:
# Logged out
self.reset_session()
self.state = "LOGI"
else:
# Couldn't log out?
self.state = "HMEN"
self.current_status = -1
elif self.state == "HELP":
# Server responded to the HELP command and is sending back the multipart response
if response_code == 211:
self.current_status = 0
self.last_response_content.clear()
elif response_code == 214:
if response_data == "\n.\n":
self.current_status = -1
else:
self.last_response_content.append(response_data)
# Server responded with a command not implemented error becuase the help section does not exist
elif response_code == 504:
self.last_response_content.clear()
self.last_response_content.append("There was no help on file for your request.")
self.current_status = -1
# Unspecified error from the server, just sending the user to the home menu
else:
self.last_response_content.clear()
self.state = "HMEN"
self.current_status = -1
# Status 421 and 221 is only used when the connection has been terminated and we just exit at that point
if response_code == 421:
print("The connection was closed by the server with the following message:")
for line in self.last_response_content:
print(line)
else:
print(f"Reason: {response_data}")
self.__close()
self.current_status = -2
if response_code == 221:
self.state = "QUIT"
self.__close()
self.current_status = -2
else:
# The response did not have a valid response code
print("Response too short.")
def __close(self):
"""Private method to close the connection to the server and end the thread"""
print("Attempting to end the Connection...")
try:
self.__selector.unregister(self.__socket)
self.__socket.close()
print(f"Connection ended with {repr(self.__address)}")
except OSError as ex:
print(f"Could not close the connection: {repr(ex)}")
pass
finally:
self.__socket = None
self.active = False
self.current_status = -1
def get_session_ref(self) -> str:
"""Method to return the current session reference from the server"""
return self.__session_ref
def get_user_name(self) -> str:
"""Method to return the current users full name"""
return self.__user_full_name
def get_user_email(self) -> str:
"""Method to return the current users email address"""
return self.__user_email
def reset_session(self) -> None:
"""Method to restart a clients session without closing the connection, used for the logout command"""
self.__session_ref = None
self.__user_email = None
self.__user_full_name = None
self.__user_role = None
self.login_username = None

284
Client/Crypto.py Normal file
View File

@ -0,0 +1,284 @@
# System Imports
import math
import random
# Project Imports
import SHA256Custom
class Receiver:
"""Class for dealing with the receiving end of the custom simple RSA encryption"""
def __init__(self):
# A list of small prime numbers we can use to calculate a larger one
self.__small_primes_list = [211, 223, 227, 229, 233, 239, 241, 251, 257, 263, 269, 271, 277, 281, 283, 293,
307, 311, 313, 317, 331, 337, 347, 349]
self.__p = None
self.__q = None
self.__n = None
self.__t = None
# This is apparently the most commonly used shared co-prime used for RSA
self.__e = 65537
self.__d = None
# The generated public and private keys
self.__public_key = None
self.__private_key = None
self.generated = False
# The bit size of the key
self.__bit_size = 1024
def generate_keys(self) -> bool:
"""Method to generate the RSA Public and Private keys for encryption"""
try:
# Only generate keys if we haven't already
if not self.generated:
self.__p = self.__generate_prime_number()
self.__q = self.__generate_prime_number()
# Make sure we have 2 different prime numbers
while self.__q == self.__p:
self.__q = self.__generate_prime_number()
# Most of the following is just the pseudo code for RSA prime generation
self.__n = self.__p * self.__q
self.__t = (self.__p - 1) * (self.__q - 1)
while math.gcd(self.__t, self.__e) > 1:
self.__e += 2
if self.__e < 1 or self.__e > self.__t:
raise Exception("E must be > 1 and < T")
if self.__t % self.__e == 0:
raise Exception("E is not a co-prime with T")
self.__d = Receiver.find_inverse(self.__e, self.__t)
# We have our keys, set up the tuples and flag the generated as true
self.__public_key = (self.__e, self.__n)
self.__private_key = (self.__d, self.__n)
self.generated = True
return True
else:
raise Exception("Keys have already been generated.")
except Exception:
return False
def get_expected_block_size(self) -> int:
"""Method to return the expected block size for the encrypted data"""
# the encrypted request blocksize seems to be half of the bit size
return int(self.__bit_size / 2)
def get_public_key_pair(self, hex_result=True):
"""Method for returning the public key as a hex string or tuple depending on the hex_results"""
if hex_result:
# Return both public key elements as a hex string
return "{0:x} {1:x}".format(self.__public_key[0], self.__public_key[1])
else:
return self.__public_key
def get_private_key_pair(self, hex_result=True):
"""Method for returning the private key as a hex string or tuple, not really used"""
if hex_result:
# Return both private key elements as a hex string
return "{0:x} {1:x}".format(self.__private_key[0], self.__private_key[1])
else:
return self.__private_key
def decrypt_string(self, string_in: str) -> str:
"""Method for decrypting our custom RSA encrypted blocks, takes the hex string in"""
if self.generated:
# Convert the hex string back into an int
encrypted_string_as_int = int(string_in, 16)
# Do the math on our encrypted int
decrypted_string_as_int = pow(encrypted_string_as_int, self.__d, self.__n)
# Convert the result back into a hex string
decrypted_string_as_hex = "{0:x}".format(decrypted_string_as_int)
start_padding_included = False
plain_text_read = False
plain_text_string = ''
hashed_checksum = ''
# Loop for every 2 characters (hex)
for i in range(0, len(decrypted_string_as_hex), 2):
current_byte = decrypted_string_as_hex[i:i+2]
# the encrypted string should start with a padding FF value
if not start_padding_included and current_byte.upper() == "FF":
start_padding_included = True
# the Checksum hash should start after the 00 value
elif not plain_text_read and start_padding_included and current_byte == "00":
plain_text_read = True
# Everything else should be the message or the checksum hash
elif start_padding_included:
if not plain_text_read:
# The message is padded after the fact to fill up the bit size, these are ignored
if current_byte.upper() != "FF":
# Convert the hex value back into a utf character
plain_text_string += chr(int(current_byte, 16))
else:
# Reconstruct the hash checksum
hashed_checksum += current_byte
# If the text of hash is missing then something went wrong with encrypting or sending
if len(plain_text_string) == 0 or len(hashed_checksum) == 0:
raise Exception("No text was decrypted, something must be wrong...")
# Hash the plain text string and make sure it matches our checksum hash
hash_test = SHA256Custom.SHA256Custom()
hash_test.update(plain_text_string.encode())
hashed_plain_text = hash_test.hexdigest()
if hashed_plain_text == hashed_checksum:
# The message has not been altered, decrypted successfully
return plain_text_string
else:
raise Exception("Plain text did not match the Hashed Checksum, something is wrong.")
else:
raise Exception("No key has been generated")
def __generate_prime_number(self) -> int:
"""Private method for generating prime numbers with the bit size set"""
while True:
current_prime_candidate = self.__generate_low_level_prime(self.__bit_size)
if not self.miller_rabin_prime_check(current_prime_candidate):
continue
else:
return current_prime_candidate
def __generate_low_level_prime(self, prime_candidate: int) -> int:
"""Private method for generating the prime number using the candidate number and the small prime array"""
while True:
current_prime_candidate = self.generate_random_large_int(prime_candidate)
# We try to generate a large prime number using the candidate and looping through the small primes
# array until we get one
for divisor in self.__small_primes_list:
if current_prime_candidate % divisor == 0 and divisor ** 2 <= current_prime_candidate:
break
else:
return current_prime_candidate
@staticmethod
def generate_random_large_int(n_bit_size: int) -> int:
"""Static method to generate a large int value"""
# Get a random number in a range of the bit size given
return random.randrange(2 ** (n_bit_size - 1) + 1, 2 ** n_bit_size - 1)
# Method to test that the prime number we generate passes the miller rabin prime candidate test
@staticmethod
def miller_rabin_prime_check(prime_candidate: int) -> bool:
"""Static method for the miller rabin prime number checking algorithm, to ensure that we have a valid prime"""
max_divisions_by_two = 0
prime_minus_one = prime_candidate - 1
while prime_minus_one % 2 == 0:
prime_minus_one >>= 1
max_divisions_by_two += 1
assert 2 ** max_divisions_by_two * prime_minus_one == prime_candidate - 1
def trial_composite(round_tester_in):
"""Sub method for doing the actual prime number factorisation check"""
if pow(round_tester_in, prime_minus_one, prime_candidate) == 1:
return False
for x in range(max_divisions_by_two):
if pow(round_tester_in, 2 ** x * prime_minus_one, prime_candidate) == prime_candidate - 1:
return False
return True
for i in range(0, 20):
round_tester = random.randrange(2, prime_candidate)
if trial_composite(round_tester):
return False
return True
@staticmethod
def extended_euclidean_algorithm(a: int, b: int) -> tuple:
"""Static method for the EEA from the pseudo code"""
if b == 0:
return 1, 0
q = a // b
r = a % b
s, t = Receiver.extended_euclidean_algorithm(b, r)
return t, s-(q*t)
@staticmethod
def find_inverse(a: int, b: int) -> int:
"""Static method for finding the inverse of the primes given"""
inverse = Receiver.extended_euclidean_algorithm(a, b)[0]
if inverse < 1:
inverse += b
return inverse
class Transmitter:
"""Class for dealing with the transmitting end of the custom simple RSA encryption"""
def __init__(self, public_key: str):
# We should be getting the string hex values from the receiver and splitting them into the actual parts
public_key_parts = public_key.split(" ", 1)
# Must match the Receiver bit size
self.__bit_size = 1024
# Check if the public key has 2 parts to be valid
if len(public_key_parts) == 2:
self.__e = int(public_key_parts[0], 16)
self.__n = int(public_key_parts[1], 16)
self.__has_key = True
else:
self.__has_key = False
raise Exception("Public key was incorrect")
def get_current_key_pair(self):
"""Method for getting the key pair values e and n"""
return self.__e, self.__n
def get_usable_byte_length(self) -> int:
"""Method for getting the usable byte length incase we need to split a request into multiple parts"""
# Im not entirely sure on this as it was calculated mostly by "brute force" but it seems to be the
# key size divided by 4
# minus 2 bytes for the start and hash break bytes added
# minus 32 for the SHA256 hash size
# minus 32 for the RSA byte overhead, i've tried different values but getting too close to 256 bytes will
# sometimes case issues which i think is down to
return int(self.__bit_size / 4) - 2 - 32 - 32
def encrypt_string(self, string_in: bytes) -> bytes:
"""Method for encrypting a string using the public RSA key provided by the receiver"""
if self.__has_key:
# Create a hash of the message given for our checksum
string_hashing = SHA256Custom.SHA256Custom()
string_hashing.update(string_in)
string_hashed = string_hashing.hexdigest()
# Pad to make the string the full size of the custom RSA block
padding = ""
if len(string_in) < self.get_usable_byte_length():
padding_required = self.get_usable_byte_length() - len(string_in) - 1
padding = "ff" * padding_required
# Add an FF pad at the start of the string, since if the input hex starts with a 0X value the 0 is dropped
# when it is converted to an int, im not entirely sure how they get around this
hex_string = "ff" + string_in.hex() + padding + "00" + string_hashed
string_as_int = int(hex_string, 16)
# Do the math to the int value
encrypted_string_as_int = pow(string_as_int, self.__e, self.__n)
# Convert the int value into a hex string
encrypted_string_hex = "{0:x}".format(encrypted_string_as_int)
return encrypted_string_hex.encode()
else:
raise Exception("No key has been generated")

126
Client/SHA256Custom.py Normal file
View File

@ -0,0 +1,126 @@
class SHA256Custom:
"""
Custom SHA256 function, its not really custom but rewritten using the pseudocode from wikipedia and
should mimic the built in function
"""
def __init__(self, data=None):
# Setting up the initial bit values, i wont pretend to understand the math involved but these seem hardcoded
self.__ks = [0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]
self.__hs = [0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]
# Copy the initial bit values for use since they are changed
self.__h = self.__hs[:]
self.__k = self.__ks[:]
# Create variables we will need in the class
self.__string_byte_len = 0
self.__buffer = b''
self.__string_hashed = False
self.__digested_string = b''
if data is not None:
self.update(data)
# Static methods because pycharm is moaning about them not needing to be inside of the class
@staticmethod
def pad_string(string_len: int) -> bytes:
"""Static method to pad the string digest for the hash"""
# Adding a "1" bit to the end, although it seems to use 63 as the actual value, im guessing its possibly to do
# with chunk size
string_di = string_len & 0x3f
# Get the 64bit big endian length of the post processed hash length
post_process_length = (string_len << 3).to_bytes(8, "big")
padded_length = 55
if string_di < 56:
padded_length -= string_di
else:
padded_length -= 199 - string_di
return b'\x80' + b'\x00' * padded_length + post_process_length
@staticmethod
def rotate_right(a, b):
"""Static method to rotate a and b bits right """
return ((a >> b) | (a << (32 - b))) & 0xffffffff
def update(self, string_in: bytes) -> None:
"""Method to update the hash value"""
if (string_in and len(string_in) > 0) and not self.__string_hashed:
self.__string_byte_len += len(string_in)
string_buffer = self.__buffer + string_in
# Process the string as 512 bit chunks (64 bytes)
for i in range(0, len(string_buffer) // 64):
self.calculate_hash(string_buffer[64 * i:64 * (i + 1)])
# Set the class buffer
self.__buffer = string_buffer[len(string_buffer) - (len(string_buffer) % 64):]
else:
return
def digest(self) -> bytes:
"""Method to get the bytes digest of our hash value"""
if not self.__string_hashed:
self.update(self.pad_string(self.__string_byte_len))
self.__digested_string = b''.join(val.to_bytes(4, 'big') for val in self.__h[:8])
self.__string_hashed = True
return self.__digested_string
def hexdigest(self) -> str:
"""Method to get the hex digest of our hash value"""
# Hex characters to use when changing the hex value back into a string
tab = '0123456789abcdef'
return "".join(tab[byt >> 4] + tab[byt & 0xf] for byt in self.digest())
def calculate_hash(self, chunk) -> None:
"""Method to calculate the actual hash value"""
# Create an array of 32bit words
words = [0] * 64
# Converted from the pseudo code, but i believe its filling the word array with first 16 words we add
words[0:16] = [int.from_bytes(chunk[i:i + 4], "big") for i in range(0, len(chunk), 4)]
for i in range(16, 64):
s0 = self.rotate_right(words[i - 15], 7) ^ self.rotate_right(words[i - 15], 18) ^ (words[i - 15] >> 3)
s1 = self.rotate_right(words[i - 2], 17) ^ self.rotate_right(words[i - 2], 19) ^ (words[i - 2] >> 10)
words[i] = (words[i - 16] + s0 + words[i - 7] + s1) & 0xffffffff
# Initialize the values with the default hash value
a, b, c, d, e, f, g, h = self.__h
# Calculation main loop
for i in range(64):
s0 = self.rotate_right(a, 2) ^ self.rotate_right(a, 13) ^ self.rotate_right(a, 22)
t2 = s0 + self.maj(a, b, c)
s1 = self.rotate_right(e, 6) ^ self.rotate_right(e, 11) ^ self.rotate_right(e, 25)
t1 = h + s1 + self.ch(e, f, g) + self.__k[i] + words[i]
h = g
g = f
f = e
e = (d + t1) & 0xffffffff
d = c
c = b
b = a
a = (t1 + t2) & 0xffffffff
for i, (x, y) in enumerate(zip(self.__h, [a, b, c, d, e, f, g, h])):
self.__h[i] = (x + y) & 0xffffffff
@staticmethod
def maj(a, b, c):
"""Static method for the calculation of the maj variable in the pseudo code"""
return (a & b) ^ (a & c) ^ (b & c)
@staticmethod
def ch(a, b, c):
"""Static method to calculate the ch variable in the pseudo code"""
return (a & b) ^ ((~a) & c)

243
Client/SMTPClient.py Normal file
View File

@ -0,0 +1,243 @@
import socket
import ClientTempMail
from ClientThread import ClientThread
class SMTPClient:
"""Class to hold the methods needed to process the client side of the script"""
def __init__(self, host: str, port: int):
self.__host = host
self.__port = port
self.__client_thread: ClientThread or None = None
def is_active(self) -> bool:
"""Method to check if the child thread is still active"""
return self.__client_thread.active
def is_connected(self) -> bool:
"""Method to check if the child thread is still connected"""
try:
return self.__client_thread.is_connected()
except Exception:
return False
def get_state(self) -> str:
"""Method to return the child threads state"""
return self.__client_thread.state
def get_current_response_content(self) -> any:
"""Method to return the client threads last response array"""
return self.__client_thread.last_response_content
def get_current_login_username(self) -> str:
"""Method to return the current logged in users username"""
return self.__client_thread.login_username
def get_current_users_name(self) -> str:
"""Method to return the current users full name"""
return self.__client_thread.get_user_name()
def get_current_users_email_address(self) -> str:
"""Method to return the current users email address"""
return self.__client_thread.get_user_email()
def get_current_temp_mail(self) -> ClientTempMail.ClientTempMail:
"""Method to return the current temp mail class object from the child thread"""
return self.__client_thread.client_mail_temp
def current_status(self) -> int:
"""Method to return the child threads current status"""
return self.__client_thread.current_status
def get_users_role(self) -> str:
"""Method to return the current users role"""
return self.__client_thread.get_users_role()
def start_connection(self) -> bool:
"""Method to start the clients connection to the server"""
server_address = (self.__host, self.__port)
current_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
current_socket.setblocking(False)
current_socket.connect_ex(server_address)
result = False
try:
# Create the child thread for the connection
self.__client_thread = ClientThread(current_socket, server_address)
self.__client_thread.start()
self.__client_thread.current_status = 0
result = True
except OSError as ex:
print(f"Error connecting to the server: {repr(ex)}")
return result
def close_connection(self) -> None:
"""Method to close the client connection"""
print("Attempting to end the Connection...")
try:
self.__client_thread.close_connection()
print(f"Connection ended with {self.__host}:{self.__port}")
except OSError as ex:
print(f"Could not close the connection: {repr(ex)}")
finally:
self.__client_thread = None
@staticmethod
def email_string_sanitize(input_str: str):
"""Static method to sanitize an email address"""
# Allowable Characters from !#$%&'*+-/=?^_`{|}~@.
special_chars = [33, 35, 36, 37, 38, 39, 42, 43, 45, 46, 47, 61, 63, 64, 94, 95, 96, 123, 124, 125, 126]
output_str = ""
for letter in input_str:
if (letter in special_chars) or (48 <= ord(letter) < 58) \
or (65 <= ord(letter) < 91) or (97 <= ord(letter) < 122):
output_str += letter
return output_str
def run_command(self, command: str, options=None) -> bool:
"""Method that processes the commands as they're sent"""
valid_command = False
if command is not None:
valid_command = True
full_command = None
# Login command processing
if command == "LOGI":
if options['username'] is not None:
# Clean the username to only accept email safe characters
cleaned_username = self.email_string_sanitize(options['username'])
else:
cleaned_username = ""
valid_command = False
password = options['password']
# Check if the username and password have been set correctly
if len(cleaned_username) > 0 and len(password) > 0:
self.__client_thread.login_username = cleaned_username
full_command = "LOGI " + cleaned_username + " " + password
else:
valid_command = False
# Start the mail command
elif command == "MAIL":
self.__client_thread.state = "MAIL"
# Create a mail object to hold the temp values we send to the server
self.__client_thread.client_mail_temp = ClientTempMail.ClientTempMail()
self.__client_thread.client_mail_temp.from_address = self.__client_thread.get_user_email
# Send the initial MAIL FROM command
full_command = "MAIL FROM <" + self.__client_thread.get_user_email() + ">"
elif command == "RCPT":
# Strip any whitespaces to stop it bypassing the len check
to_address = options['to_address'].strip()
if len(to_address) > 0:
# Check if the address is already in the list
if to_address not in self.__client_thread.client_mail_temp.to_address_list:
# Add the to address to the list and try to process it on the server
self.__client_thread.client_mail_temp.to_address_list.append(to_address)
full_command = "RCPT TO <" + to_address + ">"
else:
return False
else:
return False
elif command == "SUBJ":
# Check if the user has entered at least 1 address
if len(self.__client_thread.client_mail_temp.to_address_list) > 0:
self.__client_thread.state = "SUBJ"
return True
else:
return False
elif command == "DATA":
# Set the Subject locally because SUBJ is not an actual command in the mail sequence
self.__client_thread.client_mail_temp.subject = options['subject']
# Start the local data input
full_command = "DATA"
elif command == "LINE":
# Send the current data line to the server to append to the body
self.__client_thread.queue_command(options['data_line'])
return True
elif command == "SEND":
# Finish composing the email and send the end line
self.__client_thread.current_status = 0
self.__client_thread.process_command(options['data_line'])
return True
# Send the reset command
elif command == "RSET":
self.__client_thread.current_status = 0
self.__client_thread.state = "RSET"
full_command = "RSET"
# Send the view request
elif command == "VIEW":
if options and options['mail_id']:
# Client is trying to quit the view loop
if options['mail_id'].upper() == ">QUIT":
full_command = "VIEW QUIT"
# Client is trying to view the entire mailbox lis
elif options['mail_id'].upper() == ">LIST":
full_command = "VIEW LIST"
else:
# Client is trying to view a specific mail
full_command = "VIEW MAIL " + options['mail_id']
else:
# Do the original view list when the view option has been chosen
self.__client_thread.state = "VIEW"
full_command = "VIEW LIST"
# Send the logout command
elif command == "LOUT":
self.__client_thread.state = "LOUT"
full_command = "LOUT"
# send the help command
elif command == "HELP":
self.__client_thread.state = "HELP"
full_command = "HELP"
# Check if the user has entered a help section to search
if options and options['help_section']:
# The client wants to quit
if options['help_section'].upper() == ">QUIT":
full_command += " QUIT"
else:
full_command += " " + options['help_section'].upper()
# Send the ADMN request for admin users
elif command == "ADMN":
self.__client_thread.state = "ADMN"
if options and options['admin_command']:
# Do an unlock command to unlock a locked out user
if options['admin_command'].upper().startswith(">UNLK"):
unlock_command_parts = options['admin_command'].split(" ", 1)
full_command = "ADMN UNLK " + unlock_command_parts[1]
# Do a server log audit view
elif options['admin_command'].upper().startswith(">AUDT"):
full_command = "ADMN AUDT"
# Quit the admin command menu
elif options['admin_command'].upper().startswith(">QUIT"):
full_command = "ADMN QUIT"
else:
# Get the admin home menu
full_command = "ADMN HOME"
# Command does not need special processing
else:
full_command = command
if full_command and valid_command:
self.__client_thread.process_command(full_command)
return True
else:
return False

301
Client/client.py Normal file
View File

@ -0,0 +1,301 @@
import SMTPClient
import socket
import time
default_host = "127.0.0.1"
default_port = 50000
host = default_host
port = default_port
quit_flag = False
# Variable to setting to print the title to sections only once
current_state_initialised = False
# Handy function to draw the title block for each section when needed
def print_title(title: str) -> None:
title_bar_char = '-'
title_bar = "".join(title_bar_char * (len(title) + 2))
print(f"+{title_bar}+\n| {title} |\n+{title_bar}+")
# Handy function for dealing with Quit commands for the script
def do_a_quit(message: str) -> bool:
print(message)
selected_option = input("[Y]es/[N]o: ").strip().upper()
if selected_option == "Y" or selected_option == "YES":
result = False
else:
# quit the program loop
result = True
return result
# start main program loop
if __name__ == "__main__":
while not quit_flag:
# ask user for a hostname and port number, or leave blank to use the default values
print(f"Please enter a valid host address and port: (default: {default_host}:{default_port})")
host_input = input("host:port, blank for default:")
valid_host = True
test_host = ""
test_port = ""
# check if a value has been entered or to use the default values
if len(host_input.strip()) > 0:
# strip any trailing/leading whitespace and split by the colon
host_input = host_input.strip()
host_parts = host_input.split(':', 1)
# check if the host_parts array has 2 values, these should be a host ip and port
if len(host_parts) == 2:
try:
# convert the first item in host_parts into a valid IP address if host name is given
test_host = socket.gethostbyname(host_parts[0])
# check if the second item in host_parts is numeric for the port number
if host_parts[1].isnumeric():
test_port = int(host_parts[1])
else:
valid_host = False
except socket.gaierror:
valid_host = False
else:
valid_host = False
# if no value is given then use the default host and port given at the top
else:
# ensure the default host and port are assigned to the test values since they do not need to be
# checked/sanitized
test_host = default_host
test_port = default_port
# check if the host is still valid
if valid_host:
# set the actual host and port variables
host = test_host
port = test_port
print(f"Connecting to {host}:{port}...")
quit_input_flag = False
# Start the client with the given host and port
smtp_client = SMTPClient.SMTPClient(host, port)
# Attempt to connect 3 times before giving up
for i in range(3):
if smtp_client.start_connection():
break
else:
print("Connection failed, attempting again in 5 seconds")
time.sleep(5)
if smtp_client.is_connected():
# The main client loop
while smtp_client.is_active() and not quit_flag:
# We wait for the current status to be in "write" mode
if smtp_client.current_status() == -1:
# Print the main menu if we are in the HMEN (home menu)
if smtp_client.get_state() == "HMEN":
if not current_state_initialised:
print_title("SMTP server home menu")
print(f"Welcome {smtp_client.get_current_users_name()}.")
print(f"Email: {smtp_client.get_current_users_email_address()}")
print("[1]: [C]ompose an Email")
print("[2]: [V]iew your Mailbox")
print("[3]: [H]elp")
print("[4]: [L]og Out")
print("[5]: [Q]uit")
# Secret admin menu option if you are on an Admin account
if smtp_client.get_users_role() == "ADMIN":
print("[6]: [A]dmin console")
current_state_initialised = True
# If we have any response messages waiting, print them
if len(smtp_client.get_current_response_content()) > 0:
print("\nLast Status Message:")
print("----------------------")
for line in smtp_client.get_current_response_content():
print(line)
current_command = input("Option: ")
if current_command in ["1", "C", "c"]:
current_state_initialised = False
smtp_client.run_command("MAIL")
elif current_command in ["2", "V", "v"]:
current_state_initialised = False
smtp_client.run_command("VIEW")
elif current_command in ["3", "H", "h"]:
current_state_initialised = False
smtp_client.run_command("HELP")
elif current_command in ["4", "L", "l"]:
current_state_initialised = False
smtp_client.run_command("LOUT")
elif current_command in ["5", "Q", "q"]:
current_state_initialised = False
smtp_client.run_command("QUIT")
elif current_command in ["6", "A", "a"] and smtp_client.get_users_role() == "ADMIN":
current_state_initialised = False
smtp_client.run_command("ADMN")
else:
print("Invalid command, try again.")
# The admin menu allows for some specific Admin commands
elif smtp_client.get_state() == "ADMN":
print_title("Secret Admin Menu")
admin_content = smtp_client.get_current_response_content()
if len(admin_content) > 0:
print("SERVER RESPONDED: ")
for admin_line in admin_content:
print(admin_line)
print("END\n")
print("Admin Commands:")
print(">UNLK 'username' - Unlock a User account.")
print(">AUDT - View last 20 server actions")
print(">QUIT - Back to the Home menu")
current_command = input("Option: ")
if len(current_command) > 0:
smtp_client.run_command("ADMN", {"admin_command": current_command})
# Login details entry
elif smtp_client.get_state() == "LOGI":
print_title("Login Details")
print("Please enter your Login details:")
username = input("Username: ")
password = input("Password: ")
login_details = {"username": username, "password": password}
if not smtp_client.run_command("LOGI", login_details):
if do_a_quit("You did not enter a valid username and/or password, try again?"):
# send the quit command to the server
smtp_client.run_command("QUIT")
quit_flag = True
# Recipient email address entry, this continues til an empty value is entered
elif smtp_client.get_state() == "RCPT":
if not current_state_initialised:
print_title("Composing an Email: Recipients")
print("Input \">QUIT\" at any point to quit back to the menu.")
print("Input a recipient Email address and press enter, "
"when you are done press enter again.")
current_state_initialised = True
current_recp_address = input("TO: ")
if len(current_recp_address) > 0:
# Quit the compose email loop and send the RSET command
if current_recp_address.upper() == ">QUIT":
current_state_initialised = False
smtp_client.run_command("RSET")
else:
if not smtp_client.run_command("RCPT", {'to_address': current_recp_address}):
print("You must enter a unique recipient address.")
else:
# Continue to the Subject entry state if blank
if smtp_client.run_command("SUBJ"):
current_state_initialised = False
else:
# If a false was returned then they did not enter at least 1 address
print("You must have at least 1 recipient")
# Subject entery state, its not an official state since there is no real "SUBJ" command
elif smtp_client.get_state() == "SUBJ":
if not current_state_initialised:
print_title("Composing an Email: Subject")
print("Input \">QUIT\" at any point to quit back to the menu.")
print("Enter a Subject for this Email")
current_state_initialised = True
current_subject = input("SUBJECT: ")
# Quit the compose email loop and send the RSET command
if current_subject.upper() == ">QUIT":
current_state_initialised = False
smtp_client.run_command("RSET")
else:
if len(current_subject) == 0:
current_subject = "No Subject"
current_state_initialised = False
smtp_client.run_command("DATA", {"subject": current_subject})
# Enter the actual email body data entry mode, this will not end until you >QUIT or >SEND
elif smtp_client.get_state() == "DATA":
if not current_state_initialised:
print_title("Composing an Email: Body")
print("Input \">QUIT\" at any point to quit back to the menu.")
print("Input your email text, press enter to start a new line,")
print("Input \">SEND\" when you have finished your Email.")
current_state_initialised = True
current_mail_line = input("DATA: ")
# Quit the compose email loop and send the RSET command
if current_mail_line.upper() == ">QUIT":
current_state_initialised = False
smtp_client.run_command("RSET")
# Sends the \n.\n line to let the server know our data entry has ended
elif current_mail_line.upper() == ">SEND":
current_state_initialised = False
smtp_client.run_command("SEND", {'data_line': "\n.\n"})
else:
# Each line is sent individually when enter is pressed and stored on the server
smtp_client.run_command("LINE", {'data_line': current_mail_line})
# Output the mailbox or actual mail response from the server when in the VIEW state
elif smtp_client.get_state() == "VIEW":
print_title("Viewing Mail")
view_content = smtp_client.get_current_response_content()
for view_line in view_content:
print(view_line)
print("End of request, Enter a mail id, >LIST to view latest emails"
" or >QUIT to return to the menu.")
view_command = input("VIEW ")
smtp_client.run_command("VIEW", {"mail_id": view_command})
# Output the help response when the client is in the HELP state
elif smtp_client.get_state() == "HELP":
print_title("SMTP Server Help")
help_content = smtp_client.get_current_response_content()
for help_line in help_content:
print(help_line)
print("End of file, Enter a help subsection or >QUIT to return to the menu.")
help_command = input("HELP ")
smtp_client.run_command("HELP", {"help_section": help_command})
# QUIT should have already quit but if not we just pass
elif smtp_client.get_state() == "QUIT":
pass
# Catch all for any commands, if we end here we've done something wrong
# Kept here to help debug if we do actually run into an issue
else:
current_command = input(f"{smtp_client.get_state()}: ")
smtp_client.run_command(current_command)
# If the client loses its active flag then the connection is no longer connected
print("Connection no longer active, exiting")
quit_flag = True
else:
if not do_a_quit("The connection could not be made, would you like to try another host?"):
# reset the host and port values to the defaults
host = default_host
port = default_port
give_up_flag = ""
else:
# quit the program loop
quit_flag = True
# the host given was invalid, ask if the user would like to try again
else:
if not do_a_quit("You did not enter a valid host, try again?"):
# reset the host and port values to the defaults
host = default_host
port = default_port
give_up_flag = ""
else:
# quit the program loop
quit_flag = True
# exit the program and say goodbye
print("Program ending, thank you")