From b31d19e336ddee9e792a8aa574113a983f85f472 Mon Sep 17 00:00:00 2001 From: iDunnoDev Date: Sat, 5 Jul 2025 14:24:22 +0100 Subject: [PATCH] Initial Commit --- Client/ClientTempMail.py | 7 + Client/ClientThread.py | 546 ++++++++++++++++++++++++++++ Client/Crypto.py | 284 +++++++++++++++ Client/SHA256Custom.py | 126 +++++++ Client/SMTPClient.py | 243 +++++++++++++ Client/client.py | 301 ++++++++++++++++ Server/Crypto.py | 284 +++++++++++++++ Server/DBConn.py | 188 ++++++++++ Server/Mail.py | 120 +++++++ Server/RawMail.py | 135 +++++++ Server/SHA256Custom.py | 126 +++++++ Server/SMTPServer.py | 71 ++++ Server/ServerLog.py | 126 +++++++ Server/ServerThread.py | 742 +++++++++++++++++++++++++++++++++++++++ Server/Session.py | 101 ++++++ Server/User.py | 208 +++++++++++ Server/dbSetup.py | 217 ++++++++++++ Server/server.db | Bin 0 -> 139264 bytes Server/server.py | 33 ++ 19 files changed, 3858 insertions(+) create mode 100644 Client/ClientTempMail.py create mode 100644 Client/ClientThread.py create mode 100644 Client/Crypto.py create mode 100644 Client/SHA256Custom.py create mode 100644 Client/SMTPClient.py create mode 100644 Client/client.py create mode 100644 Server/Crypto.py create mode 100644 Server/DBConn.py create mode 100644 Server/Mail.py create mode 100644 Server/RawMail.py create mode 100644 Server/SHA256Custom.py create mode 100644 Server/SMTPServer.py create mode 100644 Server/ServerLog.py create mode 100644 Server/ServerThread.py create mode 100644 Server/Session.py create mode 100644 Server/User.py create mode 100644 Server/dbSetup.py create mode 100644 Server/server.db create mode 100644 Server/server.py diff --git a/Client/ClientTempMail.py b/Client/ClientTempMail.py new file mode 100644 index 0000000..9d66856 --- /dev/null +++ b/Client/ClientTempMail.py @@ -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 diff --git a/Client/ClientThread.py b/Client/ClientThread.py new file mode 100644 index 0000000..b0ad990 --- /dev/null +++ b/Client/ClientThread.py @@ -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 diff --git a/Client/Crypto.py b/Client/Crypto.py new file mode 100644 index 0000000..17e321c --- /dev/null +++ b/Client/Crypto.py @@ -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") diff --git a/Client/SHA256Custom.py b/Client/SHA256Custom.py new file mode 100644 index 0000000..ce9adf7 --- /dev/null +++ b/Client/SHA256Custom.py @@ -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) diff --git a/Client/SMTPClient.py b/Client/SMTPClient.py new file mode 100644 index 0000000..f399f75 --- /dev/null +++ b/Client/SMTPClient.py @@ -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 diff --git a/Client/client.py b/Client/client.py new file mode 100644 index 0000000..1c08b87 --- /dev/null +++ b/Client/client.py @@ -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") diff --git a/Server/Crypto.py b/Server/Crypto.py new file mode 100644 index 0000000..17e321c --- /dev/null +++ b/Server/Crypto.py @@ -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") diff --git a/Server/DBConn.py b/Server/DBConn.py new file mode 100644 index 0000000..82346cb --- /dev/null +++ b/Server/DBConn.py @@ -0,0 +1,188 @@ +import sqlite3 +import time +import os + + +# added pairing type, mostly to stop it complaining about line length, only works on 3.9 +# ParamPair = list[dict[str, any]] + + +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 DBConn: + """Class to hold our methods for dealing with the SQLite3 Database""" + # start the database, which we would usually have a config file with the host, port, username etc + # but since sqlite just uses a file i've hard coded it + def __init__(self): + # Apparently outside of the pycharm env we need to tell it what DIR the DB is in, it should be in the server + # folder + path = os.path.dirname(os.path.abspath(__file__)) + db = os.path.join(path, 'server.db') + + self.__db_conn = sqlite3.connect(db) + # set the row factory, to simulate the fetch_assoc rows other DB's use + self.__db_conn.row_factory = sqlite3.Row + self.__db_cursor = self.__db_conn.cursor() + + def do_generic(self, query: str, return_values=False) -> any: + """method for doing a generic SQL query, optionally return the result""" + result = False + if len(query) > 0: + try: + current_query = self.__db_cursor.execute(query) + if return_values: + result = current_query.fetchall() + else: + result = True + except Exception as ex: + print(f"Generic query Error: {repr(ex)}") + + return result + + def do_insert(self, table: str, insert_pairs, return_id=False): + """Method for doing a generic insert query given the value pairs and table, we can return the insert id too""" + result = False + # Check if the table and insert values are not empty + if len(table) > 0 and len(insert_pairs) > 0: + fields = [] + params = [] + parsed_values = {} + # Using prepared statements so we dont get SQL injected + for pair in insert_pairs: + fields.append(pair["field"]) + params.append(':' + pair["field"]) + parsed_values[pair["field"]] = pair["value"] + + # Construct the insert query with the input values + query_fields = ','.join(fields) + query_values = ','.join(params) + insert_query = "INSERT INTO {} ({}) VALUES ({})".format(table, query_fields, query_values) + + try: + if self.__db_cursor.execute(insert_query, parsed_values): + self.__db_conn.commit() + if return_id: + # Return the last row id if requested, otherwise return True + result = self.__db_cursor.lastrowid + else: + result = True + except Exception as ex: + print(f"Insert query Error: {repr(ex)}") + + return result + + def do_update(self, table: str, update_pairs, where: str, params) -> bool: + """Method for doing a generic update query""" + result = False + if len(table) > 0 and len(update_pairs) > 0: + field_params = [] + parsed_values = {} + # Setup the Update field/value pairs + for pair in update_pairs: + field_params.append("{} = {}".format(pair["field"], ':' + pair["field"])) + parsed_values[pair["field"]] = pair["value"] + + # Add the values to the parameters + for param in params: + parsed_values[param["field"]] = param["value"] + + # Construct the update query + query_fields = ','.join(field_params) + insert_query = "UPDATE {} SET {} WHERE {}".format(table, query_fields, where) + + try: + if self.__db_cursor.execute(insert_query, parsed_values): + self.__db_conn.commit() + result = True + except Exception as ex: + print(f"Update query Error: {repr(ex)}") + + return result + + def do_select(self, table: str, fetch_type="all", fields="", search="", params=None, query_extra=""): + """Method for doing a generic select query""" + result = None + if len(table) > 0: + query_fields = "*" + query_block = "" + if len(fields) > 0: + # Check if the fields variable is a string or a list array + if isinstance(fields, str): + query_fields = fields + if isinstance(fields, list): + query_fields = ','.join(fields) + + # Check if we have actual search parameters + if len(search) > 0: + query_block = " WHERE " + search + + select_query = "SELECT {} FROM {}{} {}".format(query_fields, table, query_block, query_extra) + + parsed_params = {} + for param in params: + parsed_params[param["field"]] = param["value"] + + try: + db_query = self.__db_conn.execute(select_query, parsed_params) + # Return All, the Row or Column depending on the fetchtype passed in + if fetch_type == "all": + result = db_query.fetchall() + elif fetch_type == "row": + result = db_query.fetchone() + elif fetch_type == "col": + result = db_query.fetchone()[0] + except Exception as ex: + print(f"Select query Error: {repr(ex)}") + + return result + + def get_system_setting(self, key: str): + """Method for getting a global system setting""" + setting_value = self.do_select("system_settings", "col", "setting_value", "setting_name = :setting_name", + [ + { + "field": "setting_name", + "value": key + } + ]) + + return setting_value + + def set_system_setting(self, key: str, value) -> bool: + """Method for setting a system setting""" + result = False + # Check if the setting exists, i would normally just use a INSERT ON DUPLICATE KEY UPDATE query, but it doesnt + # look like SQLite supports it + value_exists = self.do_select("system_settings", "col", "COUNT(*)", "setting_name = :setting_name", + [ + { + "field": "setting_name", + "value": key + } + ]) + + # If the key exists then update other wise insert it + if value_exists and value_exists > 0: + if self.do_update("system_settings", + [{"field": "setting_value", "value": value}], + "setting_name = :setting_name", + [{"field": "setting_name", "value": key}]): + result = True + else: + if self.do_insert("system_settings", + [{"field": "setting_name", "value": key}, + {"field": "setting_value", "value": value}]): + result = True + + return result + + def __del__(self): + """Close the connection and cursor when the object is removed""" + self.__db_cursor.close() + self.__db_conn.close() diff --git a/Server/Mail.py b/Server/Mail.py new file mode 100644 index 0000000..2f0e2ac --- /dev/null +++ b/Server/Mail.py @@ -0,0 +1,120 @@ +from datetime import datetime + +import RawMail +import User +from DBConn import DBConn, get_unix_timestamp + + +class Mail: + """Class for holding the data in the mail table of the database""" + def __init__(self): + self.__mail_id = None + self.__raw_id = None + self.__from_id = None + self.__to_id = None + self.__mail_subject = None + self.__mail_body = None + self.__mail_read = None + self.__mail_date_sent = None + + # Holding variables for the from, to and raw mail class objects + self.__from_user: User.User or None = None + self.__to_user: User.User or None = None + self.__raw_mail: RawMail.RawMail or None = None + + self.__is_valid = False + + def load_mail(self, mail_id: int): + """Method to load the mail with the id given""" + self.__mail_id = mail_id + self.__load_mail() + + def __load_mail(self) -> None: + """Private method to load the mail from the database using the mail_id""" + db_connection = DBConn() + mail_details = db_connection.do_select("mail_store", "row", "*", "mail_id = :mail_id", + [ + { + "field": "mail_id", + "value": self.__mail_id + } + ]) + + if mail_details is not None and len(mail_details) > 0: + self.__mail_id = mail_details['mail_id'] + self.__raw_id = mail_details['raw_id'] + self.__from_id = mail_details['from_id'] + self.__to_id = mail_details['to_id'] + self.__mail_subject = mail_details['mail_subject'] + self.__mail_body = mail_details['mail_body'] + self.__mail_read = mail_details['mail_read'] + self.__mail_date_sent = mail_details['mail_date_sent'] + self.__is_valid = True + + def mail_is_valid(self): + """Method to check if we have loaded a valid mail from the DB""" + return self.__is_valid + + def get_mail_id(self): + """Method to return the mail id""" + return self.__mail_id + + def get_subject(self, trim_len: int = 0): + """Method to return the subject line, if given a number it will truncate the string to that len""" + if trim_len > 0: + return self.__mail_subject[0:trim_len] + else: + return self.__mail_subject + + def get_date_sent(self, format_string: str = "%d/%m/%y %H:%M"): + """Method to get the sent date with the given date format""" + date_time_string = datetime.utcfromtimestamp(self.__mail_date_sent).strftime(format_string) + return date_time_string + + def get_from_user(self) -> User.User or None: + """Method to load a user object into the from user variable""" + if self.__from_id: + # Preload the user into the object if not loaded beforehand + if not self.__from_user: + self.__from_user = User.User(self.__from_id) + return self.__from_user + else: + return None + + def get_to_user(self) -> User.User or None: + """Method to load a user object into the to user variable""" + if self.__to_id: + # Preload the user into the object if not loaded beforehand + if not self.__to_user: + self.__to_user = User.User(self.__to_id) + return self.__to_user + else: + return None + + def get_raw_mail(self): + """Method to load a raw mail object into the variable""" + if not self.__raw_mail: + self.__raw_mail = RawMail.RawMail() + self.__raw_mail.load_raw_mail(self.__raw_id) + return self.__raw_mail + + def create_mail(self, raw_id: int, from_id: int, to_id: int, subject: str, body: str) -> bool: + """Method to add the mail to the database""" + result = False + if not self.__is_valid: + if raw_id and from_id and to_id and len(subject) > 0 or len(body) > 0: + db_connection = DBConn() + self.__mail_id = db_connection.do_insert("mail_store", + [{"field": "raw_id", "value": raw_id}, + {"field": "from_id", "value": from_id}, + {"field": "to_id", "value": to_id}, + {"field": "mail_subject", "value": subject}, + {"field": "mail_body", "value": body}, + {"field": "mail_read", "value": 0}, + {"field": "mail_date_sent", "value": get_unix_timestamp()}], + True) + if self.__mail_id: + self.__load_mail() + result = True + + return result diff --git a/Server/RawMail.py b/Server/RawMail.py new file mode 100644 index 0000000..0f0ddeb --- /dev/null +++ b/Server/RawMail.py @@ -0,0 +1,135 @@ +import User +from DBConn import DBConn, get_unix_timestamp + + +class RawMail: + """Class for holding the raw mail variables whilst they are being added by a client""" + + def __init__(self): + self.__raw_id = None + self.from_id = None + self.__raw_mail = None + self.__raw_date_sent = None + + # Temp fields for holding the actual mail content, these can be parsed back from the raw_mail variable + self.from_address = None + self.to_address_list = [] + self.subject = None + self.text_body = None + + self.__from_user = None + + self.__is_valid = False + + def get_raw_id(self): + """Method to return the raw id""" + return self.__raw_id + + def get_raw_mail_content(self): + """Method to get the raw mail body contents""" + return self.__raw_mail + + def load_raw_mail(self, raw_id: int): + """Method for loading a raw mail from its id""" + self.__raw_id = raw_id + self.__load_raw_mail() + + def __load_raw_mail(self) -> None: + """Private method to load the raw mail from the database""" + db_connection = DBConn() + raw_mail_details = db_connection.do_select("raw_mail", "row", "*", "raw_id = :raw_id", + [ + { + "field": "raw_id", + "value": self.__raw_id + } + ]) + + if raw_mail_details is not None and len(raw_mail_details) > 0: + self.__raw_id = raw_mail_details['raw_id'] + self.from_id = raw_mail_details['from_id'] + self.__raw_mail = raw_mail_details['raw_mail'] + self.__raw_date_sent = raw_mail_details['raw_date_sent'] + self.__is_valid = True + + def raw_mail_is_valid(self): + """Method to check if a valid raw mail has been loaded from the database""" + return self.__is_valid + + def get_from_user(self) -> User.User or None: + """Method to fill the from user variable with a user object""" + if self.from_id: + # Preload the user into the object if not loaded beforehand + if not self.__from_user: + self.__from_user = User.User(self.from_id) + return self.__from_user + else: + return None + + def strip_headers_from_body(self) -> str: + """Method for stripping the header lines from the mail body for storing in the database mail_store""" + body_lines = self.text_body.split("\n") + body_text = "" + + # There should be 4 "header" lines, but we dont just want to accept all lines that start with a header as + # the user could have entered one into the body of the email. Its not ideal but text parsers rarely are... + header_count = 0 + for line in body_lines: + if (line.startswith("From: ") or line.startswith("Date: ") or line.startswith("To: ")) and header_count < 4: + header_count += 1 + elif line.startswith("Subject: ") and header_count < 4: + header_count += 1 + # Set the subject since that is only set in the data state with the body + if not self.subject: + self.subject = line.replace("Subject: ", "", 1) + else: + # Anything else should be the actual email content + body_text += line + "\n" + + return body_text + + def parse_raw_mail(self): + """Method for parsing a raw mail header lines to extract the from, to, date and subject values""" + email_lines = self.__raw_mail.split("\n") + self.text_body = "" + + # There should be 4 "header" lines, but we dont just want to accept all lines that start with a header as + # the user could have entered one into the body of the email. Its not ideal but text parsers rarely are... + header_count = 0 + for line in email_lines: + if line.startswith("From: ") and header_count < 4: + # From user id is already stored in the actual raw email record + header_count += 1 + pass + elif line.startswith("Date: ") and header_count < 4: + # The date is stored in the actual table so no need to text parse it + header_count += 1 + pass + elif line.startswith("To: ") and header_count < 4: + header_count += 1 + if len(self.to_address_list) == 0: + addresses_only = line.replace("To: ", "", 1) + self.to_address_list = addresses_only.split(",") + elif line.startswith("Subject: ") and header_count < 4: + header_count += 1 + if not self.subject: + self.subject = line.replace("Subject: ", "", 1) + else: + self.text_body += line + "\n" + + def create_raw_mail(self, from_id: int, raw: str) -> bool: + """Method for saving the raw mail into the database""" + result = False + if not self.__is_valid: + if from_id and len(raw) > 0: + db_connection = DBConn() + self.__raw_id = db_connection.do_insert("raw_mail", + [{"field": "from_id", "value": from_id}, + {"field": "raw_mail", "value": raw}, + {"field": "raw_date_sent", "value": get_unix_timestamp()}], + True) + if self.__raw_id: + self.__load_raw_mail() + result = True + + return result diff --git a/Server/SHA256Custom.py b/Server/SHA256Custom.py new file mode 100644 index 0000000..ce9adf7 --- /dev/null +++ b/Server/SHA256Custom.py @@ -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) diff --git a/Server/SMTPServer.py b/Server/SMTPServer.py new file mode 100644 index 0000000..a73d17b --- /dev/null +++ b/Server/SMTPServer.py @@ -0,0 +1,71 @@ +import selectors +import socket +import ServerThread + + +class SMTPServer: + def __init__(self, host: str, port: int): + self.__host = host + self.__port = port + self.__socket = None + self.__selector = selectors.DefaultSelector() + + self.__server_threads = [] + self.active = False + + def start_server(self) -> bool: + server_address = (self.__host, self.__port) + + try: + self.__socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + self.__socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.__socket.bind(server_address) + self.__socket.listen() + self.__socket.setblocking(False) + self.__selector.register(self.__socket, selectors.EVENT_READ, data=None) + self.active = True + + print(f"Server Listening on {self.__host}:{self.__port}") + result = True + except OSError as ex: + self.__socket.close() + self.__socket = None + print(f"Error opening the listener: {repr(ex)}") + result = False + + return result + + def accept_wrapper(self, socket_in: socket): + connection, address = socket_in.accept() + print("Received Connection from", address) + + connection.setblocking(False) + new_server_thread = ServerThread.ServerThread(connection, address) + self.__server_threads.append(new_server_thread) + new_server_thread.start() + + def open_server(self): + try: + while self.active: + events = self.__selector.select(timeout=None) + + for key, mask in events: + if key.data is None: + self.accept_wrapper(key.fileobj) + else: + pass + except KeyboardInterrupt as ex: + print("Keyboard Exception") + for child_thread in self.__server_threads: + child_thread.active = False + self.active = False + finally: + self.__selector.close() + + + + + + + + diff --git a/Server/ServerLog.py b/Server/ServerLog.py new file mode 100644 index 0000000..ed79c02 --- /dev/null +++ b/Server/ServerLog.py @@ -0,0 +1,126 @@ +from datetime import datetime + +import Session +import User +from DBConn import DBConn, get_unix_timestamp + + +class ServerLog: + """Class for holding a server log entry from the database""" + def __init__(self, log_id: int = None): + self.__log_id = log_id + self.__session_ref = None + self.__log_action_dir = None + self.__log_action = None + self.__log_client_ip = None + self.__log_date = None + self.__is_valid = False + + self.__session: Session.Session or None = None + self.__user: User.User or None = None + + # if a log id is provided load the record from the database + if self.__log_id: + self.__load_server_log() + + def __load_server_log(self) -> None: + """Private method for loading a server log from the database with its id""" + db_connection = DBConn() + server_log_details = db_connection.do_select("server_logs", "row", "*", "log_id = :log_id", + [ + { + "field": "log_id", + "value": self.__log_id + } + ]) + if server_log_details is not None and len(server_log_details) > 0: + self.__log_id = server_log_details['log_id'] + self.__session_ref = server_log_details['session_ref'] + self.__log_action_dir = server_log_details['log_action_dir'] + self.__log_action = server_log_details['log_action'] + self.__log_client_ip = server_log_details['log_client_ip'] + self.__log_date = server_log_details['log_date'] + self.__is_valid = True + + def server_log_is_valid(self) -> bool: + """Method to check if a valid server log has been loaded from the database""" + return self.__is_valid + + def get_log_action_dir(self) -> str: + """Method to get the logs direction variable""" + return self.__log_action_dir + + def get_log_action(self) -> str: + """Method to get the log action variable""" + return self.__log_action + + def get_log_action_with_dir(self) -> str: + """Method to return both the log direction and action as a string""" + return f"{self.get_log_action_dir()} {self.get_log_action()}" + + def output_log_line(self, output_format_string: str, date_format_string: str = "%d/%m/%y %H:%M") -> str: + """Method for writing the log as a string in the format given""" + date_time_string = datetime.utcfromtimestamp(self.__log_date).strftime(date_format_string) + + # Check if the log has a user and get the user name, otherwise it was an out of session action + if self.get_user(): + username = self.get_user().username + else: + username = "N/A" + + log_string = output_format_string.format( + date_time_string, + username, + self.__log_client_ip, + self.__log_action_dir, + self.__log_action[0: 40] + ) + + return log_string + + def get_session(self) -> Session.Session or None: + """Method for getting the session object to use in the session variable""" + if self.__session_ref and self.__session_ref != "": + if not self.__session: + self.__session = Session.Session(self.__session_ref, None) + self.__session.load_session() + return self.__session + else: + return None + + def get_user(self) -> User.User or None: + """Method for getting the user object for the user variable""" + if self.__session_ref and self.__session_ref != "": + if self.get_session(): + # Preload the user into the object if not loaded beforehand + if not self.__user: + self.__user = self.__session.get_user() + return self.__user + else: + return None + else: + return None + + def create_server_log(self, session_ref: str, direction: str, action: str or bytes, ip: str) -> bool: + """Method for saving the current server log action to the database""" + result = False + if not self.__is_valid: + if len(direction) > 0 and len(action) > 0 and len(ip) > 0: + db_connection = DBConn() + + # Force any byte strings into normal strings before putting it into the DB + try: + action = action.decode() + except (UnicodeDecodeError, AttributeError): + pass + + if db_connection.do_insert("server_logs", + [{"field": "session_ref", "value": session_ref}, + {"field": "log_action_dir", "value": direction}, + {"field": "log_action", "value": action}, + {"field": "log_client_ip", "value": ip}, + {"field": "log_date", "value": get_unix_timestamp()}]): + self.__load_server_log() + result = True + + return result diff --git a/Server/ServerThread.py b/Server/ServerThread.py new file mode 100644 index 0000000..6c78b69 --- /dev/null +++ b/Server/ServerThread.py @@ -0,0 +1,742 @@ +# System imports +import random +import string +import socket +import selectors +import queue +import uuid +from threading import Thread +from time import sleep + +# My class imports +import Crypto +import Mail +import RawMail +import ServerLog +import Session +import User +from DBConn import DBConn, get_unix_timestamp + + +class ServerThread(Thread): + """Class for holding the server thread specific methods""" + def __init__(self, current_socket: socket, address: tuple): + Thread.__init__(self) + + self.__address = address + self.__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.active = True + self.initialised = False + self.__pending_close = False + self.__main_state = "INIT" + self.__sub_state = "" + + # Holding objects for the current session and raw mail + self.__current_session: Session.Session or None = None + self.__current_mail: RawMail.RawMail or None = None + + # Queue array for queued responses + self.__queued_messages = [] + + # Variables needed for the custom RSA encryption + self.__pending_handshake = False + self.__hand_shook = False + self.__crypt_test_phrase = None + self.__crypto_manager_receive: Crypto.Receiver or None = None + self.__crypto_manager_transmit: Crypto.Transmitter or None = None + + self.__selector.register(self.__socket, selectors.EVENT_READ | selectors.EVENT_WRITE, data=None) + + def run(self): + """Method for doing the threads main loop""" + pending_close_counter = 0 + pending_handshake_counter = 0 + timed_out = False + try: + while self.active: + waiting_commands = self.__selector.select(timeout=None) + 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(): + # Write/Send the initial message + self.__write() + elif mask & selectors.EVENT_WRITE and len(self.__queued_messages) > 0: + # Write/Send any other queued messages for multipart responses + self.__write_queue() + except Exception as ex: + print(ex) + self.__close() + + # If the connection has not been initialised send the initial service ready response + if not self.initialised: + self.__process_smtp_response(220, self.__address[0] + " SMTP service ready") + self.__main_state = "HELO" + self.initialised = True + + # If the user is logged in but the connection has not received a heartbeat or request in the time then + # close te connection and send the timeout response + if self.__current_session and not timed_out: + if self.__current_session.get_session_last_action_timestamp() < get_unix_timestamp(-300): + self.__process_smtp_response(421, "Timeout, closing connection") + timed_out = True + + # Check for a pending handshake, we dont want to flag it straight away as it will start encoding the + # response before we are ready, this will allow the current request to be processed before flipping it + 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 + + # Check if we have a pending close request, this allows us to send the good bye response before rudely + # ending the connection + if self.__pending_close: + if pending_close_counter == 1: + self.active = False + self.__close() + else: + pending_close_counter += 1 + + if not self.__selector.get_map(): + break + + finally: + self.__selector.close() + + def __read(self): + """Private method for reading the response from the client""" + try: + received = self.__socket.recv(4096) + except BlockingIOError as ex: + print(f"IO Error: {repr(ex)}") + else: + if received: + # If we have received a response, and the hand is shook and its not a plain emergency quit response we + # decrypt the data here + if self.__hand_shook and received.decode() not in ["QUIT"]: + received_decoded_string = received.decode() + # The request is split into the chunks needed and decrypted + chunk_size = self.__crypto_manager_receive.get_expected_block_size() + received_chunks = [self.__crypto_manager_receive.decrypt_string( + received_decoded_string[i:i + chunk_size] + ) for i in range(0, len(received_decoded_string), chunk_size)] + # Once we have decrypted all of the request blocks we can join them back into 1 string + full_received = "".join(received_chunks) + print(f"Received request: {repr(full_received)} from Client: {self.__address}") + self.__buffer_in.put(full_received) + + # We log the previous action in the session object and the time of the last action + if self.__current_session: + state_string = self.__main_state + if self.__sub_state: + state_string += "." + self.__sub_state + self.__current_session.update_session(state_string, full_received) + + # and log the action in the server logs + self.__write_log_entry("IN", full_received) + else: + # If we are not hand shook then we treat the request as normal and log the + # action in the server logs + self.__buffer_in.put(received.decode()) + print(f"Received request: {repr(received)} from Client: {self.__address}") + self.__write_log_entry("IN", received) + else: + raise RuntimeError("Connection Read Error, peer disconnected?") + + self.__process_smtp_request() + + def __write_queue(self): + """Private method for processing the write queue""" + # 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) + # Pop each item from the array until we have nothing + item = self.__queued_messages.pop(0) + self.__process_smtp_response(item['code'], item['message']) + self.__write() + + self.__queued_messages.clear() + + def __write(self): + """Private method for doing a normal socket write""" + try: + response = self.__buffer_out.get_nowait() + except Exception: + response = None + + if response: + print(f"Sending Response: {response} to Client: {self.__address}") + try: + self.__do_write(response) + except BlockingIOError as ex: + print(f"IO Error: {repr(ex)}") + pass + + def __do_write(self, response: bytes) -> None: + """Private method shared between the write methods to do the actual socket write and encryption""" + # If the hand is shook then do the encryption + if self.__hand_shook: + # Write the log item before encrypting so we know what it was + self.__write_log_entry("OUT", response) + # Split into the chunk sizes we need/if needed + 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)] + # Join the request back together and send + full_response = b"".join(response_chunks) + self.__socket.send(full_response) + else: + # Otherwise just send as normal and log the server action + self.__socket.send(response) + self.__write_log_entry("OUT", response) + + def __write_log_entry(self, direction: str, action: str or bytes) -> None: + """Private method to write a log to the server logs table""" + # Check if the client is logged in and has an active session to log + if self.__current_session: + session_ref = self.__current_session.session_unique_ref + else: + session_ref = "" + + # Check if its the login request and remove the password since logging that would be pretty silly... + try: + if bytes(action).startswith(b"LOGI"): + action_parts = action.split(" ") + if len(action_parts) == 3: + action = "LOGI " + action_parts[1] + else: + action = "LOGI Empty/Bad Request" + # The auth requests are too long and storing the keys seems dumb so ignore those too + elif bytes(action).startswith(b"AUTH"): + action = "AUTH Auth sending" + elif bytes(action).startswith(b"570"): + action = "570 Auth sending" + # Multipart requests also flood the table as well as self replicate when checking, ignore those as we log + # the initial request anyway + elif bytes(action).startswith(b"214"): + return + + ServerLog.ServerLog().create_server_log(session_ref, direction, action, self.__address[0]) + except Exception: + pass + + def __queue_smtp_response(self, code: int, message: str): + """Private method for queuing an SMTP response to the client""" + self.__queued_messages.append({"code": code, "message": message}) + + def __process_smtp_response(self, code: int, message: str): + """Private method for sending an SMTP response to a client """ + data_out = str(code) + if len(message) > 0: + data_out += " " + message + + encoded_data_out = data_out.encode() + self.__buffer_out.put(encoded_data_out) + + def __process_smtp_request(self): + """Private method for processing a request from the client and sending responses back""" + current_command = self.__buffer_in.get() + + # Split the request as it should be in a format like XXXX YYYYYYYYYYYY, where Y would be any optional data + current_command_parts = current_command.split(" ", 1) + + # Create a connection to the DB + db_connection = DBConn() + + # Check if we are in Data entry mode, otherwise process the command as normal + if current_command_parts[0] not in ["RSET", "NOOP"] \ + and (self.__main_state == "MAIL" and self.__sub_state == "DATA"): + # Check if we receive the end data input line + if current_command != "\n.\n": + self.__current_mail.text_body += current_command + "\n" + else: + # If it has been we process the mail and "send" it to the users + self.__current_mail.create_raw_mail(self.__current_session.get_user().get_user_id(), + self.__current_mail.text_body) + email_body = self.__current_mail.strip_headers_from_body() + + for recipient in self.__current_mail.to_address_list: + # The local recipients are added as their user id when processing the RCPT requests + if isinstance(recipient, int): + temp_email = Mail.Mail() + temp_email.create_mail(self.__current_mail.get_raw_id(), + self.__current_mail.from_id, + recipient, + self.__current_mail.subject, + email_body) + else: + # We dont really care about the non local recipients, + # but they would be forwarded to their server + pass + # Mail has been saved, send the OK and set us back to the main menu state + self.__process_smtp_response(250, "Ok") + self.__main_state = "HMEN" + self.__sub_state = None + + else: + if len(current_command_parts) > 0: + # Check if the user is logged in when required + if current_command_parts[0] not in ["HELO", "NOOP", "AUTH", "HSHK", "LOGI"]: + if self.__current_session is not None: + # User is not valid, boot them from the server + if self.__current_session.get_user() and not self.__current_session.get_user().user_logged_in(): + self.__process_smtp_response(421, "Session invalid, Goodbye") + return False + else: + # Session is invalid boot the client + self.__process_smtp_response(421, "Session invalid, Goodbye") + return False + + try: + # Respond to the HELO command + if current_command_parts[0] == "HELO": + if self.__main_state == "HELO": + self.__process_smtp_response(250, "Greetings from " + self.__address[0]) + # Setup the custom RSA encryption variables and generate the keys to send + self.__crypto_manager_receive = Crypto.Receiver() + self.__crypto_manager_receive.generate_keys() + # Queue the public key response to send after the previous response + self.__queue_smtp_response(570, self.__crypto_manager_receive.get_public_key_pair()) + self.__main_state = "AUTH" + else: + # We arnt expecting a HELO command, most likely because we didnt initialise + self.__process_smtp_response(503, "Bad sequence of commands") + + # Response to the AUTH state requests + elif current_command_parts[0] == "AUTH": + if self.__main_state == "AUTH": + if len(current_command_parts) == 2: + # We should have received the public key from the client, we generate a random string + # to send during the handshake to make sure both public keys are working correctly + self.__crypto_manager_transmit = Crypto.Transmitter(current_command_parts[1]) + self.__crypt_test_phrase = "".join( + random.choices(string.ascii_uppercase + string.digits, k=10)) + self.__queue_smtp_response(250, self.__crypt_test_phrase) + self.__main_state = "HSHK" + self.__hand_shook = True + else: + # If we didnt get a valid request the handshake most likely errored, we may as well + # close the connection + print(f"Bad handshake from {self.__address}") + self.__process_smtp_response(421, "Bad handshake, bye") + self.__pending_close = True + + elif current_command_parts[0] == "HSHK": + if self.__main_state == "HSHK": + if len(current_command_parts) == 2: + # Check if the client returned the correct test phrase back, if so the encryption + # is working both ways and we should be safe to proceed + if current_command_parts[1] == self.__crypt_test_phrase: + self.__queue_smtp_response(250, "OK") + self.__main_state = "LOGI" + else: + # If the phrase is wrong there is obviously an issue with the encryption keys and + # we just close the connection so the user can try again + print(f"Bad handshake response from {self.__address}") + self.__process_smtp_response(421, "Bad handshake, bye") + self.__pending_close = True + else: + # If the response didnt have the right amount of elements then the handshake failed and we + # close the connection so the user can try again + print(f"Bad handshake response from {self.__address}") + self.__process_smtp_response(421, "Bad handshake, bye") + self.__pending_close = True + + # Process the NOOP Keep alive/Heartbeat response request + elif current_command_parts[0] == "NOOP": + self.__process_smtp_response(295, "OK") + + # Process the LOGI login request + elif current_command_parts[0] == "LOGI": + # If the client is already logged in or the server is not expecting the login command + # send the bad sequence response + if self.__main_state != "LOGI" \ + or (self.__current_session and self.__current_session.session_is_valid()): + # if the user is already logged in then they should not be sending the LOGI command + self.__process_smtp_response(503, "Bad sequence of commands") + + elif len(current_command_parts) == 2 and len(current_command_parts[1]) > 0: + # Login parts 0 should be the username and 1 the password + login_parts = current_command_parts[1].split(" ", 1) + # We should have 2 login parts + if len(login_parts) == 2: + user_login = User.User(login_parts[0]) + current_login = user_login.login(login_parts[1], self.__address[0]) + + # The user has successfully logged in + if current_login['status_code'] == 1: + # Generate a UUID to use as the sessions unique ID + session_uuid = str(uuid.uuid4()) + self.__current_session = Session.Session(session_uuid, user_login) + self.__current_session.create_session( + user_login.get_user_id(), + self.__address[0] + ) + self.__process_smtp_response(250, "OK, Welcome " + user_login.username) + # Send the session reference back so the client can use this to verify itself + self.__queue_smtp_response(265, self.__current_session.session_unique_ref) + self.__main_state = "HMEN" + + elif current_login['status_code'] == -1: + # Too many login attempts for this user so the connection is rejected + self.__process_smtp_response(421, "Authentication failed, your connection has been " + "terminated, please wait and try again later") + self.__pending_close = True + + else: + # 535 is the only error code that relates to authentication + self.__process_smtp_response(535, "Authentication failed") + + else: + self.__process_smtp_response(535, "Authentication failed") + + else: + # Syntax error because they did not send the command correctly + self.__process_smtp_response(501, "Syntax Error") + + # Respond to the VRFY request + elif current_command_parts[0] == "VRFY": + if len(current_command_parts) == 2: + # Get the logged in users name and email address + verify_user = User.User(current_command_parts[1]) + if self.__current_session.get_user().get_user_id() == verify_user.get_user_id(): + self.__queue_smtp_response(261, verify_user.role.upper()) + self.__queue_smtp_response(250, verify_user.get_user_fullname() + + " <" + verify_user.email_address + ">") + else: + self.__process_smtp_response(421, "Session invalid, Goodbye") + else: + self.__process_smtp_response(500, "Invalid command given") + + # Respond to the Log out Command + elif current_command_parts[0] == "LOUT": + logged_in_user = self.__current_session.get_user() + if logged_in_user and logged_in_user.user_logged_in(): + self.__current_session = None + self.__process_smtp_response(250, "OK, Goodbye " + logged_in_user.username) + self.__main_state = "LOGI" + self.__sub_state = None + else: + # the user wasn't logged in so we shouldn't be getting this request + self.__process_smtp_response(503, "Bad sequence of commands") + + # Process the MAIL command + elif current_command_parts[0] == "MAIL": + if self.__main_state == "HMEN": + if len(current_command_parts) == 2: + # To start the mail process the client should be sending the FROM address to us + mail_command_parts = current_command_parts[1].split(" ", 1) + if len(mail_command_parts) == 2 and mail_command_parts[0] == "FROM" \ + and len(mail_command_parts[1].strip(" <>")) > 0: + # Create a raw mail for us to add the temp fields in before creating the mail + self.__current_mail = RawMail.RawMail() + self.__current_mail.from_id = self.__current_session.get_user().get_user_id() + self.__current_mail.from_address = mail_command_parts[1].strip(" <>") + self.__main_state = "MAIL" + self.__sub_state = "RCPT" + self.__process_smtp_response(250, "OK") + else: + # Bad Email address sent + self.__process_smtp_response(500, "Invalid command given") + else: + # Bad command sent + self.__process_smtp_response(500, "Invalid command given") + else: + # Tried to mail from the wrong state + self.__process_smtp_response(503, "Bad sequence of commands") + + # Process the RCPT request + elif current_command_parts[0] == "RCPT": + if self.__sub_state == "RCPT": + if len(current_command_parts) == 2: + # Split the command so that we can extract the email address + rcpt_command_parts = current_command_parts[1].split(" ", 1) + if len(rcpt_command_parts) == 2 and rcpt_command_parts[0] == "TO" \ + and len(rcpt_command_parts[1].strip(" <>")) > 0: + email_cleaned = rcpt_command_parts[1].strip(" <>") + email_parts = email_cleaned.split("@", 1) + if len(email_parts) == 2: + # Check if the email address is from the local domain from the site settings + if email_parts[1] == db_connection.get_system_setting("SERVER_DOMAIN"): + local_user = db_connection.do_select("users", "col", "user_id", + "user_email = :user_email", + [ + { + "field": "user_email", + "value": email_cleaned + } + ]) + if local_user and int(local_user) > 0: + # Address is a valid local address + if local_user not in self.__current_mail.to_address_list: + self.__current_mail.to_address_list.append(local_user) + self.__process_smtp_response(250, "Ok") + else: + # Address is already in the list... + self.__process_smtp_response(500, "Invalid command given") + else: + # Address does not exist locally + self.__process_smtp_response(550, "Mailbox unavailable") + else: + # The email uses a non local domain, so we pretend we can forward it + if email_cleaned not in self.__current_mail.to_address_list: + self.__current_mail.to_address_list.append(email_cleaned) + self.__process_smtp_response(251, + "User not local, will attempt to forward") + else: + # Address is already in the list... + self.__process_smtp_response(500, "Invalid command given") + else: + self.__process_smtp_response(500, "Not a valid email address") + else: + self.__process_smtp_response(500, "Invalid command given") + else: + self.__process_smtp_response(503, "Bad sequence of commands") + + # Start the DATA request response + elif current_command_parts[0] == "DATA": + if self.__main_state == "MAIL" and self.__sub_state == "RCPT": + if len(self.__current_mail.to_address_list) > 0: + # Set the server into data entry mode, this will cause it to loop at the start + # of this method instead of match commands until the end characters are sent + self.__process_smtp_response(354, "Start mail input; end with .") + self.__sub_state = "DATA" + self.__current_mail.text_body = "" + else: + self.__process_smtp_response(500, "Invalid command given") + else: + self.__process_smtp_response(503, "Bad sequence of commands") + + # Process the VIEW request + elif current_command_parts[0] == "VIEW": + if len(current_command_parts) == 2: + view_command_parts = current_command_parts[1].split(" ", 1) + # The client wants to quit the VIEW state + if view_command_parts[0] == "QUIT": + self.__process_smtp_response(250, "OK") + self.__main_state = "HMEN" + self.__sub_state = None + # The client wants to view a mail + elif view_command_parts[0] == "MAIL": + self.__main_state = "VIEW" + self.__sub_state = "MAIL" + + user_id = self.__current_session.get_user().get_user_id() + mail_id = view_command_parts[1] + + mail_item = db_connection.do_select("mail_store", "row", "mail_id", + "mail_id = :mail_id AND to_id = :to_id", + [ + { + "field": "mail_id", + "value": mail_id + }, + { + "field": "to_id", + "value": user_id + } + ]) + + # Return the mail if the query returns it, it must be one of their emails and a valid + # mail id + if mail_item: + current_mail = Mail.Mail() + current_mail.load_mail(mail_item['mail_id']) + + mail_body = current_mail.get_raw_mail().get_raw_mail_content() + mail_body_lines = mail_body.split("\n") + + # Send each line of the email then the output ending string + self.__process_smtp_response(211, "OK") + for line in mail_body_lines: + if len(line) > 0: + self.__queue_smtp_response(214, line) + self.__queue_smtp_response(214, "\n.\n") + + else: + self.__process_smtp_response(550, "Could not find the mail id") + + # Client wants to view the mails in their mailbox + elif view_command_parts[0] == "LIST": + self.__main_state = "VIEW" + self.__sub_state = "LIST" + + user_id = self.__current_session.get_user().get_user_id() + + mail_box_content = ["Last 20 mailbox items:", ""] + mail_box_items = db_connection.do_select("mail_store", "all", "mail_id", + "to_id = :to_id", + [ + { + "field": "to_id", + "value": user_id + } + ], + "ORDER BY mail_date_sent DESC LIMIT 0, 20") + for row in mail_box_items: + current_mail = Mail.Mail() + current_mail.load_mail(row['mail_id']) + mail_box_content.append("{:<8} | {:<25} | " + "{:<25} | {:<15}".format(current_mail.get_mail_id(), + current_mail.get_subject(20), + current_mail.get_from_user() + .get_user_fullname(), + current_mail.get_date_sent() + ) + ) + + if len(mail_box_content) == 2: + mail_box_content[1] = "No Items." + else: + mail_box_content[1] = "{:<8} | {:<25} | {:<25} | {:<15}".format("ID", "Subject", + "From", "Date") + + self.__process_smtp_response(211, "OK") + for line in mail_box_content: + self.__queue_smtp_response(214, line) + self.__queue_smtp_response(214, "\n.\n") + + else: + self.__process_smtp_response(500, "Invalid command given") + + else: + self.__process_smtp_response(500, "Invalid command given") + + # Process the RSET command and return to the home menu state + elif current_command_parts[0] == "RSET": + self.__current_mail = None + self.__process_smtp_response(250, "Ok") + self.__main_state = "HMEN" + self.__sub_state = None + + # Process the HELP request + elif current_command_parts[0] == "HELP": + help_content = "" + # If no help "section" is requested get the MAIN help text + if len(current_command_parts) == 1: + help_content = db_connection.get_system_setting("HELPMAIN") + # Set the state to help mode + self.__main_state = "HELP" + elif len(current_command_parts) == 2 and self.__main_state == "HELP": + # The client is trying to quit the help state + if current_command_parts[1] == "QUIT": + self.__process_smtp_response(250, "OK") + self.__main_state = "HMEN" + self.__sub_state = None + else: + help_content = db_connection.get_system_setting("HELP" + current_command_parts[1]) + else: + # If the client is asking for a HELP section and we arnt in the help state they're wrong + if self.__main_state != "HELP": + self.__process_smtp_response(503, "Bad sequence of commands") + else: + self.__process_smtp_response(500, "Invalid command given") + + # If we have help content to actually send back the content + if help_content and len(help_content) > 0: + self.__process_smtp_response(211, "OK") + help_content_lines = help_content.split("\n") + for line in help_content_lines: + self.__queue_smtp_response(214, line) + self.__queue_smtp_response(214, "\n.\n") + else: + if self.__main_state == "HELP": + self.__process_smtp_response(504, "Command parameter is not implemented") + + # ADMIN only commands + elif current_command_parts[0] == "ADMN": + # Check the logged in user is actually an admin + if self.__current_session.get_user().role.upper() == "ADMIN": + if len(current_command_parts) == 2: + if current_command_parts[1] == "HOME": + self.__main_state = "ADMN" + self.__process_smtp_response(250, "OK") + # The client is trying to quit the admin menu + elif current_command_parts[1] == "QUIT": + self.__main_state = "HMEN" + self.__sub_state = None + self.__process_smtp_response(250, "OK QUIT") + # Unlock a user that has been locked out + elif current_command_parts[1].startswith("UNLK"): + unlock_parts = current_command_parts[1].split(" ", 1) + if len(unlock_parts) == 2: + unlock_user = User.User(unlock_parts[1]) + if unlock_user.valid_user(): + unlock_user.unlock() + self.__process_smtp_response(250, "OK DONE") + else: + self.__process_smtp_response(500, "User not found.") + else: + self.__process_smtp_response(500, "Invalid command given") + # Command to return the last 20 items from the server logs audit + elif current_command_parts[1].startswith("AUDT"): + server_log_content = ["Last 20 server actions:", ""] + server_log_items = db_connection.do_select("server_logs", "all", "log_id", "", [], + "ORDER BY log_date DESC " + "LIMIT 0, 20") + for row in server_log_items: + current_log = ServerLog.ServerLog(row['log_id']) + server_log_content.append(current_log.output_log_line("{:<14} | {:<14} " + "| {:<15} | {:<3} " + "| {:<30} ")) + + if len(server_log_content) == 2: + server_log_content[1] = "No Items." + else: + server_log_content[1] = "{:<14} | " \ + "{:<14} | " \ + "{:<15} | " \ + "{:<3} | " \ + "{:<30} ".format("Date", "User", "IP", "Dir", "Action") + + self.__process_smtp_response(211, "OK") + for line in server_log_content: + self.__queue_smtp_response(214, line) + self.__queue_smtp_response(214, "\n.\n") + else: + self.__process_smtp_response(500, "Invalid command given") + else: + self.__process_smtp_response(535, "No access") + + # The client wants to quit, send the goodbye response + elif current_command_parts[0] == "QUIT": + self.__process_smtp_response(221, "Bye") + print(f"Client {repr(self.__address[0])} has quit...") + + else: + self.__process_smtp_response(500, "Invalid command given") + + except Exception: + # Catch all for any errors thrown during this loop + self.__process_smtp_response(500, "Invalid command given") + # print(f"Error: {repr(ex)}") + + else: + self.__process_smtp_response(500, "Invalid command given") + + def __close(self): + """Private method to close the server thread""" + print("Attempting to end the Connection...") + try: + self.__selector.unregister(self.__socket) + self.__socket.close() + print(f"Connection ended with ", self.__address) + except OSError as ex: + print(f"Could not close the connection: {repr(ex)}") + finally: + self.__socket = None + self.active = False + self.current_status = -1 diff --git a/Server/Session.py b/Server/Session.py new file mode 100644 index 0000000..e2b8d4a --- /dev/null +++ b/Server/Session.py @@ -0,0 +1,101 @@ +import User +from DBConn import DBConn, get_unix_timestamp + + +class Session: + """Class to hold a users login session methods and variables""" + def __init__(self, session_ref, user: User.User or None): + self.__session_id = None + self.session_unique_ref = session_ref + self.__user_id = None + self.__session_ip = None + self.__session_data = None + self.__session_state = None + self.__session_last_action_date = None + self.__is_valid = False + + self.__user: User.User or None = user + + def __load_session(self) -> None: + """Private method to load the users session from the database""" + db_connection = DBConn() + session_details = db_connection.do_select("sessions", "row", "*", "session_unique_ref = :session_unique_ref", + [ + { + "field": "session_unique_ref", + "value": self.session_unique_ref + } + ]) + if session_details is not None and len(session_details) > 0: + self.__session_id = session_details['session_id'] + self.session_unique_ref = session_details['session_unique_ref'] + self.__user_id = session_details['user_id'] + self.__session_ip = session_details['session_ip'] + self.__session_data = session_details['session_data'] + self.__session_state = session_details['session_state'] + self.__session_last_action_date = session_details['session_last_action_date'] + self.__is_valid = True + + def session_is_valid(self): + """Method to check if the session has a valid record loaded""" + return self.__is_valid + + def load_session(self) -> None: + """Method to load a session if the unique reference has been set""" + if self.session_unique_ref: + self.__load_session() + + def get_session_last_action_timestamp(self): + """Method to get the sessions last action timestamp""" + return self.__session_last_action_date + + def get_user(self) -> User.User or None: + """Method to get the user object for the variable""" + if self.__user_id: + # Preload the user into the object if not loaded beforehand + if not self.__user: + self.__user = User.User(self.__user_id) + return self.__user + else: + return None + + def update_session(self, current_state: str, last_action: str) -> None: + """Method to update the session with the last action, current state and current timestamp""" + self.__session_data = last_action + self.__session_state = current_state + self.__session_last_action_date = get_unix_timestamp() + self.save_current_changes() + + def save_current_changes(self) -> bool: + """Method to save the changes made to a session to the database""" + result = False + if self.__is_valid: + db_connection = DBConn() + if db_connection.do_update("sessions", + [{"field": "session_data", "value": self.__session_data}, + {"field": "session_state", "value": self.__session_state}, + {"field": "session_last_action_date", + "value": self.__session_last_action_date}], + "session_id = :session_id", + [{"field": "session_id", "value": self.__session_id}]): + result = True + + return result + + def create_session(self, user_id: int, ip: str, data="", state="") -> bool: + """Method to save a new session to the database""" + result = False + if not self.__is_valid: + if user_id and len(ip) > 0: + db_connection = DBConn() + if db_connection.do_insert("sessions", + [{"field": "session_unique_ref", "value": self.session_unique_ref}, + {"field": "user_id", "value": user_id}, + {"field": "session_ip", "value": ip}, + {"field": "session_data", "value": data}, + {"field": "session_state", "value": state}, + {"field": "session_last_action_date", "value": get_unix_timestamp()}]): + self.__load_session() + result = True + + return result diff --git a/Server/User.py b/Server/User.py new file mode 100644 index 0000000..f40bf07 --- /dev/null +++ b/Server/User.py @@ -0,0 +1,208 @@ +import random +import string +import time + +import SHA256Custom +from DBConn import DBConn, get_unix_timestamp + + +class User: + """Class for dealing with a User object from the database""" + def __init__(self, user_id): + # Check if the user id is being added as an int for the user_id or string for the username + if isinstance(user_id, int): + self.username = None + self.__user_id = user_id + elif isinstance(user_id, str): + self.username = user_id + self.__user_id = None + self.first_name = "" + self.last_name = "" + self.email_address = "" + self.login_ip = "" + self.role = "" + self.__is_valid = False + self.__is_logged_in = False + self.login_date = -1 + self.__load_user() + + def user_logged_in(self): + """Method to check if the user is logged in""" + return self.__is_logged_in + + def get_user_id(self): + """Method to return the users id""" + return self.__user_id + + def get_user_fullname(self): + """Method to return the users full name""" + return self.first_name + " " + self.last_name + + def valid_user(self) -> bool: + """Method to check that the loaded user is valid""" + return self.__is_valid + + def __load_user(self) -> None: + """Private method to load the user data from the database""" + db_connection = DBConn() + # Use the username if the user_id was not provided + if not self.__user_id: + user_details = db_connection.do_select("users", "row", "*", "username = :username", + [ + { + "field": "username", + "value": self.username + } + ]) + else: + user_details = db_connection.do_select("users", "row", "*", "user_id = :user_id", + [ + { + "field": "user_id", + "value": self.__user_id + } + ]) + if user_details is not None and len(user_details) > 0: + self.__user_id = user_details['user_id'] + self.username = user_details['username'] + self.first_name = user_details['user_first_name'] + self.last_name = user_details['user_last_name'] + self.email_address = user_details['user_email'] + self.login_ip = "" + self.role = user_details['user_role'] + self.__is_valid = True + self.login_date = int(time.time()) + + def login(self, password, ip_address) -> any: + """Method for attempting to log in the current user with the provided password""" + result = {"is_valid": False, "status_code": 0, "status_msg": ""} + # Check that the user isn't already logged in with this instance + if not self.__is_logged_in: + if len(password) > 0 and len(ip_address) > 0: + # Check that the user doesnt have 5 bad login attempts recently and is not locked out + if self.bad_login_attempt_count() < 5: + db_connection = DBConn() + user_details = db_connection.do_select("users", "row", "user_password, user_salt", + "username = :username", + [ + { + "field": "username", + "value": self.username + } + ]) + if user_details is not None and len(user_details) > 0: + stored_password = user_details['user_password'] + stored_salt = user_details['user_salt'] + + # Hash the input password with the users salt from the database + password_hash = SHA256Custom.SHA256Custom() + password_hash.update((password + stored_salt).encode()) + + login_password = password_hash.hexdigest() + + # If the passwords match then login + if stored_password == login_password: + self.__is_logged_in = True + self.login_date = int(time.time()) + # Log the login attempt for the server records + self.log_login_attempt(1, ip_address) + result = {"is_valid": True, "status_code": 1, "status_msg": "User Logged in."} + else: + # Log the login attempt for the server records + self.log_login_attempt(-1, ip_address) + result['status_msg'] = "Incorrect Password." + else: + result['status_msg'] = "User not found." + else: + result['status_msg'] = "Too many tries, please wait." + result['status_code'] = -1 + else: + result['status_msg'] = "No Password and/or IP address provided." + else: + result['status_msg'] = "User already logged in." + + return result + + def save_current_changes(self) -> bool: + """Method to save any changes made to the user to the database""" + result = False + if self.__is_valid: + db_connection = DBConn() + if db_connection.do_update("users", + [{"field": "username", "value": self.username}, + {"field": "user_first_name", "value": self.first_name}, + {"field": "user_last_name", "value": self.last_name}, + {"field": "user_email", "value": self.email_address}, + {"field": "user_role", "value": self.role}], + "user_id = :user_id", + [{"field": "user_id", "value": self.__user_id}]): + result = True + + return result + + def create_user(self, first_name: str, last_name: str, password: str, email_address: str, role="User") -> bool: + """Method to create and save the user in the database""" + result = False + if not self.__is_valid: + if len(password) > 0 and len(email_address) and len(role) > 0: + user_salt = "".join(random.choices(string.ascii_uppercase + string.digits, k=10)) + + password_hash = SHA256Custom.SHA256Custom() + password_hash.update((password + user_salt).encode()) + + user_password = password_hash.hexdigest() + + db_connection = DBConn() + if db_connection.do_insert("users", + [{"field": "username", "value": self.username}, + {"field": "user_first_name", "value": first_name}, + {"field": "user_last_name", "value": last_name}, + {"field": "user_password", "value": user_password}, + {"field": "user_salt", "value": user_salt}, + {"field": "user_email", "value": email_address}, + {"field": "user_role", "value": role}, + {"field": "user_date_created", "value": get_unix_timestamp()}]): + self.__load_user() + result = True + + return result + + def unlock(self) -> None: + """Method to unlock a user if they have been locked out by bad login attempts""" + if self.__is_valid and not self.__is_logged_in: + db_connection = DBConn() + # It just sets the status values to another failed integer so they no longer get counted + db_connection.do_update("login_attempts", + [{"field": "login_attempt_status", "value": -2}], + "user_id = :user_id AND login_attempt_status = :bad_status", + [{"field": "user_id", "value": self.__user_id}, + {"field": "bad_status", "value": -1}]) + + def log_login_attempt(self, status, ip_address) -> None: + """Method for getting the current login attempt count from the database""" + if self.__is_valid: + db_connection = DBConn() + db_connection.do_insert("login_attempts", + [{"field": "user_id", "value": self.__user_id}, + {"field": "login_attempt_ip", "value": ip_address}, + {"field": "login_attempt_status", "value": status}, + {"field": "login_attempt_timestamp", "value": get_unix_timestamp()}]) + + def bad_login_attempt_count(self) -> int: + """Method to count bad login attempts from the user within the set timeframe""" + result = 0 + if self.__user_id: + db_connection = DBConn() + attempt_count = db_connection.do_select("login_attempts", "col", "COUNT(login_attempt_id)", + "user_id = :user_id AND " + "login_attempt_status = :bad_login_status AND " + "login_attempt_timestamp BETWEEN :start_time AND :end_time", + [{"field": "user_id", "value": self.__user_id}, + {"field": "bad_login_status", "value": -1}, + {"field": "start_time", "value": get_unix_timestamp(-300)}, + {"field": "end_time", "value": get_unix_timestamp()}]) + + if attempt_count is not None: + result = attempt_count + + return result diff --git a/Server/dbSetup.py b/Server/dbSetup.py new file mode 100644 index 0000000..aa68c0c --- /dev/null +++ b/Server/dbSetup.py @@ -0,0 +1,217 @@ +import Crypto +import hashlib +import SHA256Custom + +from DBConn import DBConn +from User import User + +# This is just a setup and test script to create the initial database and to test out some of the features + +# should create the DB file if missing +db_connection = DBConn() + +create_system_settings_sql = "CREATE TABLE IF NOT EXISTS system_settings(" \ + "setting_name TEXT PRIMARY KEY," \ + "setting_value TEXT" \ + ");" + +create_user_table_sql = "CREATE TABLE IF NOT EXISTS users (" \ + "user_id INTEGER PRIMARY KEY," \ + "username TEXT UNIQUE," \ + "user_first_name TEXT," \ + "user_last_name TEXT," \ + "user_password TEXT NOT NULL," \ + "user_salt TEXT NOT NULL," \ + "user_email TEXT NOT NULL," \ + "user_role TEXT NOT NULL," \ + "user_date_created INTEGER NOT NULL" \ + ");" + +create_user_login_attempt_table = "CREATE TABLE IF NOT EXISTS login_attempts(" \ + "login_attempt_id INTEGER PRIMARY KEY," \ + "user_id INTEGER NOT NULL," \ + "login_attempt_ip TEXT," \ + "login_attempt_status INTEGER NOT NULL," \ + "login_attempt_timestamp INTEGER NOT NULL," \ + "FOREIGN KEY(user_id) REFERENCES users(user_id)" \ + ");" + +create_session_table = "CREATE TABLE IF NOT EXISTS sessions(" \ + "session_id TEST PRIMARY KEY," \ + "session_unique_ref TEXT UNIQUE NOT NULL," \ + "user_id INTEGER NOT NULL," \ + "session_ip TEXT NOT NULL," \ + "session_data TEXT," \ + "session_state TEXT NOT NULL," \ + "session_last_action_date INTEGER NOT NULL," \ + "FOREIGN KEY(user_id) REFERENCES users(user_id)" \ + ");" + +create_raw_mail_table_sql = "CREATE TABLE IF NOT EXISTS raw_mail (" \ + "raw_id INTEGER PRIMARY KEY," \ + "from_id INTEGER NOT NULL," \ + "raw_mail TEXT NOT NULL," \ + "raw_date_sent INTEGER NOT NULL," \ + "FOREIGN KEY(from_id) REFERENCES users(user_id)" \ + ");" + +create_mail_table_sql = "CREATE TABLE IF NOT EXISTS mail_store (" \ + "mail_id INTEGER PRIMARY KEY," \ + "raw_id INTEGER NOT NULL," \ + "from_id INTEGER NOT NULL," \ + "to_id INTEGER," \ + "mail_subject TEXT NOT NULL," \ + "mail_body TEXT NOT NULL," \ + "mail_read TEXT DEFAULT 0," \ + "mail_date_sent INTEGER NOT NULL," \ + "FOREIGN KEY(from_id) REFERENCES users(user_id)," \ + "FOREIGN KEY(to_id) REFERENCES users(user_id)" \ + ");" + +create_server_log_sql = "CREATE TABLE IF NOT EXISTS server_logs(" \ + "log_id INTEGER PRIMARY KEY," \ + "session_ref TEXT," \ + "log_action_dir TEXT," \ + "log_action TEXT," \ + "log_client_ip TEXT," \ + "log_date INTEGER" \ + ");" + +# Create the system settings and set the actual system settings needed +if db_connection.do_generic(create_system_settings_sql): + db_connection.set_system_setting("SERVER_DOMAIN", "server.local") + db_connection.set_system_setting("MOTD", "Hello welcome to my SMTP server!") + + help_main_body = "This is the main help text, i cant think of anything to put here,\n" + help_main_body += "so i'll fill it with some lorem ipsums, but you can also look at \n" + help_main_body += "sub help files using [USEFUL], [LOREM] or [MAIL]." + + db_connection.set_system_setting("HELPMAIN", help_main_body) + + help_lorem_body = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Nunc sit amet arcu tortor. \n" \ + "Maecenas ante lacus, vehicula id volutpat sed, auctor eget nunc. Mauris id consequat \n" \ + "augue. Sed molestie diam quis sapien vehicula, non ultricies tellus interdum. \n" \ + "Pellentesque vel dui enim. Integer sed magna blandit, consectetur felis id, lacinia nisi. \n" \ + "Phasellus luctus imperdiet orci quis hendrerit. Phasellus vel mattis libero. Suspendisse \n" \ + "ornare porttitor dolor, eu aliquet quam laoreet at. Integer tincidunt pretium pretium. \n" \ + "In auctor sapien a erat suscipit, quis semper ex fermentum. Vestibulum bibendum nulla ut \n" \ + "vestibulum fringilla. Fusce est risus, rhoncus feugiat tellus vitae, feugiat finibus \n" \ + "lacus. Maecenas gravida ornare imperdiet. Mauris nec est nec mi dapibus dictum viverra \n" \ + "at libero. Morbi blandit sed lacus a semper." + + db_connection.set_system_setting("HELPLOREM", help_lorem_body) + + help_mail_body = "When you compose a mail message it will take you through 3 sections, the first is to \n" \ + "collect the email addresses you wish to send to, \n" \ + "leaving the prompt blank will continue to the next section.\n" \ + "The next section will ask you to enter a subject for your email, this is straight forward.\n" \ + "The last section will allow you to enter the body of the email, enter will start a new \n" \ + "paragraph, if you leave the input blank and hit enter the email will attempt to send." + + db_connection.set_system_setting("HELPMAIL", help_mail_body) + + help_useful_body = "Test Email account email address list:\n" \ + "admin@server.local,\n" \ + "testuser@server.local,\n" \ + "craig.smith@server.local,\n" \ + "g.lemon@server.local,\n" \ + "v.king@server.local,\n" \ + "j.chong@server.local" \ + + db_connection.set_system_setting("HELPUSEFUL", help_useful_body) + +# Create the user table and generate the admin and some fake users +if db_connection.do_generic(create_user_table_sql): + if db_connection.do_generic(create_user_login_attempt_table) and db_connection.do_generic(create_session_table): + + # Set up users and do some class tests + admin_user = User("admin") + if admin_user.create_user("Jeff", "Jones", "admin", "admin@server.local", "Admin"): + print("Admin user created.") + + normal_user = User("testuser") + if normal_user.create_user("Adam", "David", "password", "testuser@server.local"): + print("Test user created.") + + user_1 = User("user1") + if user_1.create_user("Craig", "Smith", "user", "craig.smith@server.local"): + print("Test user created.") + + user_2 = User("user2") + if user_2.create_user("Greg", "Lemon", "user", "g.lemon@server.local"): + print("Test user created.") + + user_3 = User("user3") + if user_3.create_user("Vadim", "King", "user", "v.king@server.local"): + print("Test user created.") + + user_4 = User("user4") + if user_4.create_user("Jim", "Chong", "user", "j.chong@server.local"): + print("Test user created.") + + print(admin_user.__dict__) + print(normal_user.__dict__) + + user_test = User("admin") + for i in range(0, 5): + print(user_test.login("nothing", "127.0.0.1")) + print(f"Attempt {i+1}, count: {user_test.bad_login_attempt_count()}\n") + + print(user_test.login("admin", "127.0.0.1")) + print(f"Attempt 6, count: {user_test.bad_login_attempt_count()}\n") + + print("Unlocking the user account\n") + user_test.unlock() + + print(user_test.login("admin", "127.0.0.1")) + print(f"Attempt 7, count: {user_test.bad_login_attempt_count()}\n") + print(user_test.__dict__) + + user_test.email_address = "admin@email.com" + user_test.save_current_changes() + print(user_test.__dict__) + + user_test.email_address = "admin@server.local" + user_test.save_current_changes() + +# Create the mail store and raw mail tables for holding the emails +if db_connection.do_generic(create_raw_mail_table_sql): + if db_connection.do_generic(create_mail_table_sql): + if db_connection.do_generic(create_server_log_sql): + pass + +# Testing the Encrypting and RSA class +crypto_test = Crypto.Receiver() +crypto_test.generate_keys() + +print(f"Public: {crypto_test.get_public_key_pair()}") +print(f"Private: {crypto_test.get_private_key_pair()}") + +decrypto_test = Crypto.Transmitter(crypto_test.get_public_key_pair()) + +print(f"Public Sent: {crypto_test.get_public_key_pair(False)}") +print(f"Public Recv: {decrypto_test.get_current_key_pair()}") + +print("String in: Hello world!") +enc_string = decrypto_test.encrypt_string(b"Hello world!") +print(f"Encrypted String: {enc_string}") + +dec_string = crypto_test.decrypt_string(enc_string.decode()) +print(f"Decrypted String: {dec_string}") + +hash_test_string = "Hello World!" + +py_hash = hashlib.sha256(hash_test_string.encode()) +hash_test_a = py_hash.hexdigest() + +custom_hash = SHA256Custom.SHA256Custom(hash_test_string.encode()) +hash_test_b = custom_hash.hexdigest() + +print(f"Input string: {hash_test_string}") +print(f"Python hash: {hash_test_a}") +print(f"Custom hash: {hash_test_b}") + +if hash_test_a == hash_test_b: + print("Matching Hash") +else: + print("Non matching Hash") diff --git a/Server/server.db b/Server/server.db new file mode 100644 index 0000000000000000000000000000000000000000..09144d6f02193038baeeb74999554d72b74ca9b7 GIT binary patch literal 139264 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 NS*8@lte5%4{{=CXE3^Or literal 0 HcmV?d00001 diff --git a/Server/server.py b/Server/server.py new file mode 100644 index 0000000..e2a2291 --- /dev/null +++ b/Server/server.py @@ -0,0 +1,33 @@ +from SMTPServer import SMTPServer + +server_host = "0.0.0.0" +server_port = 50000 + +start_attempts = 0 +max_start_attempts = 3 +server_started = False +quit_flag = False + +if __name__ == "__main__": + while not quit_flag: + print(f"Starting the server on {server_host}:{server_port}") + + smtp_server = SMTPServer(server_host, server_port) + + # Give the server 3 attempts to start, otherwise exit the program + if not server_started: + server_started = smtp_server.start_server() + start_attempts += 1 + + if server_started: + # Open the server and start the main script loop + smtp_server.open_server() + if not smtp_server.active: + quit_flag = True + + else: + if start_attempts == max_start_attempts: + print("Could not start the server, Exiting...") + quit_flag = True + + print("Server Ended, Thank you.")