commit b31d19e336ddee9e792a8aa574113a983f85f472 Author: iDunnoDev Date: Sat Jul 5 14:24:22 2025 +0100 Initial Commit 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 0000000..09144d6 Binary files /dev/null and b/Server/server.db differ 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.")